@runloop/rl-cli 0.9.0 → 1.0.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.
package/README.md CHANGED
@@ -4,7 +4,13 @@
4
4
  [![CI](https://github.com/runloopai/rl-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/runloopai/rl-cli/actions/workflows/ci.yml)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
6
 
7
- A beautiful CLI for managing Runloop built with Ink and TypeScript. Use it as an **interactive command-line application** with rich UI components, or as a **traditional CLI** for scripting and automation.
7
+ An interactive CLI for interacting with the [Runloop.ai](https://runloop.ai) platform. Use it as an **interactive command-line application** with rich UI components, or as a **traditional CLI** for scripting and automation.
8
+
9
+ 📖 **[Full Documentation](https://docs.runloop.ai/docs/tools/cli)**
10
+
11
+ <p align="center">
12
+ <img src="https://raw.githubusercontent.com/runloopai/rl-cli/main/misc/demo.gif" alt="Runloop CLI Demo" width="800">
13
+ </p>
8
14
 
9
15
  ## Quick Example
10
16
 
@@ -23,8 +29,8 @@ rli devbox delete <devbox-id>
23
29
 
24
30
  - ⚡ Fast and responsive with pagination
25
31
  - 📦 Manage devboxes, snapshots, and blueprints
26
- - 🚀 Execute commands in devboxes
27
- - 🎯 Organized command structure with aliases
32
+ - 🚀 Execute commands, ssh, view logs in devboxes
33
+ - 🎯 Traditional CLI with text, json, and yaml output modes.
28
34
  - 🤖 **Model Context Protocol (MCP) server for AI integration**
29
35
 
30
36
  ## Installation
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React from "react";
3
3
  import { Box, Text, useInput, useApp } from "ink";
4
4
  import TextInput from "ink-text-input";
@@ -149,8 +149,21 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
149
149
  const ABSOLUTE_MAX_NAME = 80;
150
150
  const ABSOLUTE_MAX_ID = 50;
151
151
  const columns = [
152
+ // Status icon column - visual indicator for quick scanning
153
+ {
154
+ key: "statusIcon",
155
+ label: "",
156
+ width: statusIconWidth,
157
+ render: (devbox, _index, isSelected) => {
158
+ const statusDisplay = getStatusDisplay(devbox?.status);
159
+ const statusColor = statusDisplay.color === colors.textDim
160
+ ? colors.info
161
+ : statusDisplay.color;
162
+ return (_jsxs(Text, { color: isSelected ? "white" : statusColor, bold: true, dimColor: false, inverse: isSelected, wrap: "truncate", children: [statusDisplay.icon, " "] }));
163
+ },
164
+ },
152
165
  createTextColumn("name", "Name", (devbox) => {
153
- const name = String(devbox?.name || devbox?.id || "");
166
+ const name = String(devbox?.name || "");
154
167
  const safeMax = Math.min(nameWidth || 15, ABSOLUTE_MAX_NAME);
155
168
  return name.length > safeMax
156
169
  ? name.substring(0, Math.max(1, safeMax - 3)) + "..."
@@ -171,14 +184,22 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
171
184
  dimColor: false,
172
185
  bold: false,
173
186
  }),
174
- createTextColumn("status", "Status", (devbox) => {
175
- const statusDisplay = getStatusDisplay(devbox?.status);
176
- const text = String(statusDisplay?.text || "-");
177
- return text.length > 20 ? text.substring(0, 17) + "..." : text;
178
- }, {
187
+ // Status text column with color matching the icon
188
+ {
189
+ key: "status",
190
+ label: "Status",
179
191
  width: statusTextWidth,
180
- dimColor: false,
181
- }),
192
+ render: (devbox, _index, isSelected) => {
193
+ const statusDisplay = getStatusDisplay(devbox?.status);
194
+ const statusColor = statusDisplay.color === colors.textDim
195
+ ? colors.info
196
+ : statusDisplay.color;
197
+ const safeWidth = Math.max(1, statusTextWidth);
198
+ const truncated = statusDisplay.text.slice(0, safeWidth);
199
+ const padded = truncated.padEnd(safeWidth, " ");
200
+ return (_jsx(Text, { color: isSelected ? "white" : statusColor, bold: true, dimColor: false, inverse: isSelected, wrap: "truncate", children: padded }));
201
+ },
202
+ },
182
203
  createTextColumn("created", "Created", (devbox) => {
183
204
  const time = formatTimeAgo(devbox?.create_time_ms || Date.now());
184
205
  const text = String(time || "-");
@@ -217,6 +238,7 @@ const ListDevboxesUI = ({ status, onBack, onExit, onNavigateToDetail, }) => {
217
238
  }
218
239
  return columns;
219
240
  }, [
241
+ statusIconWidth,
220
242
  nameWidth,
221
243
  idWidth,
222
244
  statusTextWidth,
@@ -15,12 +15,12 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, initial
15
15
  const [currentField, setCurrentField] = React.useState("create");
16
16
  const [formData, setFormData] = React.useState({
17
17
  name: "",
18
- architecture: "arm64",
18
+ architecture: "x86_64",
19
19
  resource_size: "SMALL",
20
20
  custom_cpu: "",
21
21
  custom_memory: "",
22
22
  custom_disk: "",
23
- keep_alive: "3600",
23
+ keep_alive: "3600", // 1 hour
24
24
  metadata: {},
25
25
  blueprint_id: initialBlueprintId || "",
26
26
  snapshot_id: initialSnapshotId || "",
@@ -381,7 +381,7 @@ export const DevboxCreatePage = ({ onBack, onCreate, initialBlueprintId, initial
381
381
  if (field.type === "metadata") {
382
382
  if (!inMetadataSection) {
383
383
  // Collapsed view
384
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 0, children: [_jsxs(Box, { children: [_jsxs(Text, { color: isActive ? colors.primary : colors.textDim, children: [isActive ? figures.pointer : " ", " ", field.label, ":", " "] }), _jsxs(Text, { color: colors.text, children: [Object.keys(formData.metadata).length, " item(s)"] }), isActive && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[Enter to manage]"] }))] }), Object.keys(formData.metadata).length > 0 && (_jsx(Box, { marginLeft: 2, children: _jsx(MetadataDisplay, { metadata: formData.metadata, showBorder: false }) }))] }, field.key));
384
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 0, children: [_jsxs(Box, { children: [_jsxs(Text, { color: isActive ? colors.primary : colors.textDim, children: [isActive ? figures.pointer : " ", " ", field.label, ":", " "] }), _jsxs(Text, { color: colors.text, children: [Object.keys(formData.metadata).length, " item(s)"] }), isActive && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[Enter to manage]"] }))] }), Object.keys(formData.metadata).length > 0 && (_jsx(Box, { marginLeft: 2, children: _jsx(MetadataDisplay, { metadata: formData.metadata, title: "", showBorder: false, compact: true }) }))] }, field.key));
385
385
  }
386
386
  // Expanded metadata section view
387
387
  const metadataKeys = Object.keys(formData.metadata);
@@ -4,7 +4,6 @@ import { Box, Text, useInput } from "ink";
4
4
  import figures from "figures";
5
5
  import { Header } from "./Header.js";
6
6
  import { StatusBadge } from "./StatusBadge.js";
7
- import { MetadataDisplay } from "./MetadataDisplay.js";
8
7
  import { Breadcrumb } from "./Breadcrumb.js";
9
8
  import { DevboxActionsMenu } from "./DevboxActionsMenu.js";
10
9
  import { StateHistory } from "./StateHistory.js";
@@ -33,6 +32,12 @@ const formatTimeAgo = (timestamp) => {
33
32
  const years = Math.floor(months / 12);
34
33
  return `${years}y ago`;
35
34
  };
35
+ // Truncate long strings to prevent layout issues
36
+ const truncateString = (str, maxLength) => {
37
+ if (str.length <= maxLength)
38
+ return str;
39
+ return str.substring(0, maxLength - 3) + "...";
40
+ };
36
41
  export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, }) => {
37
42
  const isMounted = React.useRef(true);
38
43
  // Track mounted state
@@ -360,7 +365,7 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, }) => {
360
365
  lines.push(_jsxs(Text, { color: colors.error, dimColor: true, children: [" ", "Failure Reason: ", selectedDevbox.failure_reason] }, "status-fail"));
361
366
  }
362
367
  if (selectedDevbox.shutdown_reason) {
363
- lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Shutdown Reason: ", selectedDevbox.shutdown_reason] }, "status-shut"));
368
+ lines.push(_jsxs(Text, { dimColor: true, children: [" ", "Shutdown Initiator: ", selectedDevbox.shutdown_reason] }, "status-shut"));
364
369
  }
365
370
  lines.push(_jsx(Text, { children: " " }, "status-space"));
366
371
  }
@@ -425,16 +430,61 @@ export const DevboxDetailPage = ({ devbox: initialDevbox, onBack, }) => {
425
430
  return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: [
426
431
  { label: "Devboxes" },
427
432
  { label: selectedDevbox.name || selectedDevbox.id, active: true },
428
- ] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: colors.primary, bold: true, children: selectedDevbox.name || selectedDevbox.id }), _jsx(Text, { children: " " }), _jsx(StatusBadge, { status: selectedDevbox.status }), _jsxs(Text, { color: colors.idColor, children: [" \u2022 ", selectedDevbox.id] })] }), _jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: formattedCreateTime }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "(", createTimeAgo, ")"] })] }), uptime !== null && selectedDevbox.status === "running" && (_jsxs(Box, { children: [_jsxs(Text, { color: colors.success, dimColor: true, children: ["Uptime:", " ", uptime < 60
433
+ ] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", flexWrap: "wrap", children: [_jsx(Text, { color: colors.primary, bold: true, children: truncateString(selectedDevbox.name || selectedDevbox.id, Math.max(20, detailViewport.terminalWidth - 35)) }), selectedDevbox.name && (_jsxs(Text, { color: colors.idColor, children: [" \u2022 ", selectedDevbox.id] }))] }), _jsxs(Box, { children: [_jsx(StatusBadge, { status: selectedDevbox.status, fullText: true }), uptime !== null && selectedDevbox.status === "running" && (_jsxs(Text, { color: colors.success, dimColor: true, children: [" ", "\u2022 Uptime:", " ", uptime < 60
429
434
  ? `${uptime}m`
430
- : `${Math.floor(uptime / 60)}h ${uptime % 60}m`] }), lp?.keep_alive_time_seconds && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 Keep-alive: ", Math.floor(lp.keep_alive_time_seconds / 60), "m"] }))] }))] }), _jsxs(Box, { flexDirection: "row", gap: 1, marginBottom: 1, children: [(lp?.resource_size_request ||
431
- lp?.custom_cpu_cores ||
432
- lp?.custom_gb_memory ||
433
- lp?.custom_disk_size ||
434
- lp?.architecture) && (_jsxs(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: [_jsxs(Text, { color: colors.warning, bold: true, children: [figures.squareSmallFilled, " Resources"] }), _jsxs(Text, { dimColor: true, children: [lp?.resource_size_request && `${lp.resource_size_request}`, lp?.architecture && ` • ${lp.architecture}`, lp?.custom_cpu_cores && ` • ${lp.custom_cpu_cores}VCPU`, lp?.custom_gb_memory && ` • ${lp.custom_gb_memory}GB RAM`, lp?.custom_disk_size && ` • ${lp.custom_disk_size}GB DISC`] })] })), hasCapabilities && (_jsxs(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: [_jsxs(Text, { color: colors.info, bold: true, children: [figures.tick, " Capabilities"] }), _jsx(Text, { dimColor: true, children: selectedDevbox.capabilities
435
- .filter((c) => c !== "unknown")
436
- .join(", ") })] })), (selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) && (_jsxs(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: [_jsxs(Text, { color: colors.secondary, bold: true, children: [figures.circleFilled, " Source"] }), selectedDevbox.blueprint_id && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "BP: " }), _jsx(Text, { color: colors.idColor, children: selectedDevbox.blueprint_id })] })), selectedDevbox.snapshot_id && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Snap: " }), _jsx(Text, { color: colors.idColor, children: selectedDevbox.snapshot_id })] }))] }))] }), selectedDevbox.metadata &&
437
- Object.keys(selectedDevbox.metadata).length > 0 && (_jsx(Box, { marginBottom: 1, paddingX: 1, children: _jsx(MetadataDisplay, { metadata: selectedDevbox.metadata, showBorder: false }) })), selectedDevbox.failure_reason && (_jsxs(Box, { marginBottom: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.error, bold: true, children: [figures.cross, " "] }), _jsx(Text, { color: colors.error, dimColor: true, children: selectedDevbox.failure_reason })] })), _jsx(StateHistory, { stateTransitions: selectedDevbox.state_transitions }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Actions"] }), _jsx(Box, { flexDirection: "column", children: operations.map((op, index) => {
435
+ : `${Math.floor(uptime / 60)}h ${uptime % 60}m`] })), selectedDevbox.status !== "running" &&
436
+ selectedDevbox.create_time_ms &&
437
+ selectedDevbox.end_time_ms && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 Ran for:", " ", (() => {
438
+ const runtime = Math.floor((selectedDevbox.end_time_ms -
439
+ selectedDevbox.create_time_ms) /
440
+ 1000);
441
+ if (runtime < 60)
442
+ return `${runtime}s`;
443
+ const mins = Math.floor(runtime / 60);
444
+ if (mins < 60)
445
+ return `${mins}m ${runtime % 60}s`;
446
+ const hours = Math.floor(mins / 60);
447
+ return `${hours}h ${mins % 60}m`;
448
+ })()] }))] })] }), _jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { color: colors.warning, bold: true, children: [figures.squareSmallFilled, " Details"] }), _jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [selectedDevbox.create_time_ms && (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, children: "Created " }), _jsx(Text, { dimColor: true, children: formattedCreateTime }), selectedDevbox.end_time_ms ? (_jsxs(Text, { dimColor: true, children: [" ", figures.arrowRight, " ", new Date(selectedDevbox.end_time_ms).toLocaleString()] })) : (_jsxs(Text, { dimColor: true, children: [" (", createTimeAgo, ")"] }))] })), (lp?.resource_size_request ||
449
+ lp?.custom_cpu_cores ||
450
+ lp?.custom_gb_memory ||
451
+ lp?.custom_disk_size ||
452
+ lp?.architecture) && (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, children: "Resources " }), _jsx(Text, { dimColor: true, children: [
453
+ lp?.resource_size_request,
454
+ lp?.architecture,
455
+ lp?.custom_cpu_cores && `${lp.custom_cpu_cores}VCPU`,
456
+ lp?.custom_gb_memory && `${lp.custom_gb_memory}GB RAM`,
457
+ lp?.custom_disk_size && `${lp.custom_disk_size}GB DISC`,
458
+ ]
459
+ .filter(Boolean)
460
+ .join(" • ") })] })), (lp?.keep_alive_time_seconds || lp?.user_parameters) && (_jsxs(Box, { children: [lp?.keep_alive_time_seconds && (_jsxs(_Fragment, { children: [_jsx(Text, { color: colors.textDim, children: "Lifetime " }), _jsx(Text, { dimColor: true, children: lp.keep_alive_time_seconds < 3600
461
+ ? `${Math.floor(lp.keep_alive_time_seconds / 60)}m`
462
+ : `${Math.floor(lp.keep_alive_time_seconds / 3600)}h ${Math.floor((lp.keep_alive_time_seconds % 3600) / 60)}m` }), uptime !== null && selectedDevbox.status === "running" && (_jsxs(Text, { children: [" ", "\u2022", " ", (() => {
463
+ const maxLifetimeMinutes = Math.floor(lp.keep_alive_time_seconds / 60);
464
+ const remainingMinutes = maxLifetimeMinutes - uptime;
465
+ if (remainingMinutes <= 0) {
466
+ return _jsx(Text, { color: colors.error, children: "Expired" });
467
+ }
468
+ else if (remainingMinutes < 5) {
469
+ return (_jsxs(Text, { color: colors.error, children: [remainingMinutes, "m remaining"] }));
470
+ }
471
+ else if (remainingMinutes < 15) {
472
+ return (_jsxs(Text, { color: colors.warning, children: [remainingMinutes, "m remaining"] }));
473
+ }
474
+ else if (remainingMinutes < 60) {
475
+ return (_jsxs(Text, { color: colors.success, children: [remainingMinutes, "m remaining"] }));
476
+ }
477
+ else {
478
+ const hours = Math.floor(remainingMinutes / 60);
479
+ const mins = remainingMinutes % 60;
480
+ return (_jsxs(Text, { color: colors.success, children: [hours, "h ", mins, "m remaining"] }));
481
+ }
482
+ })()] })), lp?.user_parameters && (_jsx(Text, { color: colors.textDim, children: " \u2022 " }))] })), lp?.user_parameters && (_jsxs(_Fragment, { children: [!lp?.keep_alive_time_seconds && (_jsx(Text, { color: colors.textDim, children: "User " })), _jsx(Text, { color: colors.textDim, children: "User: " }), _jsxs(Text, { dimColor: true, children: [lp.user_parameters.username || "default", lp.user_parameters.uid != null &&
483
+ lp.user_parameters.uid !== 0 &&
484
+ ` (UID: ${lp.user_parameters.uid})`] })] }))] })), (selectedDevbox.blueprint_id || selectedDevbox.snapshot_id) && (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, children: "Source " }), _jsx(Text, { color: colors.success, children: selectedDevbox.blueprint_id || selectedDevbox.snapshot_id })] })), selectedDevbox.initiator_id && (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, children: "Initiator " }), _jsx(Text, { color: colors.secondary, children: selectedDevbox.initiator_id })] })), hasCapabilities && (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, children: "Capabilities " }), _jsx(Text, { dimColor: true, children: selectedDevbox.capabilities
485
+ .filter((c) => c !== "unknown")
486
+ .join(", ") })] }))] })] }), selectedDevbox.metadata &&
487
+ Object.keys(selectedDevbox.metadata).length > 0 && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { color: colors.secondary, bold: true, children: [figures.identical, " Metadata"] }), _jsx(Box, { flexDirection: "column", paddingLeft: 2, children: Object.entries(selectedDevbox.metadata).map(([key, value]) => (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, children: key }), _jsx(Text, { color: colors.textDim, children: ": " }), _jsx(Text, { color: colors.textDim, dimColor: true, children: value })] }, key))) })] })), selectedDevbox.failure_reason && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { color: colors.error, bold: true, children: [figures.cross, " Error"] }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: colors.error, children: selectedDevbox.failure_reason }) })] })), _jsx(StateHistory, { stateTransitions: selectedDevbox.state_transitions, shutdownReason: selectedDevbox.shutdown_reason ?? undefined }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.play, " Actions"] }), _jsx(Box, { flexDirection: "column", paddingLeft: 2, children: operations.map((op, index) => {
438
488
  const isSelected = index === selectedOperation;
439
489
  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));
440
490
  }) })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: [figures.arrowUp, figures.arrowDown, " Navigate \u2022 [Enter] Execute \u2022 [i] Full Details \u2022 [o] Browser \u2022 [q] Back"] }) })] }));
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import React from "react";
2
3
  import { Box, Text } from "ink";
3
4
  import figures from "figures";
4
5
  import { colors } from "../utils/theme.js";
5
- const renderKeyValueBadge = (keyText, value, color) => (_jsxs(Box, { borderStyle: "round", borderColor: color, paddingX: 1, marginRight: 1, children: [_jsx(Text, { color: color, bold: true, children: keyText }), _jsx(Text, { color: color, children: ": " }), _jsx(Text, { color: color, children: value })] }));
6
+ const renderKeyValueBadge = (keyText, value, color) => (_jsxs(Box, { borderStyle: "round", borderColor: color, paddingX: 1, marginRight: 1, children: [_jsx(Text, { color: color, bold: true, children: keyText }), _jsx(Text, { color: color, children: "= " }), _jsx(Text, { color: color, children: value })] }));
7
+ const renderCompactKeyValue = (keyText, value, color, isLast) => (_jsxs(Text, { children: [_jsx(Text, { color: color, bold: true, children: keyText }), _jsx(Text, { color: colors.textDim, children: "=" }), _jsx(Text, { color: color, children: value }), !isLast && _jsx(Text, { color: colors.textDim, children: " \u00B7 " })] }));
6
8
  // Generate color for each key based on hash
7
9
  const getColorForKey = (key, index) => {
8
10
  const colorList = [
@@ -15,11 +17,19 @@ const getColorForKey = (key, index) => {
15
17
  ];
16
18
  return colorList[index % colorList.length];
17
19
  };
18
- export const MetadataDisplay = ({ metadata, title = "Metadata", showBorder = false, selectedKey, }) => {
20
+ export const MetadataDisplay = ({ metadata, title = "Metadata", showBorder = false, selectedKey, compact = false, }) => {
19
21
  const entries = Object.entries(metadata);
20
22
  if (entries.length === 0) {
21
23
  return null;
22
24
  }
25
+ if (compact) {
26
+ return (_jsxs(Box, { flexDirection: "column", children: [title && (_jsxs(Text, { color: colors.accent3, bold: true, children: [figures.identical, " ", title] })), _jsx(Box, { flexDirection: "row", flexWrap: "wrap", children: entries.map(([key, value], index) => {
27
+ const color = getColorForKey(key, index);
28
+ const isSelected = selectedKey === key;
29
+ const isLast = index === entries.length - 1;
30
+ return (_jsxs(React.Fragment, { children: [isSelected && (_jsx(Text, { color: colors.primary, bold: true, children: figures.pointer })), renderCompactKeyValue(key, value, isSelected ? colors.primary : color, isLast)] }, key));
31
+ }) })] }));
32
+ }
23
33
  const content = (_jsxs(Box, { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 1, children: [title && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.accent3, bold: true, children: [figures.identical, " ", title] }), _jsx(Text, { children: " " })] })), entries.map(([key, value], index) => {
24
34
  const color = getColorForKey(key, index);
25
35
  const isSelected = selectedKey === key;
@@ -10,25 +10,38 @@ import { Table } from "./Table.js";
10
10
  import { colors } from "../utils/theme.js";
11
11
  import { useViewportHeight } from "../hooks/useViewportHeight.js";
12
12
  import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
13
- // Format time ago in a succinct way
13
+ // Format time ago - concise with ISO-style date for older items
14
14
  export const formatTimeAgo = (timestamp) => {
15
15
  const seconds = Math.floor((Date.now() - timestamp) / 1000);
16
+ const date = new Date(timestamp);
17
+ const now = new Date();
18
+ // Format time as HH:MM:SS (24h)
19
+ const time = date.toTimeString().slice(0, 8);
20
+ // Less than 1 minute
16
21
  if (seconds < 60)
17
- return `${seconds}s ago`;
22
+ return `${time} (${seconds}s ago)`;
18
23
  const minutes = Math.floor(seconds / 60);
24
+ // Less than 1 hour
19
25
  if (minutes < 60)
20
- return `${minutes}m ago`;
26
+ return `${time} (${minutes}m ago)`;
21
27
  const hours = Math.floor(minutes / 60);
28
+ // Less than 24 hours - show time + relative
22
29
  if (hours < 24)
23
- return `${hours}h ago`;
30
+ return `${time} (${hours}hr ago)`;
24
31
  const days = Math.floor(hours / 24);
25
- if (days < 30)
26
- return `${days}d ago`;
27
- const months = Math.floor(days / 30);
28
- if (months < 12)
29
- return `${months}mo ago`;
30
- const years = Math.floor(months / 12);
31
- return `${years}y ago`;
32
+ const sameYear = date.getFullYear() === now.getFullYear();
33
+ // Format date parts
34
+ const month = String(date.getMonth() + 1).padStart(2, "0");
35
+ const day = String(date.getDate()).padStart(2, "0");
36
+ const year = date.getFullYear();
37
+ // Date format: MM-DD or YYYY-MM-DD if different year
38
+ const dateStr = `${month}-${day}`;
39
+ // 1-7 days - show date + time + relative
40
+ if (days <= 7) {
41
+ return `${dateStr} ${time} (${days}d)`;
42
+ }
43
+ // More than 7 days - just date + time, no relative
44
+ return `${dateStr} ${time}`;
32
45
  };
33
46
  export function ResourceListView({ config }) {
34
47
  const { exit: inkExit } = useApp();
@@ -2,6 +2,37 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
2
2
  import { Box, Text } from "ink";
3
3
  import figures from "figures";
4
4
  import { colors } from "../utils/theme.js";
5
+ import { getStatusDisplay } from "./StatusBadge.js";
6
+ // Format shutdown reason into human-readable text
7
+ const formatShutdownReason = (reason) => {
8
+ switch (reason) {
9
+ case "api_shutdown":
10
+ return "API call";
11
+ case "idle_timeout":
12
+ return "Idle timeout";
13
+ case "keep_alive_timeout":
14
+ return "Max lifetime expired";
15
+ case "max_lifetime":
16
+ case "max_lifetime_exceeded":
17
+ return "Max lifetime exceeded";
18
+ case "user_initiated":
19
+ return "User initiated";
20
+ case "system_maintenance":
21
+ return "System maintenance";
22
+ case "resource_limit":
23
+ return "Resource limits";
24
+ case "entrypoint_exit":
25
+ return "Entrypoint exited";
26
+ case "idle":
27
+ return "Idle";
28
+ case "error":
29
+ case "failure":
30
+ return "Error";
31
+ default:
32
+ // Convert snake_case to readable text
33
+ return reason.replace(/_/g, " ");
34
+ }
35
+ };
5
36
  // Format time ago in a succinct way
6
37
  const formatTimeAgo = (timestamp) => {
7
38
  const seconds = Math.floor((Date.now() - timestamp) / 1000);
@@ -38,28 +69,30 @@ const formatDuration = (milliseconds) => {
38
69
  };
39
70
  // Capitalize first letter of a string
40
71
  const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
41
- export const StateHistory = ({ stateTransitions }) => {
42
- if (!stateTransitions ||
43
- !Array.isArray(stateTransitions) ||
44
- stateTransitions.length === 0) {
72
+ // Terminal states that don't need duration shown (no new state coming)
73
+ const TERMINAL_STATES = ["shutdown", "failure"];
74
+ export const StateHistory = ({ stateTransitions, shutdownReason, }) => {
75
+ if (!stateTransitions || stateTransitions.length === 0) {
45
76
  return null;
46
77
  }
47
- // Get last 3 transitions (most recent first)
48
- const lastThree = stateTransitions
49
- .slice(-3)
50
- .reverse()
78
+ // Check if there are more than 5 transitions
79
+ const totalTransitions = stateTransitions.length;
80
+ const hasMore = totalTransitions > 5;
81
+ // Get last 5 transitions (oldest first - chronological order)
82
+ const lastFive = stateTransitions
83
+ .slice(-5)
51
84
  .map((transition, idx, arr) => {
52
85
  const transitionTime = transition.transition_time_ms;
53
- // Calculate duration: time until next transition, or until now if it's the current state
86
+ // Calculate duration: time until next transition, or until now if it's the last state
54
87
  let duration = 0;
55
88
  if (transitionTime) {
56
- if (idx === 0) {
89
+ if (idx === arr.length - 1) {
57
90
  // Most recent state - duration is from transition time to now
58
91
  duration = Date.now() - transitionTime;
59
92
  }
60
93
  else {
61
- // Previous state - duration is from this transition to the next one
62
- const nextTransition = arr[idx - 1];
94
+ // Earlier state - duration is from this transition to the next one
95
+ const nextTransition = arr[idx + 1];
63
96
  const nextTransitionTime = nextTransition.transition_time_ms;
64
97
  if (nextTransitionTime) {
65
98
  duration = nextTransitionTime - transitionTime;
@@ -73,8 +106,15 @@ export const StateHistory = ({ stateTransitions }) => {
73
106
  };
74
107
  })
75
108
  .filter((state) => state.transitionTime); // Only show states with valid timestamps
76
- if (lastThree.length === 0) {
109
+ if (lastFive.length === 0) {
77
110
  return null;
78
111
  }
79
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.info, bold: true, children: [figures.circleFilled, " State History"] }), _jsx(Box, { flexDirection: "column", children: lastThree.map((state, idx) => (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { dimColor: true, children: [capitalize(state.status), state.transitionTime && (_jsxs(_Fragment, { children: [" ", "at ", new Date(state.transitionTime).toLocaleString(), " ", _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["(", formatTimeAgo(state.transitionTime), ")"] }), state.duration > 0 && (_jsxs(_Fragment, { children: [" ", "\u2022 Duration:", " ", _jsx(Text, { color: colors.info, children: formatDuration(state.duration) })] }))] }))] }) }, idx))) })] }));
112
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { color: colors.success, bold: true, children: [figures.info, " State History", hasMore && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "(", totalTransitions - 5, " earlier)"] }))] }), _jsx(Box, { flexDirection: "column", paddingLeft: 2, children: lastFive.map((state, idx) => {
113
+ const statusDisplay = getStatusDisplay(state.status || "");
114
+ const isLastState = idx === lastFive.length - 1;
115
+ const isTerminalState = TERMINAL_STATES.includes(state.status);
116
+ const showDuration = state.duration > 0 && !(isLastState && isTerminalState);
117
+ const isShutdownState = state.status === "shutdown";
118
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: statusDisplay.color, children: [statusDisplay.icon, " "] }), _jsx(Text, { color: isLastState ? statusDisplay.color : colors.textDim, bold: isLastState, children: capitalize(state.status || "unknown") }), state.transitionTime && (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: [" ", "at ", new Date(state.transitionTime).toLocaleString(), " ", _jsxs(Text, { color: colors.textDim, children: ["(", formatTimeAgo(state.transitionTime), ")"] }), showDuration && (_jsxs(_Fragment, { children: [" ", "\u2022 Duration:", " ", _jsx(Text, { color: colors.secondary, children: formatDuration(state.duration) })] }))] }), isShutdownState && shutdownReason && (_jsxs(_Fragment, { children: [_jsx(Text, { color: colors.textDim, children: " due to " }), _jsx(Text, { color: colors.warning, children: formatShutdownReason(shutdownReason) })] }))] }))] }, idx));
119
+ }) })] }));
80
120
  };
@@ -8,87 +8,118 @@ export const getStatusDisplay = (status) => {
8
8
  icon: figures.questionMarkPrefix,
9
9
  color: colors.textDim,
10
10
  text: "UNKNOWN ",
11
+ label: "Unknown",
11
12
  };
12
13
  }
13
14
  switch (status) {
15
+ // === ACTIVE STATE ===
14
16
  case "running":
15
17
  return {
16
18
  icon: figures.circleFilled,
17
19
  color: colors.success,
18
20
  text: "RUNNING ",
21
+ label: "Running",
19
22
  };
23
+ // === STARTING UP (transitioning to active) ===
20
24
  case "provisioning":
21
25
  return {
22
- icon: figures.ellipsis,
26
+ icon: figures.arrowUp,
23
27
  color: colors.warning,
24
28
  text: "PROVISION ",
29
+ label: "Provisioning",
25
30
  };
26
31
  case "initializing":
27
32
  return {
28
- icon: figures.ellipsis,
33
+ icon: figures.arrowUp,
29
34
  color: colors.primary,
30
35
  text: "INITIALIZE",
36
+ label: "Initializing",
31
37
  };
38
+ case "resuming":
39
+ return {
40
+ icon: figures.arrowUp,
41
+ color: colors.primary,
42
+ text: "RESUMING ",
43
+ label: "Resuming",
44
+ };
45
+ // === SHUTTING DOWN (transitioning to inactive) ===
46
+ case "suspending":
47
+ return {
48
+ icon: figures.arrowDown,
49
+ color: colors.warning,
50
+ text: "SUSPENDING",
51
+ label: "Suspending",
52
+ };
53
+ // === INACTIVE STATES ===
32
54
  case "suspended":
33
55
  return {
34
56
  icon: figures.circleDotted,
35
57
  color: colors.warning,
36
58
  text: "SUSPENDED ",
59
+ label: "Suspended",
37
60
  };
38
- case "failure":
39
- return { icon: figures.cross, color: colors.error, text: "FAILED " };
40
61
  case "shutdown":
41
62
  return {
42
63
  icon: figures.circle,
43
64
  color: colors.textDim,
44
65
  text: "SHUTDOWN ",
66
+ label: "Shutdown",
45
67
  };
46
- case "resuming":
68
+ // === ERROR STATES ===
69
+ case "failure":
47
70
  return {
48
- icon: figures.ellipsis,
49
- color: colors.primary,
50
- text: "RESUMING ",
71
+ icon: figures.cross,
72
+ color: colors.error,
73
+ text: "FAILED ",
74
+ label: "Failed",
51
75
  };
52
- case "suspending":
76
+ case "build_failed":
77
+ case "failed":
53
78
  return {
54
- icon: figures.ellipsis,
55
- color: colors.warning,
56
- text: "SUSPENDING",
79
+ icon: figures.cross,
80
+ color: colors.error,
81
+ text: "FAILED ",
82
+ label: "Failed",
57
83
  };
84
+ // === BUILD STATES (for blueprints) ===
58
85
  case "ready":
59
86
  return {
60
- icon: figures.bullet,
87
+ icon: figures.tick,
61
88
  color: colors.success,
62
89
  text: "READY ",
90
+ label: "Ready",
63
91
  };
64
92
  case "build_complete":
65
93
  case "building_complete":
66
94
  return {
67
- icon: figures.bullet,
95
+ icon: figures.tick,
68
96
  color: colors.success,
69
97
  text: "COMPLETE ",
98
+ label: "Build Complete",
70
99
  };
71
100
  case "building":
72
101
  return {
73
- icon: figures.ellipsis,
102
+ icon: figures.arrowUp,
74
103
  color: colors.warning,
75
104
  text: "BUILDING ",
105
+ label: "Building: In Progress",
76
106
  };
77
- case "build_failed":
78
- case "failed":
79
- return { icon: figures.cross, color: colors.error, text: "FAILED " };
80
107
  default:
81
108
  // Truncate and pad any unknown status to 10 chars to match column width
82
109
  const truncated = status.toUpperCase().slice(0, 10);
83
110
  const padded = truncated.padEnd(10, " ");
111
+ // Capitalize first letter for label
112
+ const label = status.charAt(0).toUpperCase() + status.slice(1);
84
113
  return {
85
114
  icon: figures.questionMarkPrefix,
86
115
  color: colors.textDim,
87
116
  text: padded,
117
+ label: label,
88
118
  };
89
119
  }
90
120
  };
91
- export const StatusBadge = ({ status, showText = true }) => {
121
+ export const StatusBadge = ({ status, showText = true, fullText = false, }) => {
92
122
  const statusDisplay = getStatusDisplay(status);
93
- return (_jsxs(_Fragment, { children: [_jsx(Text, { color: statusDisplay.color, children: statusDisplay.icon }), showText && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: statusDisplay.color, children: statusDisplay.text })] }))] }));
123
+ const displayText = fullText ? statusDisplay.label : statusDisplay.text;
124
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { color: statusDisplay.color, children: statusDisplay.icon }), showText && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: statusDisplay.color, children: displayText })] }))] }));
94
125
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runloop/rl-cli",
3
- "version": "0.9.0",
3
+ "version": "1.0.0",
4
4
  "description": "Beautiful CLI for the Runloop platform",
5
5
  "type": "module",
6
6
  "bin": {