@runloop/rl-cli 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React, { useState, useEffect, useRef } from "react";
3
- import { Box, Text } from "ink";
3
+ import { Box, Text, useStdout } 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
7
  // Dramatic shades of green shimmer - wide range
9
8
  const DARK_SHIMMER_COLORS = [
10
9
  "#024A38", // Very very dark emerald
@@ -83,15 +82,38 @@ const DARK_FRAMES = precomputeFrames(DARK_SHIMMER_COLORS.filter((_, i) => i % 2
83
82
  const LIGHT_FRAMES = precomputeFrames(LIGHT_SHIMMER_COLORS.filter((_, i) => i % 2 === 0));
84
83
  // Minimum width to show the full BigText banner (simple3d font needs ~80 chars for "RUNLOOP.ai")
85
84
  const MIN_WIDTH_FOR_BIG_BANNER = 90;
85
+ // Minimum height to show the full BigText banner - require generous room (40 lines)
86
+ const MIN_HEIGHT_FOR_BIG_BANNER = 40;
86
87
  // Animation interval in ms
87
88
  const SHIMMER_INTERVAL = 400;
88
89
  export const Banner = React.memo(() => {
89
90
  const [frameIndex, setFrameIndex] = useState(0);
90
91
  const frames = isLightMode() ? LIGHT_FRAMES : DARK_FRAMES;
91
- const { terminalWidth } = useViewportHeight();
92
+ const { stdout } = useStdout();
92
93
  const timeoutRef = useRef(null);
93
- // Determine if we should show compact mode
94
- const isCompact = terminalWidth < MIN_WIDTH_FOR_BIG_BANNER;
94
+ // Get raw terminal dimensions, responding to resize events
95
+ // Default to conservative values if we can't detect (triggers compact mode)
96
+ const getDimensions = React.useCallback(() => ({
97
+ width: stdout?.columns && stdout.columns > 0 ? stdout.columns : 80,
98
+ height: stdout?.rows && stdout.rows > 0 ? stdout.rows : 20,
99
+ }), [stdout]);
100
+ const [dimensions, setDimensions] = useState(getDimensions);
101
+ useEffect(() => {
102
+ // Update immediately on mount and when stdout changes
103
+ setDimensions(getDimensions());
104
+ if (!stdout)
105
+ return;
106
+ const handleResize = () => {
107
+ setDimensions(getDimensions());
108
+ };
109
+ stdout.on("resize", handleResize);
110
+ return () => {
111
+ stdout.off("resize", handleResize);
112
+ };
113
+ }, [stdout, getDimensions]);
114
+ // Determine if we should show compact mode (not enough width OR height)
115
+ const isCompact = dimensions.width < MIN_WIDTH_FOR_BIG_BANNER ||
116
+ dimensions.height < MIN_HEIGHT_FOR_BIG_BANNER;
95
117
  useEffect(() => {
96
118
  const tick = () => {
97
119
  setFrameIndex((prev) => (prev + 1) % frames.length);
@@ -16,32 +16,42 @@ export const LogsViewer = ({ logs, breadcrumbItems = [{ label: "Logs", active: t
16
16
  const [logsScroll, setLogsScroll] = React.useState(0);
17
17
  const [copyStatus, setCopyStatus] = React.useState(null);
18
18
  // Calculate viewport for logs output:
19
- // - Breadcrumb (3 lines + marginBottom): 4 lines
20
- // - Log box borders: 2 lines
19
+ // - Breadcrumb (border top + content + border bottom + marginBottom): 4 lines
20
+ // - Log box borders: 2 lines (added to height by Ink)
21
21
  // - Stats bar (marginTop + content): 2 lines
22
- // - Help bar (marginTop + content): 2 lines
23
- // - Safety buffer: 1 line
24
- // Total: 11 lines
25
- const logsViewport = useViewportHeight({ overhead: 11, minHeight: 10 });
22
+ // - Help bar (content): 1 line
23
+ // Total: 9 lines
24
+ const logsViewport = useViewportHeight({ overhead: 9, minHeight: 10 });
25
+ // Calculate max scroll position based on current mode
26
+ // For wrap mode, we can scroll until the last entry is at the top
27
+ // For non-wrap mode, we stop when the last entries fill the viewport
28
+ const getMaxScroll = () => {
29
+ if (logsWrapMode) {
30
+ return Math.max(0, logs.length - 1);
31
+ }
32
+ else {
33
+ return Math.max(0, logs.length - logsViewport.viewportHeight);
34
+ }
35
+ };
26
36
  // Handle input for logs navigation
27
37
  useInput((input, key) => {
38
+ const maxScroll = getMaxScroll();
28
39
  if (key.upArrow || input === "k") {
29
40
  setLogsScroll(Math.max(0, logsScroll - 1));
30
41
  }
31
42
  else if (key.downArrow || input === "j") {
32
- setLogsScroll(logsScroll + 1);
43
+ setLogsScroll(Math.min(maxScroll, logsScroll + 1));
33
44
  }
34
45
  else if (key.pageUp) {
35
46
  setLogsScroll(Math.max(0, logsScroll - 10));
36
47
  }
37
48
  else if (key.pageDown) {
38
- setLogsScroll(logsScroll + 10);
49
+ setLogsScroll(Math.min(maxScroll, logsScroll + 10));
39
50
  }
40
51
  else if (input === "g") {
41
52
  setLogsScroll(0);
42
53
  }
43
54
  else if (input === "G") {
44
- const maxScroll = Math.max(0, logs.length - logsViewport.viewportHeight);
45
55
  setLogsScroll(maxScroll);
46
56
  }
47
57
  else if (input === "w") {
@@ -101,72 +111,141 @@ export const LogsViewer = ({ logs, breadcrumbItems = [{ label: "Logs", active: t
101
111
  });
102
112
  const viewportHeight = Math.max(1, logsViewport.viewportHeight);
103
113
  const terminalWidth = logsViewport.terminalWidth;
104
- const maxScroll = Math.max(0, logs.length - viewportHeight);
105
- const actualScroll = Math.min(logsScroll, maxScroll);
106
- const visibleLogs = logs.slice(actualScroll, actualScroll + viewportHeight);
107
- const hasMore = actualScroll + viewportHeight < logs.length;
114
+ // Account for box borders (2 chars) and paddingX={1} (2 chars)
115
+ // Add extra buffer (4 chars) for any edge cases with Ink rendering
116
+ const boxChrome = 8;
117
+ const contentWidth = Math.max(40, terminalWidth - boxChrome);
118
+ // Helper to sanitize log message
119
+ const sanitizeMessage = (message) => {
120
+ // Strip ANSI escape sequences (colors, cursor movement, etc.)
121
+ const strippedAnsi = message.replace(
122
+ // eslint-disable-next-line no-control-regex
123
+ /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
124
+ // Replace control characters with spaces
125
+ return (strippedAnsi
126
+ .replace(/\r\n/g, " ")
127
+ .replace(/\n/g, " ")
128
+ .replace(/\r/g, " ")
129
+ .replace(/\t/g, " ")
130
+ // Remove any other control characters (ASCII 0-31 except space)
131
+ // eslint-disable-next-line no-control-regex
132
+ .replace(/[\x00-\x1F]/g, ""));
133
+ };
134
+ // Helper to calculate how many lines a log entry will take when wrapped
135
+ const calculateWrappedLineCount = (log) => {
136
+ const parts = parseAnyLogEntry(log);
137
+ const sanitized = sanitizeMessage(parts.message);
138
+ const MAX_MESSAGE_LENGTH = 1000;
139
+ const fullMessage = sanitized.length > MAX_MESSAGE_LENGTH
140
+ ? sanitized.substring(0, MAX_MESSAGE_LENGTH) + "..."
141
+ : sanitized;
142
+ const cmd = parts.cmd
143
+ ? `$ ${parts.cmd.substring(0, 40)}${parts.cmd.length > 40 ? "..." : ""} `
144
+ : "";
145
+ const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
146
+ const shellPart = parts.shellName ? `(${parts.shellName}) ` : "";
147
+ // Calculate total line length
148
+ const totalLength = parts.timestamp.length +
149
+ 1 + // space
150
+ parts.level.length +
151
+ 1 + // space
152
+ parts.source.length +
153
+ 2 + // brackets
154
+ 1 + // space
155
+ shellPart.length +
156
+ cmd.length +
157
+ fullMessage.length +
158
+ (exitCode ? 1 + exitCode.length : 0);
159
+ // Calculate how many lines this will wrap to
160
+ // Use contentWidth directly since we now have proper width constraints
161
+ const lineCount = Math.ceil(totalLength / contentWidth);
162
+ return Math.max(1, lineCount);
163
+ };
164
+ // Calculate visible logs based on wrap mode
165
+ let visibleLogs;
166
+ let actualScroll;
167
+ let visibleLineCount;
168
+ if (logsWrapMode) {
169
+ // In wrap mode, we need to count lines and only show what fits
170
+ actualScroll = Math.min(logsScroll, Math.max(0, logs.length - 1));
171
+ visibleLogs = [];
172
+ visibleLineCount = 0;
173
+ for (let i = actualScroll; i < logs.length; i++) {
174
+ const lineCount = calculateWrappedLineCount(logs[i]);
175
+ if (visibleLineCount + lineCount > viewportHeight &&
176
+ visibleLogs.length > 0) {
177
+ break;
178
+ }
179
+ visibleLogs.push(logs[i]);
180
+ visibleLineCount += lineCount;
181
+ }
182
+ }
183
+ else {
184
+ // In non-wrap mode, each log is exactly 1 line
185
+ const maxScroll = Math.max(0, logs.length - viewportHeight);
186
+ actualScroll = Math.min(logsScroll, maxScroll);
187
+ visibleLogs = logs.slice(actualScroll, actualScroll + viewportHeight);
188
+ visibleLineCount = visibleLogs.length;
189
+ }
190
+ const hasMore = actualScroll + visibleLogs.length < logs.length;
108
191
  const hasLess = actualScroll > 0;
109
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, children: logs.length === 0 ? (_jsx(Text, { color: colors.textDim, dimColor: true, children: "No logs available" })) : (visibleLogs.map((log, index) => {
192
+ // Color maps (defined once outside the loop)
193
+ const levelColorMap = {
194
+ red: colors.error,
195
+ yellow: colors.warning,
196
+ blue: colors.primary,
197
+ gray: colors.textDim,
198
+ };
199
+ const sourceColorMap = {
200
+ magenta: "#d33682",
201
+ cyan: colors.info,
202
+ green: colors.success,
203
+ yellow: colors.warning,
204
+ gray: colors.textDim,
205
+ white: colors.text,
206
+ };
207
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, height: viewportHeight, children: logs.length === 0 ? (_jsx(Text, { color: colors.textDim, dimColor: true, children: "No logs available" })) : (visibleLogs.map((log, index) => {
110
208
  const parts = parseAnyLogEntry(log);
111
- // Sanitize message: escape special chars to prevent layout breaks
112
- const escapedMessage = parts.message
113
- .replace(/\r\n/g, "\\n")
114
- .replace(/\n/g, "\\n")
115
- .replace(/\r/g, "\\r")
116
- .replace(/\t/g, "\\t");
209
+ const sanitizedMessage = sanitizeMessage(parts.message);
117
210
  // Limit message length to prevent Yoga layout engine errors
118
211
  const MAX_MESSAGE_LENGTH = 1000;
119
- const fullMessage = escapedMessage.length > MAX_MESSAGE_LENGTH
120
- ? escapedMessage.substring(0, MAX_MESSAGE_LENGTH) + "..."
121
- : escapedMessage;
212
+ const fullMessage = sanitizedMessage.length > MAX_MESSAGE_LENGTH
213
+ ? sanitizedMessage.substring(0, MAX_MESSAGE_LENGTH) + "..."
214
+ : sanitizedMessage;
122
215
  const cmd = parts.cmd
123
216
  ? `$ ${parts.cmd.substring(0, 40)}${parts.cmd.length > 40 ? "..." : ""} `
124
217
  : "";
125
218
  const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
126
- // Map color names to theme colors
127
- const levelColorMap = {
128
- red: colors.error,
129
- yellow: colors.warning,
130
- blue: colors.primary,
131
- gray: colors.textDim,
132
- };
133
- const sourceColorMap = {
134
- magenta: "#d33682",
135
- cyan: colors.info,
136
- green: colors.success,
137
- yellow: colors.warning,
138
- gray: colors.textDim,
139
- white: colors.text,
140
- };
141
219
  const levelColor = levelColorMap[parts.levelColor] || colors.textDim;
142
220
  const sourceColor = sourceColorMap[parts.sourceColor] || colors.textDim;
143
221
  if (logsWrapMode) {
144
- return (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: parts.timestamp }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: parts.levelColor === "red", children: parts.level }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, children: ["[", parts.source, "]"] }), _jsx(Text, { children: " " }), parts.shellName && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["(", parts.shellName, ")", " "] })), cmd && _jsx(Text, { color: colors.info, children: cmd }), _jsx(Text, { children: fullMessage }), exitCode && (_jsxs(Text, { color: parts.exitCode === 0 ? colors.success : colors.error, children: [" ", exitCode] }))] }, index));
222
+ // For wrap mode, render with explicit width to prevent layout issues
223
+ return (_jsx(Box, { width: contentWidth, flexDirection: "column", children: _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: parts.timestamp }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: parts.levelColor === "red", children: parts.level }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, children: ["[", parts.source, "]"] }), _jsx(Text, { children: " " }), parts.shellName && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["(", parts.shellName, ")", " "] })), cmd && _jsx(Text, { color: colors.info, children: cmd }), _jsx(Text, { children: fullMessage }), exitCode && (_jsxs(Text, { color: parts.exitCode === 0 ? colors.success : colors.error, children: [" ", exitCode] }))] }) }, index));
145
224
  }
146
225
  else {
147
- // Calculate available width for message truncation
148
- const timestampLen = parts.timestamp.length;
149
- const levelLen = parts.level.length;
150
- const sourceLen = parts.source.length + 2; // brackets
151
- const shellLen = parts.shellName ? parts.shellName.length + 3 : 0;
152
- const cmdLen = cmd.length;
153
- const exitLen = exitCode.length;
154
- const spacesLen = 5; // spaces between elements
155
- const metadataWidth = timestampLen +
156
- levelLen +
157
- sourceLen +
158
- shellLen +
159
- cmdLen +
160
- exitLen +
161
- spacesLen;
162
- const safeTerminalWidth = Math.max(80, terminalWidth);
163
- const availableMessageWidth = Math.max(20, safeTerminalWidth - metadataWidth);
164
- const truncatedMessage = fullMessage.length > availableMessageWidth
165
- ? fullMessage.substring(0, Math.max(1, availableMessageWidth - 3)) + "..."
166
- : fullMessage;
167
- return (_jsxs(Box, { children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: parts.timestamp }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: parts.levelColor === "red", children: parts.level }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, children: ["[", parts.source, "]"] }), _jsx(Text, { children: " " }), parts.shellName && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["(", parts.shellName, ")", " "] })), cmd && _jsx(Text, { color: colors.info, children: cmd }), _jsx(Text, { children: truncatedMessage }), exitCode && (_jsxs(Text, { color: parts.exitCode === 0 ? colors.success : colors.error, children: [" ", exitCode] }))] }, index));
226
+ // Non-wrap mode: build the complete line and truncate to fit exactly
227
+ const shellPart = parts.shellName ? `(${parts.shellName}) ` : "";
228
+ const exitPart = exitCode ? ` ${exitCode}` : "";
229
+ // Build the full line content
230
+ const prefix = `${parts.timestamp} ${parts.level} [${parts.source}] ${shellPart}${cmd}`;
231
+ const suffix = exitPart;
232
+ // Calculate how much space is available for the message
233
+ const availableForMessage = contentWidth - prefix.length - suffix.length;
234
+ let displayMessage;
235
+ if (availableForMessage <= 3) {
236
+ // No room for message
237
+ displayMessage = "";
238
+ }
239
+ else if (fullMessage.length <= availableForMessage) {
240
+ displayMessage = fullMessage;
241
+ }
242
+ else {
243
+ displayMessage =
244
+ fullMessage.substring(0, availableForMessage - 3) + "...";
245
+ }
246
+ return (_jsx(Box, { width: contentWidth, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: colors.textDim, dimColor: true, children: parts.timestamp }), _jsx(Text, { children: " " }), _jsx(Text, { color: levelColor, bold: parts.levelColor === "red", children: parts.level }), _jsx(Text, { children: " " }), _jsxs(Text, { color: sourceColor, children: ["[", parts.source, "]"] }), _jsx(Text, { children: " " }), parts.shellName && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: ["(", parts.shellName, ")", " "] })), cmd && _jsx(Text, { color: colors.info, children: cmd }), _jsx(Text, { children: displayMessage }), exitCode && (_jsxs(Text, { color: parts.exitCode === 0 ? colors.success : colors.error, children: [" ", exitCode] }))] }) }, index));
168
247
  }
169
- })) }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", logs.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total logs"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll + viewportHeight, logs.length), " of", " ", logs.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: logsWrapMode ? colors.success : colors.textDim, bold: logsWrapMode, children: logsWrapMode ? "Wrap: ON" : "Wrap: OFF" }), 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: [
248
+ })) }), _jsxs(Box, { marginTop: 1, paddingX: 1, children: [_jsxs(Text, { color: colors.primary, bold: true, children: [figures.hamburger, " ", logs.length] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "total logs"] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Viewing ", actualScroll + 1, "-", Math.min(actualScroll + visibleLogs.length, logs.length), " of", " ", logs.length] }), hasLess && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowUp] }), hasMore && _jsxs(Text, { color: colors.primary, children: [" ", figures.arrowDown] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsx(Text, { color: logsWrapMode ? colors.success : colors.textDim, bold: logsWrapMode, children: logsWrapMode ? "Wrap: ON" : "Wrap: OFF" }), 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: [
170
249
  { key: "g", label: "Top" },
171
250
  { key: "G", label: "Bottom" },
172
251
  { key: "w", label: "Toggle Wrap" },
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import React from "react";
3
- import { Box, Text, useInput, useApp } from "ink";
3
+ import { Box, Text, useInput, useApp, useStdout } from "ink";
4
4
  import figures from "figures";
5
5
  import { Banner } from "./Banner.js";
6
6
  import { Breadcrumb } from "./Breadcrumb.js";
@@ -8,7 +8,6 @@ import { NavigationTips } from "./NavigationTips.js";
8
8
  import { VERSION } from "../version.js";
9
9
  import { colors } from "../utils/theme.js";
10
10
  import { execCommand } from "../utils/exec.js";
11
- import { useViewportHeight } from "../hooks/useViewportHeight.js";
12
11
  import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
13
12
  import { useUpdateCheck } from "../hooks/useUpdateCheck.js";
14
13
  const menuItems = [
@@ -48,11 +47,44 @@ const menuItems = [
48
47
  color: colors.info,
49
48
  },
50
49
  ];
50
+ function getLayoutMode(height) {
51
+ if (height >= 40)
52
+ return "full"; // Big banner + bordered items + descriptions
53
+ if (height >= 22)
54
+ return "medium"; // Small banner + simple items + descriptions
55
+ if (height >= 15)
56
+ return "compact"; // No banner + simple items + short descriptions
57
+ return "minimal"; // No banner + labels only
58
+ }
51
59
  export const MainMenu = ({ onSelect }) => {
52
60
  const { exit } = useApp();
53
61
  const [selectedIndex, setSelectedIndex] = React.useState(0);
54
- // Use centralized viewport hook for consistent layout
55
- const { terminalHeight } = useViewportHeight({ overhead: 0 });
62
+ const { stdout } = useStdout();
63
+ // Get raw terminal dimensions, responding to resize events
64
+ // Default to 20 rows / 80 cols if we can't detect
65
+ const getTerminalDimensions = React.useCallback(() => {
66
+ return {
67
+ height: stdout?.rows && stdout.rows > 0 ? stdout.rows : 20,
68
+ width: stdout?.columns && stdout.columns > 0 ? stdout.columns : 80,
69
+ };
70
+ }, [stdout]);
71
+ const [terminalDimensions, setTerminalDimensions] = React.useState(getTerminalDimensions);
72
+ React.useEffect(() => {
73
+ // Update immediately on mount and when stdout changes
74
+ setTerminalDimensions(getTerminalDimensions());
75
+ if (!stdout)
76
+ return;
77
+ const handleResize = () => {
78
+ setTerminalDimensions(getTerminalDimensions());
79
+ };
80
+ stdout.on("resize", handleResize);
81
+ return () => {
82
+ stdout.off("resize", handleResize);
83
+ };
84
+ }, [stdout, getTerminalDimensions]);
85
+ const terminalHeight = terminalDimensions.height;
86
+ const terminalWidth = terminalDimensions.width;
87
+ const isNarrow = terminalWidth < 70;
56
88
  // Check for updates
57
89
  const { updateAvailable } = useUpdateCheck();
58
90
  // Handle Ctrl+C to exit
@@ -93,26 +125,49 @@ export const MainMenu = ({ onSelect }) => {
93
125
  ]);
94
126
  }
95
127
  });
96
- // Use compact layout if terminal height is less than 20 lines (memoized)
97
- const useCompactLayout = terminalHeight < 20;
98
- if (useCompactLayout) {
99
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsxs(Box, { paddingX: 2, marginBottom: 1, children: [_jsx(Text, { color: colors.primary, bold: true, children: "RUNLOOP.ai" }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 Cloud development environments \u2022 v", VERSION] })] }), _jsx(Box, { flexDirection: "column", paddingX: 2, children: menuItems.map((item, index) => {
128
+ const layoutMode = getLayoutMode(terminalHeight);
129
+ // Navigation tips for all layouts
130
+ const navTips = (_jsx(NavigationTips, { showArrows: true, paddingX: 2, tips: [
131
+ { key: "1-5", label: "Quick select" },
132
+ { key: "Enter", label: "Select" },
133
+ { key: "Esc", label: "Quit" },
134
+ { key: "u", label: "Update", condition: !!updateAvailable },
135
+ ] }));
136
+ // Minimal layout - just the essentials
137
+ if (layoutMode === "minimal") {
138
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { paddingX: 2, children: [_jsx(Text, { color: colors.primary, bold: true, children: "RUNLOOP" }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "v", VERSION] })] }), _jsx(Box, { flexDirection: "column", paddingX: 2, children: menuItems.map((item, index) => {
100
139
  const isSelected = index === selectedIndex;
101
- return (_jsxs(Box, { marginBottom: 0, children: [_jsx(Text, { color: isSelected ? item.color : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsx(Text, { children: " " }), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "- ", item.description] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", index + 1, "]"] })] }, item.key));
102
- }) }), _jsx(NavigationTips, { showArrows: true, paddingX: 2, tips: [
103
- { key: "1-5", label: "Quick select" },
104
- { key: "Enter", label: "Select" },
105
- { key: "Esc", label: "Quit" },
106
- { key: "u", label: "Update", condition: !!updateAvailable },
107
- ] })] }));
140
+ return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? item.color : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsxs(Text, { color: item.color, children: [" ", item.icon, " "] }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", index + 1, "]"] })] }, item.key));
141
+ }) }), navTips] }));
108
142
  }
109
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsx(Banner, {}), _jsx(Box, { flexDirection: "column", paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Cloud development environments for your team \u2022 v", VERSION] }) }) }), _jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, children: [_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(Text, { color: colors.text, bold: true, children: "Select a resource:" }) }), menuItems.map((item, index) => {
143
+ // Compact layout - no banner, simple items with descriptions (or no descriptions if narrow)
144
+ if (layoutMode === "compact") {
145
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsxs(Box, { paddingX: 2, children: [_jsx(Text, { color: colors.primary, bold: true, children: "RUNLOOP.ai" }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022 v", VERSION] })] }), _jsx(Box, { flexDirection: "column", paddingX: 2, children: menuItems.map((item, index) => {
146
+ const isSelected = index === selectedIndex;
147
+ return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? item.color : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsxs(Text, { color: item.color, children: [" ", item.icon, " "] }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsx(Text, { color: colors.textDim, dimColor: true, children: isNarrow
148
+ ? ` [${index + 1}]`
149
+ : ` - ${item.description} [${index + 1}]` })] }, item.key));
150
+ }) }), navTips] }));
151
+ }
152
+ // Medium layout - small banner, simple items with descriptions (or no descriptions if narrow)
153
+ if (layoutMode === "medium") {
154
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsxs(Box, { paddingX: 2, marginBottom: 1, children: [_jsx(Text, { color: colors.primary, bold: true, children: "RUNLOOP.ai" }), _jsx(Text, { color: colors.textDim, dimColor: true, children: isNarrow
155
+ ? ` • v${VERSION}`
156
+ : ` • Cloud development environments • v${VERSION}` })] }), _jsx(Box, { flexDirection: "column", paddingX: 2, children: menuItems.map((item, index) => {
157
+ const isSelected = index === selectedIndex;
158
+ return (_jsxs(Box, { marginBottom: 0, children: [_jsx(Text, { color: isSelected ? item.color : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsx(Text, { children: " " }), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), !isNarrow && (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "- ", item.description] })), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", index + 1, "]"] })] }, item.key));
159
+ }) }), navTips] }));
160
+ }
161
+ // Full layout - big banner, bordered items (or simple items if narrow)
162
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Breadcrumb, { items: [{ label: "Home", active: true }], showVersionCheck: true }), _jsx(Banner, {}), _jsx(Box, { flexDirection: "column", paddingX: 2, flexShrink: 0, children: _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["Cloud development environments for your team \u2022 v", VERSION] }) }) }), _jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, children: [_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(Text, { color: colors.text, bold: true, children: "Select a resource:" }) }), isNarrow ? (
163
+ // Narrow layout - no borders, compact items
164
+ _jsx(Box, { flexDirection: "column", marginTop: 1, children: menuItems.map((item, index) => {
165
+ const isSelected = index === selectedIndex;
166
+ return (_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? item.color : colors.textDim, children: isSelected ? figures.pointer : " " }), _jsxs(Text, { color: item.color, children: [" ", item.icon, " "] }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "[", index + 1, "]"] })] }, item.key));
167
+ }) })) : (
168
+ // Wide layout - bordered items with descriptions
169
+ menuItems.map((item, index) => {
110
170
  const isSelected = index === selectedIndex;
111
171
  return (_jsxs(Box, { paddingX: 2, paddingY: 0, borderStyle: "single", borderColor: isSelected ? item.color : colors.border, marginTop: index === 0 ? 1 : 0, flexShrink: 0, children: [isSelected && (_jsxs(_Fragment, { children: [_jsx(Text, { color: item.color, bold: true, children: figures.pointer }), _jsx(Text, { children: " " })] })), _jsx(Text, { color: item.color, bold: true, children: item.icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? item.color : colors.text, bold: isSelected, children: item.label }), _jsx(Text, { color: colors.textDim, children: " " }), _jsx(Text, { color: colors.textDim, dimColor: true, children: item.description }), _jsx(Text, { children: " " }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: ["[", index + 1, "]"] })] }, item.key));
112
- })] }), _jsx(NavigationTips, { showArrows: true, tips: [
113
- { key: "1-5", label: "Quick select" },
114
- { key: "Enter", label: "Select" },
115
- { key: "Esc", label: "Quit" },
116
- { key: "u", label: "Update", condition: !!updateAvailable },
117
- ] })] }));
172
+ }))] }), navTips] }));
118
173
  };
@@ -1,11 +1,149 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * NavigationTips - Shared component for rendering keyboard navigation hints
4
+ * Supports responsive display with compact format for small terminal widths
5
+ */
6
+ import React from "react";
7
+ import { Box, Text, useStdout } from "ink";
3
8
  import figures from "figures";
4
9
  import { colors } from "../utils/theme.js";
5
10
  /**
6
- * Renders a responsive navigation tips bar that wraps on small screens
11
+ * Map of common labels to their compact versions
12
+ */
13
+ const COMPACT_LABELS = {
14
+ // Navigation
15
+ Navigate: "Nav",
16
+ Page: "Pg",
17
+ Top: "Top",
18
+ Bottom: "Bot",
19
+ // Actions
20
+ Select: "Sel",
21
+ Details: "Info",
22
+ Execute: "Run",
23
+ Continue: "OK",
24
+ Back: "Back",
25
+ Quit: "Quit",
26
+ Search: "Find",
27
+ Actions: "Act",
28
+ Create: "New",
29
+ Delete: "Del",
30
+ Copy: "Cpy",
31
+ Update: "Upd",
32
+ Refresh: "Ref",
33
+ // Browser/Web
34
+ Browser: "Web",
35
+ "Open in Browser": "Web",
36
+ // Toggles
37
+ "Toggle Wrap": "Wrap",
38
+ "Full Details": "More",
39
+ // Quick select - short version that isn't redundant with key
40
+ "Quick select": "Jump",
41
+ };
42
+ /**
43
+ * Get compact label - uses explicit compactLabel, then lookup, then truncates
44
+ */
45
+ function getCompactLabel(tip) {
46
+ if (tip.compactLabel)
47
+ return tip.compactLabel;
48
+ if (COMPACT_LABELS[tip.label])
49
+ return COMPACT_LABELS[tip.label];
50
+ // Truncate long labels to first word or 4 chars
51
+ if (tip.label.length > 6) {
52
+ const firstWord = tip.label.split(" ")[0];
53
+ return firstWord.length <= 6 ? firstWord : firstWord.slice(0, 4);
54
+ }
55
+ return tip.label;
56
+ }
57
+ /**
58
+ * Shorten key for very compact display
59
+ */
60
+ function getCompactKey(key) {
61
+ // Shorten common compound keys
62
+ if (key === "Enter/q/esc")
63
+ return "⏎/q";
64
+ if (key === "Enter/q")
65
+ return "⏎/q";
66
+ if (key === "Enter")
67
+ return "⏎";
68
+ if (key === "Esc")
69
+ return "⎋";
70
+ return key;
71
+ }
72
+ /**
73
+ * Calculate the width needed to render tips in a given mode
74
+ */
75
+ function calculateWidth(tips, mode, separator) {
76
+ let width = 0;
77
+ tips.forEach((tip, index) => {
78
+ if (index > 0)
79
+ width += separator.length;
80
+ if (tip.icon) {
81
+ width += tip.icon.length;
82
+ if (mode !== "keysOnly")
83
+ width += 1; // space after icon
84
+ }
85
+ if (tip.key) {
86
+ const keyStr = mode === "keysOnly" ? getCompactKey(tip.key) : tip.key;
87
+ width += keyStr.length + 2; // "[key]"
88
+ if (mode !== "keysOnly")
89
+ width += 1; // space after key
90
+ }
91
+ if (mode === "full") {
92
+ width += tip.label.length;
93
+ }
94
+ else if (mode === "compact") {
95
+ width += getCompactLabel(tip).length;
96
+ }
97
+ // keysOnly mode adds no label width
98
+ });
99
+ return width;
100
+ }
101
+ /**
102
+ * Renders a single tip based on display mode
103
+ */
104
+ function renderTip(tip, mode) {
105
+ let result = "";
106
+ if (tip.icon) {
107
+ result += tip.icon;
108
+ if (mode !== "keysOnly")
109
+ result += " ";
110
+ }
111
+ if (tip.key) {
112
+ const keyStr = mode === "keysOnly" ? getCompactKey(tip.key) : tip.key;
113
+ result += `[${keyStr}]`;
114
+ if (mode !== "keysOnly")
115
+ result += " ";
116
+ }
117
+ if (mode === "full") {
118
+ result += tip.label;
119
+ }
120
+ else if (mode === "compact") {
121
+ result += getCompactLabel(tip);
122
+ }
123
+ return result.trimEnd();
124
+ }
125
+ /**
126
+ * Renders a responsive navigation tips bar with compact format for small screens
7
127
  */
8
128
  export const NavigationTips = ({ tips, showArrows = false, arrowLabel = "Navigate", paddingX = 1, marginTop = 1, }) => {
129
+ const { stdout } = useStdout();
130
+ // Get raw terminal width, responding to resize events
131
+ const [terminalWidth, setTerminalWidth] = React.useState(() => {
132
+ return stdout?.columns && stdout.columns > 0 ? stdout.columns : 80;
133
+ });
134
+ React.useEffect(() => {
135
+ if (!stdout)
136
+ return;
137
+ const handleResize = () => {
138
+ const newWidth = stdout.columns && stdout.columns > 0 ? stdout.columns : 80;
139
+ setTerminalWidth(newWidth);
140
+ };
141
+ stdout.on("resize", handleResize);
142
+ handleResize(); // Check on mount
143
+ return () => {
144
+ stdout.off("resize", handleResize);
145
+ };
146
+ }, [stdout]);
9
147
  // Filter tips by condition (undefined condition means always show)
10
148
  const visibleTips = tips.filter((tip) => tip.condition === undefined || tip.condition === true);
11
149
  // Build the tips array, prepending arrows if requested
@@ -20,5 +158,37 @@ export const NavigationTips = ({ tips, showArrows = false, arrowLabel = "Navigat
20
158
  if (allTips.length === 0) {
21
159
  return null;
22
160
  }
23
- return (_jsx(Box, { marginTop: marginTop, paddingX: paddingX, flexWrap: "wrap", children: allTips.map((tip, index) => (_jsxs(Text, { color: colors.textDim, dimColor: true, children: [index > 0 && " • ", tip.icon && `${tip.icon} `, tip.key && `[${tip.key}] `, tip.label] }, index))) }));
161
+ // Calculate available width (terminal width minus padding)
162
+ const availableWidth = terminalWidth - paddingX * 2;
163
+ // Determine the best display mode that fits
164
+ const fullSeparator = " • ";
165
+ const compactSeparator = " ";
166
+ const keysOnlySeparator = " ";
167
+ let mode = "full";
168
+ let separator = fullSeparator;
169
+ const fullWidth = calculateWidth(allTips, "full", fullSeparator);
170
+ if (fullWidth > availableWidth) {
171
+ // Try compact mode with shorter separator
172
+ const compactWidth = calculateWidth(allTips, "compact", compactSeparator);
173
+ if (compactWidth <= availableWidth) {
174
+ mode = "compact";
175
+ separator = compactSeparator;
176
+ }
177
+ else {
178
+ // Fall back to keys-only mode
179
+ const keysOnlyWidth = calculateWidth(allTips, "keysOnly", keysOnlySeparator);
180
+ if (keysOnlyWidth <= availableWidth) {
181
+ mode = "keysOnly";
182
+ separator = keysOnlySeparator;
183
+ }
184
+ else {
185
+ // Even keys-only doesn't fit, use it anyway (best we can do)
186
+ mode = "keysOnly";
187
+ separator = keysOnlySeparator;
188
+ }
189
+ }
190
+ }
191
+ // Build the output string to ensure no wrapping
192
+ const output = allTips.map((tip) => renderTip(tip, mode)).join(separator);
193
+ return (_jsx(Box, { marginTop: marginTop, paddingX: paddingX, children: _jsx(Text, { color: colors.textDim, dimColor: true, children: output }) }));
24
194
  };
@@ -50,6 +50,43 @@ export function ResourceDetailPage({ resource: initialResource, resourceType, ge
50
50
  }, []);
51
51
  // Local state for resource data (updated by polling)
52
52
  const [currentResource, setCurrentResource] = React.useState(initialResource);
53
+ const [copyStatus, setCopyStatus] = React.useState(null);
54
+ // Copy to clipboard helper
55
+ const copyToClipboard = React.useCallback(async (text) => {
56
+ const { spawn } = await import("child_process");
57
+ const platform = process.platform;
58
+ let command;
59
+ let args;
60
+ if (platform === "darwin") {
61
+ command = "pbcopy";
62
+ args = [];
63
+ }
64
+ else if (platform === "win32") {
65
+ command = "clip";
66
+ args = [];
67
+ }
68
+ else {
69
+ command = "xclip";
70
+ args = ["-selection", "clipboard"];
71
+ }
72
+ const proc = spawn(command, args);
73
+ proc.stdin.write(text);
74
+ proc.stdin.end();
75
+ proc.on("exit", (code) => {
76
+ if (code === 0) {
77
+ setCopyStatus("Copied ID to clipboard!");
78
+ setTimeout(() => setCopyStatus(null), 2000);
79
+ }
80
+ else {
81
+ setCopyStatus("Failed to copy");
82
+ setTimeout(() => setCopyStatus(null), 2000);
83
+ }
84
+ });
85
+ proc.on("error", () => {
86
+ setCopyStatus("Copy not supported");
87
+ setTimeout(() => setCopyStatus(null), 2000);
88
+ });
89
+ }, []);
53
90
  const [showDetailedInfo, setShowDetailedInfo] = React.useState(false);
54
91
  const [detailScroll, setDetailScroll] = React.useState(0);
55
92
  const [selectedOperation, setSelectedOperation] = React.useState(0);
@@ -106,6 +143,10 @@ export function ResourceDetailPage({ resource: initialResource, resourceType, ge
106
143
  if (input === "q" || key.escape) {
107
144
  onBack();
108
145
  }
146
+ else if (input === "c") {
147
+ // Copy resource ID to clipboard
148
+ copyToClipboard(getId(currentResource));
149
+ }
109
150
  else if (input === "i" && buildDetailLines) {
110
151
  setShowDetailedInfo(true);
111
152
  setDetailScroll(0);
@@ -176,11 +217,12 @@ export function ResourceDetailPage({ resource: initialResource, resourceType, ge
176
217
  .map((field, fieldIndex) => (_jsxs(Box, { children: [_jsxs(Text, { color: colors.textDim, children: [field.label, " "] }), typeof field.value === "string" ? (_jsx(Text, { color: field.color, dimColor: !field.color, children: field.value })) : (field.value)] }, fieldIndex))) })] }, sectionIndex))), additionalContent, operations.length > 0 && (_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) => {
177
218
  const isSelected = index === selectedOperation;
178
219
  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));
179
- }) })] })), _jsx(NavigationTips, { showArrows: true, tips: [
220
+ }) })] })), copyStatus && (_jsx(Box, { marginTop: 1, paddingX: 1, children: _jsx(Text, { color: colors.success, bold: true, children: copyStatus }) })), _jsx(NavigationTips, { showArrows: true, tips: [
180
221
  { key: "Enter", label: "Execute" },
222
+ { key: "c", label: "Copy ID" },
181
223
  { key: "i", label: "Full Details", condition: !!buildDetailLines },
182
224
  { key: "o", label: "Browser", condition: !!getUrl },
183
- { key: "q", label: "Back" },
225
+ { key: "q/Ctrl+C", label: "Back/Quit" },
184
226
  ] })] }));
185
227
  }
186
228
  // Helper to format timestamp as "time (ago)"
@@ -13,36 +13,35 @@ const SOURCE_CONFIG = {
13
13
  const SOURCE_WIDTH = 5;
14
14
  /**
15
15
  * Format timestamp based on how recent the log is
16
+ * Always returns a fixed-width string for consistent alignment
16
17
  */
17
18
  export function formatTimestamp(timestampMs) {
18
19
  const date = new Date(timestampMs);
19
20
  const now = new Date();
20
21
  const isToday = date.toDateString() === now.toDateString();
21
22
  const isThisYear = date.getFullYear() === now.getFullYear();
22
- const time = date.toLocaleTimeString("en-US", {
23
- hour12: false,
24
- hour: "2-digit",
25
- minute: "2-digit",
26
- second: "2-digit",
27
- });
23
+ // Build time components manually for consistent formatting
24
+ const hours = date.getHours().toString().padStart(2, "0");
25
+ const minutes = date.getMinutes().toString().padStart(2, "0");
26
+ const seconds = date.getSeconds().toString().padStart(2, "0");
28
27
  const ms = date.getMilliseconds().toString().padStart(3, "0");
28
+ const time = `${hours}:${minutes}:${seconds}`;
29
29
  if (isToday) {
30
+ // Format: "HH:MM:SS.mmm" (12 chars)
30
31
  return `${time}.${ms}`;
31
32
  }
32
33
  else if (isThisYear) {
33
- const monthDay = date.toLocaleDateString("en-US", {
34
- month: "short",
35
- day: "numeric",
36
- });
37
- return `${monthDay} ${time}`;
34
+ // Format: "Mon DD HH:MM:SS" (15 chars, pad day to 2)
35
+ const month = date.toLocaleDateString("en-US", { month: "short" });
36
+ const day = date.getDate().toString().padStart(2, " ");
37
+ return `${month} ${day} ${time}`;
38
38
  }
39
39
  else {
40
- const fullDate = date.toLocaleDateString("en-US", {
41
- year: "numeric",
42
- month: "short",
43
- day: "numeric",
44
- });
45
- return `${fullDate} ${time}`;
40
+ // Format: "YYYY Mon DD HH:MM:SS" (20 chars)
41
+ const year = date.getFullYear();
42
+ const month = date.toLocaleDateString("en-US", { month: "short" });
43
+ const day = date.getDate().toString().padStart(2, " ");
44
+ return `${year} ${month} ${day} ${time}`;
46
45
  }
47
46
  }
48
47
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runloop/rl-cli",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "Beautiful CLI for the Runloop platform",
5
5
  "type": "module",
6
6
  "bin": {