@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.
- package/dist/components/Banner.js +27 -5
- package/dist/components/LogsViewer.js +140 -61
- package/dist/components/MainMenu.js +77 -22
- package/dist/components/NavigationTips.js +174 -4
- package/dist/components/ResourceDetailPage.js +44 -2
- package/dist/utils/logFormatter.js +16 -17
- package/package.json +1 -1
|
@@ -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 {
|
|
92
|
+
const { stdout } = useStdout();
|
|
92
93
|
const timeoutRef = useRef(null);
|
|
93
|
-
//
|
|
94
|
-
|
|
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 (
|
|
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 (
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
120
|
-
?
|
|
121
|
-
:
|
|
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
|
-
|
|
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
|
-
//
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return (
|
|
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 +
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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, {
|
|
102
|
-
}) }),
|
|
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
|
-
|
|
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
|
-
})] }),
|
|
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 {
|
|
2
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
/**
|