@runloop/rl-cli 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +29 -8
  2. package/dist/commands/blueprint/list.js +97 -28
  3. package/dist/commands/blueprint/prune.js +258 -0
  4. package/dist/commands/devbox/create.js +3 -0
  5. package/dist/commands/devbox/list.js +44 -65
  6. package/dist/commands/menu.js +2 -1
  7. package/dist/commands/network-policy/create.js +27 -0
  8. package/dist/commands/network-policy/delete.js +21 -0
  9. package/dist/commands/network-policy/get.js +15 -0
  10. package/dist/commands/network-policy/list.js +494 -0
  11. package/dist/commands/object/list.js +516 -24
  12. package/dist/commands/snapshot/list.js +90 -29
  13. package/dist/components/Banner.js +109 -8
  14. package/dist/components/ConfirmationPrompt.js +45 -0
  15. package/dist/components/DevboxActionsMenu.js +42 -6
  16. package/dist/components/DevboxCard.js +1 -1
  17. package/dist/components/DevboxCreatePage.js +95 -81
  18. package/dist/components/DevboxDetailPage.js +218 -272
  19. package/dist/components/LogsViewer.js +8 -1
  20. package/dist/components/MainMenu.js +35 -4
  21. package/dist/components/NavigationTips.js +24 -0
  22. package/dist/components/NetworkPolicyCreatePage.js +264 -0
  23. package/dist/components/OperationsMenu.js +9 -1
  24. package/dist/components/ResourceActionsMenu.js +5 -1
  25. package/dist/components/ResourceDetailPage.js +204 -0
  26. package/dist/components/ResourceListView.js +19 -2
  27. package/dist/components/StatusBadge.js +2 -2
  28. package/dist/components/Table.js +6 -8
  29. package/dist/components/form/FormActionButton.js +7 -0
  30. package/dist/components/form/FormField.js +7 -0
  31. package/dist/components/form/FormListManager.js +112 -0
  32. package/dist/components/form/FormSelect.js +34 -0
  33. package/dist/components/form/FormTextInput.js +8 -0
  34. package/dist/components/form/index.js +8 -0
  35. package/dist/hooks/useViewportHeight.js +38 -20
  36. package/dist/router/Router.js +23 -1
  37. package/dist/screens/BlueprintDetailScreen.js +337 -0
  38. package/dist/screens/MenuScreen.js +6 -0
  39. package/dist/screens/NetworkPolicyCreateScreen.js +7 -0
  40. package/dist/screens/NetworkPolicyDetailScreen.js +247 -0
  41. package/dist/screens/NetworkPolicyListScreen.js +7 -0
  42. package/dist/screens/ObjectDetailScreen.js +377 -0
  43. package/dist/screens/ObjectListScreen.js +7 -0
  44. package/dist/screens/SnapshotDetailScreen.js +208 -0
  45. package/dist/services/blueprintService.js +30 -11
  46. package/dist/services/networkPolicyService.js +108 -0
  47. package/dist/services/objectService.js +101 -0
  48. package/dist/services/snapshotService.js +39 -3
  49. package/dist/store/blueprintStore.js +4 -10
  50. package/dist/store/index.js +1 -0
  51. package/dist/store/networkPolicyStore.js +83 -0
  52. package/dist/store/objectStore.js +92 -0
  53. package/dist/store/snapshotStore.js +4 -8
  54. package/dist/utils/commands.js +58 -0
  55. package/package.json +2 -2
@@ -8,6 +8,7 @@ import { SpinnerComponent } from "../../components/Spinner.js";
8
8
  import { ErrorMessage } from "../../components/ErrorMessage.js";
9
9
  import { SuccessMessage } from "../../components/SuccessMessage.js";
10
10
  import { Breadcrumb } from "../../components/Breadcrumb.js";
11
+ import { NavigationTips } from "../../components/NavigationTips.js";
11
12
  import { Table, createTextColumn } from "../../components/Table.js";
12
13
  import { ActionsPopup } from "../../components/ActionsPopup.js";
13
14
  import { formatTimeAgo } from "../../components/ResourceListView.js";
@@ -18,6 +19,7 @@ import { useExitOnCtrlC } from "../../hooks/useExitOnCtrlC.js";
18
19
  import { useCursorPagination } from "../../hooks/useCursorPagination.js";
19
20
  import { DevboxCreatePage } from "../../components/DevboxCreatePage.js";
20
21
  import { useNavigation } from "../../store/navigationStore.js";
22
+ import { ConfirmationPrompt } from "../../components/ConfirmationPrompt.js";
21
23
  const DEFAULT_PAGE_SIZE = 10;
22
24
  const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
23
25
  const { exit: inkExit } = useApp();
@@ -32,6 +34,7 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
32
34
  const [operationError, setOperationError] = React.useState(null);
33
35
  const [operationLoading, setOperationLoading] = React.useState(false);
34
36
  const [showCreateDevbox, setShowCreateDevbox] = React.useState(false);
37
+ const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
35
38
  // Calculate overhead for viewport height
36
39
  const overhead = 13;
37
40
  const { viewportHeight, terminalWidth } = useViewportHeight({
@@ -40,11 +43,16 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
40
43
  });
41
44
  const PAGE_SIZE = viewportHeight;
42
45
  // All width constants
46
+ const fixedWidth = 6; // border + padding
43
47
  const idWidth = 25;
44
- const nameWidth = Math.max(15, terminalWidth >= 120 ? 30 : 25);
45
48
  const devboxWidth = 15;
46
49
  const timeWidth = 20;
47
50
  const showDevboxIdColumn = terminalWidth >= 100 && !devboxId;
51
+ // Name width uses remaining space after fixed columns
52
+ const baseWidth = fixedWidth + idWidth + timeWidth;
53
+ const optionalWidth = showDevboxIdColumn ? devboxWidth : 0;
54
+ const remainingWidth = terminalWidth - baseWidth - optionalWidth;
55
+ const nameWidth = Math.min(80, Math.max(15, remainingWidth));
48
56
  // Fetch function for pagination hook
49
57
  const fetchPage = React.useCallback(async (params) => {
50
58
  const client = getClient();
@@ -81,16 +89,25 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
81
89
  return result;
82
90
  }, [devboxId]);
83
91
  // Use the shared pagination hook
84
- const { items: snapshots, loading, navigating, error, currentPage, hasMore, hasPrev, totalCount, nextPage, prevPage, } = useCursorPagination({
92
+ const { items: snapshots, loading, navigating, error, currentPage, hasMore, hasPrev, totalCount, nextPage, prevPage, refresh, } = useCursorPagination({
85
93
  fetchPage,
86
94
  pageSize: PAGE_SIZE,
87
95
  getItemId: (snapshot) => snapshot.id,
88
96
  pollInterval: 2000,
89
- pollingEnabled: !showPopup && !executingOperation && !showCreateDevbox,
97
+ pollingEnabled: !showPopup &&
98
+ !executingOperation &&
99
+ !showCreateDevbox &&
100
+ !showDeleteConfirm,
90
101
  deps: [devboxId, PAGE_SIZE],
91
102
  });
92
103
  // Operations for snapshots
93
104
  const operations = React.useMemo(() => [
105
+ {
106
+ key: "view_details",
107
+ label: "View Details",
108
+ color: colors.primary,
109
+ icon: figures.pointer,
110
+ },
94
111
  {
95
112
  key: "create_devbox",
96
113
  label: "Create Devbox from Snapshot",
@@ -142,14 +159,13 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
142
159
  const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
143
160
  const startIndex = currentPage * PAGE_SIZE;
144
161
  const endIndex = startIndex + snapshots.length;
145
- const executeOperation = async () => {
162
+ const executeOperation = async (snapshot, operationKey) => {
146
163
  const client = getClient();
147
- const snapshot = selectedSnapshot;
148
164
  if (!snapshot)
149
165
  return;
150
166
  try {
151
167
  setOperationLoading(true);
152
- switch (executingOperation) {
168
+ switch (operationKey) {
153
169
  case "delete":
154
170
  await client.devboxes.deleteDiskSnapshot(snapshot.id);
155
171
  setOperationResult(`Snapshot ${snapshot.id} deleted successfully`);
@@ -167,10 +183,16 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
167
183
  // Handle operation result display
168
184
  if (operationResult || operationError) {
169
185
  if (input === "q" || key.escape || key.return) {
186
+ const wasDelete = executingOperation === "delete";
187
+ const hadError = operationError !== null;
170
188
  setOperationResult(null);
171
189
  setOperationError(null);
172
190
  setExecutingOperation(null);
173
191
  setSelectedSnapshot(null);
192
+ // Refresh the list after delete to show updated data
193
+ if (wasDelete && !hadError) {
194
+ setTimeout(() => refresh(), 0);
195
+ }
174
196
  }
175
197
  return;
176
198
  }
@@ -189,17 +211,34 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
189
211
  else if (key.return) {
190
212
  setShowPopup(false);
191
213
  const operationKey = operations[selectedOperation].key;
192
- if (operationKey === "create_devbox") {
214
+ if (operationKey === "view_details") {
215
+ navigate("snapshot-detail", {
216
+ snapshotId: selectedSnapshotItem.id,
217
+ });
218
+ }
219
+ else if (operationKey === "create_devbox") {
193
220
  setSelectedSnapshot(selectedSnapshotItem);
194
221
  setShowCreateDevbox(true);
195
222
  }
223
+ else if (operationKey === "delete") {
224
+ // Show delete confirmation
225
+ setSelectedSnapshot(selectedSnapshotItem);
226
+ setShowDeleteConfirm(true);
227
+ }
196
228
  else {
197
229
  setSelectedSnapshot(selectedSnapshotItem);
198
230
  setExecutingOperation(operationKey);
199
- // Execute immediately after state update
200
- setTimeout(() => executeOperation(), 0);
231
+ // Execute immediately with values passed directly
232
+ executeOperation(selectedSnapshotItem, operationKey);
201
233
  }
202
234
  }
235
+ else if (input === "v" && selectedSnapshotItem) {
236
+ // View details hotkey
237
+ setShowPopup(false);
238
+ navigate("snapshot-detail", {
239
+ snapshotId: selectedSnapshotItem.id,
240
+ });
241
+ }
203
242
  else if (key.escape || input === "q") {
204
243
  setShowPopup(false);
205
244
  setSelectedOperation(0);
@@ -211,11 +250,10 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
211
250
  setShowCreateDevbox(true);
212
251
  }
213
252
  else if (input === "d") {
214
- // Delete hotkey
253
+ // Delete hotkey - show confirmation
215
254
  setShowPopup(false);
216
255
  setSelectedSnapshot(selectedSnapshotItem);
217
- setExecutingOperation("delete");
218
- setTimeout(() => executeOperation(), 0);
256
+ setShowDeleteConfirm(true);
219
257
  }
220
258
  return;
221
259
  }
@@ -241,6 +279,12 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
241
279
  prevPage();
242
280
  setSelectedIndex(0);
243
281
  }
282
+ else if (key.return && selectedSnapshotItem) {
283
+ // Enter key navigates to detail view
284
+ navigate("snapshot-detail", {
285
+ snapshotId: selectedSnapshotItem.id,
286
+ });
287
+ }
244
288
  else if (input === "a" && selectedSnapshotItem) {
245
289
  setShowPopup(true);
246
290
  setSelectedOperation(0);
@@ -267,7 +311,22 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
267
311
  label: selectedSnapshot?.name || selectedSnapshot?.id || "Snapshot",
268
312
  },
269
313
  { label: operationLabel, active: true },
270
- ] }), _jsx(Header, { title: "Operation Result" }), operationResult && _jsx(SuccessMessage, { message: operationResult }), operationError && (_jsx(ErrorMessage, { message: "Operation failed", error: operationError })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter], [q], or [esc] to continue" }) })] }));
314
+ ] }), _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" }] })] }));
315
+ }
316
+ // Delete confirmation
317
+ if (showDeleteConfirm && selectedSnapshot) {
318
+ return (_jsx(ConfirmationPrompt, { title: "Delete Snapshot", message: `Are you sure you want to delete "${selectedSnapshot.name || selectedSnapshot.id}"?`, details: "This action cannot be undone.", breadcrumbItems: [
319
+ { label: "Snapshots" },
320
+ { label: selectedSnapshot.name || selectedSnapshot.id },
321
+ { label: "Delete", active: true },
322
+ ], onConfirm: () => {
323
+ setShowDeleteConfirm(false);
324
+ setExecutingOperation("delete");
325
+ executeOperation(selectedSnapshot, "delete");
326
+ }, onCancel: () => {
327
+ setShowDeleteConfirm(false);
328
+ setSelectedSnapshot(null);
329
+ } }));
271
330
  }
272
331
  // Operation loading state
273
332
  if (operationLoading && selectedSnapshot) {
@@ -311,30 +370,32 @@ const ListSnapshotsUI = ({ devboxId, onBack, onExit, }) => {
311
370
  : []),
312
371
  ] }), _jsx(ErrorMessage, { message: "Failed to list snapshots", error: error })] }));
313
372
  }
314
- // Empty state
315
- if (snapshots.length === 0) {
316
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
317
- { label: "Snapshots", active: !devboxId },
318
- ...(devboxId
319
- ? [{ label: `Devbox: ${devboxId}`, active: true }]
320
- : []),
321
- ] }), _jsxs(Box, { children: [_jsx(Text, { color: colors.warning, children: figures.info }), _jsx(Text, { children: " No snapshots found. Try: " }), _jsxs(Text, { color: colors.primary, bold: true, children: ["rli snapshot create ", "<devbox-id>"] })] })] }));
322
- }
323
373
  // Main list view
324
374
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
325
375
  { label: "Snapshots", active: !devboxId },
326
376
  ...(devboxId ? [{ label: `Devbox: ${devboxId}`, active: true }] : []),
327
- ] }), !showPopup && (_jsx(Table, { data: snapshots, keyExtractor: (snapshot) => snapshot.id, selectedIndex: selectedIndex, title: `snapshots[${totalCount}]`, columns: columns })), !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) => ({
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) => ({
328
378
  key: op.key,
329
379
  label: op.label,
330
380
  color: op.color,
331
381
  icon: op.icon,
332
- shortcut: op.key === "create_devbox"
333
- ? "c"
334
- : op.key === "delete"
335
- ? "d"
336
- : "",
337
- })), selectedOperation: selectedOperation, onClose: () => setShowPopup(false) }) })), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate"] }), (hasMore || hasPrev) && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 ", figures.arrowLeft, figures.arrowRight, " Page"] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [a] Actions"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 [Esc] Back"] })] })] }));
382
+ shortcut: op.key === "view_details"
383
+ ? "v"
384
+ : op.key === "create_devbox"
385
+ ? "c"
386
+ : op.key === "delete"
387
+ ? "d"
388
+ : "",
389
+ })), selectedOperation: selectedOperation, onClose: () => setShowPopup(false) }) })), _jsx(NavigationTips, { showArrows: true, tips: [
390
+ {
391
+ icon: `${figures.arrowLeft}${figures.arrowRight}`,
392
+ label: "Page",
393
+ condition: hasMore || hasPrev,
394
+ },
395
+ { key: "Enter", label: "Details" },
396
+ { key: "a", label: "Actions" },
397
+ { key: "Esc", label: "Back" },
398
+ ] })] }));
338
399
  };
339
400
  // Export the UI component for use in the main menu
340
401
  export { ListSnapshotsUI };
@@ -1,14 +1,115 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import React from "react";
3
- import { Box } from "ink";
2
+ import React, { useState, useEffect, useRef } from "react";
3
+ import { Box, Text } from "ink";
4
4
  import BigText from "ink-big-text";
5
5
  import Gradient from "ink-gradient";
6
6
  import { isLightMode } from "../utils/theme.js";
7
+ import { useViewportHeight } from "../hooks/useViewportHeight.js";
8
+ // Dramatic shades of green shimmer - wide range
9
+ const DARK_SHIMMER_COLORS = [
10
+ "#024A38", // Very very dark emerald
11
+ "#035544", //
12
+ "#036050", //
13
+ "#046C54", //
14
+ "#04765C", //
15
+ "#058164", //
16
+ "#058C6D", //
17
+ "#059669", // Deep emerald
18
+ "#08A076", //
19
+ "#0BA67B", //
20
+ "#0EAE84", //
21
+ "#10B981", // Runloop success green
22
+ "#14C793", // Lighter emerald
23
+ "#1CD7A7", //
24
+ "#24E0B5", //
25
+ "#30EAC0", //
26
+ "#40F5CC", // Very bright emerald
27
+ "#30EAC0", //
28
+ "#24E0B5", //
29
+ "#1CD7A7", //
30
+ "#14C793", // Lighter emerald
31
+ "#10B981", // Runloop success green
32
+ "#0EAE84", //
33
+ "#0BA67B", //
34
+ "#08A076", //
35
+ "#059669", // Deep emerald
36
+ "#058C6D", //
37
+ "#058164", //
38
+ "#04765C", //
39
+ "#046C54", //
40
+ "#036050", //
41
+ "#035544", //
42
+ ];
43
+ const LIGHT_SHIMMER_COLORS = [
44
+ "#034D3A", // Very very deep emerald
45
+ "#045540", //
46
+ "#055D46", //
47
+ "#065F46", //
48
+ "#046A50", //
49
+ "#047857", // Deep emerald
50
+ "#058360", //
51
+ "#058C68", //
52
+ "#059669", // Runloop light success green
53
+ "#08A076", //
54
+ "#0BA67B", //
55
+ "#0EAE84", //
56
+ "#10B981", // Medium emerald
57
+ "#14C793", // Lighter emerald
58
+ "#18D29F", //
59
+ "#1CDCA9", //
60
+ "#20E5B3", //
61
+ "#1CDCA9", //
62
+ "#18D29F", //
63
+ "#14C793", // Lighter emerald
64
+ "#10B981", // Medium emerald
65
+ "#0EAE84", //
66
+ "#0BA67B", //
67
+ "#08A076", //
68
+ "#059669", // Runloop light success green
69
+ "#058C68", //
70
+ "#058360", //
71
+ "#047857", // Deep emerald
72
+ "#046A50", //
73
+ "#065F46", //
74
+ "#055D46", //
75
+ "#045540", //
76
+ ];
77
+ // Pre-compute all rotated color frames at module load time
78
+ const precomputeFrames = (colors) => {
79
+ return colors.map((_, i) => [...colors.slice(i), ...colors.slice(0, i)]);
80
+ };
81
+ // Use every 2nd color to reduce frame count and minimize flickering
82
+ const DARK_FRAMES = precomputeFrames(DARK_SHIMMER_COLORS.filter((_, i) => i % 2 === 0));
83
+ const LIGHT_FRAMES = precomputeFrames(LIGHT_SHIMMER_COLORS.filter((_, i) => i % 2 === 0));
84
+ // Minimum width to show the full BigText banner (simple3d font needs ~80 chars for "RUNLOOP.ai")
85
+ const MIN_WIDTH_FOR_BIG_BANNER = 90;
86
+ // Animation interval in ms
87
+ const SHIMMER_INTERVAL = 400;
7
88
  export const Banner = React.memo(() => {
8
- // Use theme-aware gradient colors
9
- // In light mode, use darker/deeper colors for better contrast on light backgrounds
10
- // "teen" has darker colors (blue/purple) that work well on light backgrounds
11
- // In dark mode, use the vibrant "vice" gradient (pink/cyan) that works well on dark backgrounds
12
- const gradientName = isLightMode() ? "teen" : "vice";
13
- return (_jsx(Box, { flexDirection: "column", alignItems: "flex-start", paddingX: 1, children: _jsx(Gradient, { name: gradientName, children: _jsx(BigText, { text: "RUNLOOP.ai", font: "simple3d" }) }) }));
89
+ const [frameIndex, setFrameIndex] = useState(0);
90
+ const frames = isLightMode() ? LIGHT_FRAMES : DARK_FRAMES;
91
+ const { terminalWidth } = useViewportHeight();
92
+ const timeoutRef = useRef(null);
93
+ // Determine if we should show compact mode
94
+ const isCompact = terminalWidth < MIN_WIDTH_FOR_BIG_BANNER;
95
+ useEffect(() => {
96
+ const tick = () => {
97
+ setFrameIndex((prev) => (prev + 1) % frames.length);
98
+ timeoutRef.current = setTimeout(tick, SHIMMER_INTERVAL);
99
+ };
100
+ timeoutRef.current = setTimeout(tick, SHIMMER_INTERVAL);
101
+ return () => {
102
+ if (timeoutRef.current) {
103
+ clearTimeout(timeoutRef.current);
104
+ }
105
+ };
106
+ }, [frames.length]);
107
+ // Use pre-computed frame - no array operations during render
108
+ const currentColors = frames[frameIndex];
109
+ // Compact banner for narrow terminals
110
+ if (isCompact) {
111
+ return (_jsx(Box, { flexDirection: "column", alignItems: "flex-start", paddingX: 1, children: _jsx(Gradient, { colors: currentColors, children: _jsx(Text, { bold: true, children: "\u25C6 RUNLOOP.ai" }) }) }));
112
+ }
113
+ // Full banner for wide terminals
114
+ return (_jsx(Box, { flexDirection: "column", alignItems: "flex-start", paddingX: 1, children: _jsx(Gradient, { colors: currentColors, children: _jsx(BigText, { text: "RUNLOOP.ai", font: "simple3d" }) }) }));
14
115
  });
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * ConfirmationPrompt - Reusable confirmation dialog for destructive actions
4
+ */
5
+ import React from "react";
6
+ import { Box, Text, useInput } from "ink";
7
+ import figures from "figures";
8
+ import { Header } from "./Header.js";
9
+ import { Breadcrumb } from "./Breadcrumb.js";
10
+ import { NavigationTips } from "./NavigationTips.js";
11
+ import { colors } from "../utils/theme.js";
12
+ export const ConfirmationPrompt = ({ title, message, details, breadcrumbItems, onConfirm, onCancel, confirmLabel = "Yes, delete", cancelLabel = "No, cancel", confirmColor = colors.error, }) => {
13
+ // Default to "No" (index 1)
14
+ const [selectedIndex, setSelectedIndex] = React.useState(1);
15
+ useInput((input, key) => {
16
+ if (key.upArrow || key.leftArrow) {
17
+ setSelectedIndex(0);
18
+ }
19
+ else if (key.downArrow || key.rightArrow) {
20
+ setSelectedIndex(1);
21
+ }
22
+ else if (key.return) {
23
+ if (selectedIndex === 0) {
24
+ onConfirm();
25
+ }
26
+ else {
27
+ onCancel();
28
+ }
29
+ }
30
+ else if (key.escape || input === "q") {
31
+ onCancel();
32
+ }
33
+ else if (input === "y" || input === "Y") {
34
+ onConfirm();
35
+ }
36
+ else if (input === "n" || input === "N") {
37
+ onCancel();
38
+ }
39
+ });
40
+ return (_jsxs(_Fragment, { children: [breadcrumbItems && _jsx(Breadcrumb, { items: breadcrumbItems }), _jsx(Header, { title: title }), _jsxs(Box, { flexDirection: "column", marginTop: 1, paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: colors.warning, children: [figures.warning, " ", message] }) }), details && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: details }) })), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: selectedIndex === 0 ? confirmColor : colors.textDim, children: [selectedIndex === 0 ? figures.pointer : " ", " "] }), _jsx(Text, { color: selectedIndex === 0 ? confirmColor : colors.textDim, bold: selectedIndex === 0, children: confirmLabel }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[y]"] })] }), _jsxs(Box, { children: [_jsxs(Text, { color: selectedIndex === 1 ? colors.success : colors.textDim, children: [selectedIndex === 1 ? figures.pointer : " ", " "] }), _jsx(Text, { color: selectedIndex === 1 ? colors.success : colors.textDim, bold: selectedIndex === 1, children: cancelLabel }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[n]"] })] })] }), _jsx(NavigationTips, { showArrows: true, arrowLabel: "Select", marginTop: 1, paddingX: 0, tips: [
41
+ { key: "Enter", label: "Confirm" },
42
+ { key: "y/n", label: "Quick select" },
43
+ { key: "Esc", label: "Cancel" },
44
+ ] })] })] }));
45
+ };
@@ -8,6 +8,8 @@ import { SpinnerComponent } from "./Spinner.js";
8
8
  import { ErrorMessage } from "./ErrorMessage.js";
9
9
  import { SuccessMessage } from "./SuccessMessage.js";
10
10
  import { Breadcrumb } from "./Breadcrumb.js";
11
+ import { NavigationTips } from "./NavigationTips.js";
12
+ import { ConfirmationPrompt } from "./ConfirmationPrompt.js";
11
13
  import { colors } from "../utils/theme.js";
12
14
  import { useViewportHeight } from "../hooks/useViewportHeight.js";
13
15
  import { useNavigation } from "../store/navigationStore.js";
@@ -27,6 +29,7 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
27
29
  const [operationError, setOperationError] = React.useState(null);
28
30
  const [execScroll, setExecScroll] = React.useState(0);
29
31
  const [copyStatus, setCopyStatus] = React.useState(null);
32
+ const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false);
30
33
  // Calculate viewport for exec output:
31
34
  // - Breadcrumb (3 lines + marginBottom): 4 lines
32
35
  // - Command header (border + 2 content + border + marginBottom): 5 lines
@@ -144,15 +147,22 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
144
147
  return op.key === "logs" || op.key === "delete";
145
148
  })
146
149
  : allOperations;
147
- // Auto-execute operations that don't need input
150
+ // Auto-execute operations that don't need input (except delete which needs confirmation)
148
151
  React.useEffect(() => {
149
- const autoExecuteOps = ["delete", "ssh", "logs", "suspend", "resume"];
152
+ const autoExecuteOps = ["ssh", "logs", "suspend", "resume"];
150
153
  if (executingOperation &&
151
154
  autoExecuteOps.includes(executingOperation) &&
152
155
  !loading &&
153
156
  devbox) {
154
157
  executeOperation();
155
158
  }
159
+ // Show confirmation for delete
160
+ if (executingOperation === "delete" &&
161
+ !loading &&
162
+ devbox &&
163
+ !showDeleteConfirm) {
164
+ setShowDeleteConfirm(true);
165
+ }
156
166
  }, [executingOperation]);
157
167
  // Handle Ctrl+C to exit
158
168
  useExitOnCtrlC();
@@ -415,6 +425,21 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
415
425
  }
416
426
  };
417
427
  const operationLabel = operations.find((o) => o.key === executingOperation)?.label || "Operation";
428
+ // Show delete confirmation
429
+ if (showDeleteConfirm) {
430
+ return (_jsx(ConfirmationPrompt, { title: "Shutdown Devbox", message: `Are you sure you want to shutdown "${devbox.name || devbox.id}"?`, details: "The devbox will be terminated and all unsaved data will be lost.", breadcrumbItems: [
431
+ ...breadcrumbItems.slice(0, -1),
432
+ { label: devbox.name || devbox.id },
433
+ { label: "Shutdown", active: true },
434
+ ], confirmLabel: "Yes, shutdown", onConfirm: () => {
435
+ setShowDeleteConfirm(false);
436
+ executeOperation();
437
+ }, onCancel: () => {
438
+ setShowDeleteConfirm(false);
439
+ setExecutingOperation(null);
440
+ onBack();
441
+ } }));
442
+ }
418
443
  // Operation result display
419
444
  if (operationResult || operationError) {
420
445
  // Check for custom exec rendering
@@ -445,7 +470,12 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
445
470
  const isStderr = actualIndex >= stdoutLines.length;
446
471
  const lineColor = isStderr ? colors.error : colors.text;
447
472
  return (_jsx(Box, { children: _jsx(Text, { color: lineColor, children: line }) }, index));
448
- })] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", allLines.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "lines"] }), allLines.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, allLines.length), " of", " ", allLines.length] }), hasLess && (_jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] })), hasMore && (_jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }))] })), stdout && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.success, dimColor: true, children: ["stdout: ", stdoutLines.length, " lines"] })] })), stderr && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.error, dimColor: true, children: ["stderr: ", stderrLines.length, " lines"] })] })), copyStatus && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: colors.success, bold: true, children: copyStatus })] }))] }), _jsx(Box, { marginTop: 1, paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [g] Top \u2022 [G] Bottom \u2022 [c] Copy \u2022 [Enter], [q], or [esc] Back"] }) })] }));
473
+ })] }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", allLines.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "lines"] }), allLines.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, allLines.length), " of", " ", allLines.length] }), hasLess && (_jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] })), hasMore && (_jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }))] })), stdout && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.success, dimColor: true, children: ["stdout: ", stdoutLines.length, " lines"] })] })), stderr && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.error, dimColor: true, children: ["stderr: ", stderrLines.length, " lines"] })] })), copyStatus && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: colors.success, bold: true, children: copyStatus })] }))] }), _jsx(NavigationTips, { showArrows: true, tips: [
474
+ { key: "g", label: "Top" },
475
+ { key: "G", label: "Bottom" },
476
+ { key: "c", label: "Copy" },
477
+ { key: "Enter/q/esc", label: "Back" },
478
+ ] })] }));
449
479
  }
450
480
  // Check for custom logs rendering
451
481
  if (operationResult &&
@@ -470,7 +500,7 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
470
500
  }
471
501
  }, title: "Logs" }));
472
502
  }
473
- 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(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter], [q], or [esc] to continue" }) })] }));
503
+ 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" }] })] }));
474
504
  }
475
505
  // Operation input mode
476
506
  if (executingOperation && devbox) {
@@ -514,14 +544,20 @@ export const DevboxActionsMenu = ({ devbox, onBack, breadcrumbItems = [
514
544
  ? "/path/to/file"
515
545
  : executingOperation === "tunnel"
516
546
  ? "8080"
517
- : "my-snapshot" }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: "Press [Enter] to execute \u2022 [q or esc] Cancel" }) })] })] }));
547
+ : "my-snapshot" }) }), _jsx(NavigationTips, { tips: [
548
+ { key: "Enter", label: "Execute" },
549
+ { key: "q/esc", label: "Cancel" },
550
+ ] })] })] }));
518
551
  }
519
552
  // Operations selection mode - only show if not skipping
520
553
  if (!skipOperationsMenu || !executingOperation) {
521
554
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Operations"] }), _jsx(Box, { flexDirection: "column", children: operations.map((op, index) => {
522
555
  const isSelected = index === selectedOperation;
523
556
  return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? colors.primary : colors.textDim, children: [isSelected ? figures.pointer : " ", " "] }), _jsxs(Text, { color: isSelected ? op.color : colors.textDim, bold: isSelected, children: [op.icon, " ", op.label] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", op.shortcut, "]"] })] }, op.key));
524
- }) })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [Enter] Select \u2022 [q] Back"] }) })] }));
557
+ }) })] }), _jsx(NavigationTips, { showArrows: true, paddingX: 0, tips: [
558
+ { key: "Enter", label: "Select" },
559
+ { key: "q", label: "Back" },
560
+ ] })] }));
525
561
  }
526
562
  // If skipOperationsMenu is true and executingOperation is set, show loading while it executes
527
563
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsx(SpinnerComponent, { message: "Loading..." })] }));
@@ -14,7 +14,7 @@ export const DevboxCard = ({ id, name, status, createdAt, }) => {
14
14
  case "suspended":
15
15
  return { icon: figures.circle, color: colors.textDim };
16
16
  case "failed":
17
- return { icon: figures.cross, color: colors.error };
17
+ return { icon: figures.warning, color: colors.error };
18
18
  default:
19
19
  return { icon: figures.circle, color: colors.textDim };
20
20
  }