@runloop/rl-cli 1.7.0 → 1.7.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.
@@ -5,7 +5,6 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  */
6
6
  import React from "react";
7
7
  import { Box, Text, useInput } from "ink";
8
- import Spinner from "ink-spinner";
9
8
  import figures from "figures";
10
9
  import { Breadcrumb } from "./Breadcrumb.js";
11
10
  import { NavigationTips } from "./NavigationTips.js";
@@ -14,6 +13,21 @@ import { useViewportHeight } from "../hooks/useViewportHeight.js";
14
13
  import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js";
15
14
  import { parseAnyLogEntry } from "../utils/logFormatter.js";
16
15
  import { getDevboxLogs } from "../services/devboxService.js";
16
+ // Color maps - defined outside component to avoid recreation on every render
17
+ const levelColorMap = {
18
+ red: colors.error,
19
+ yellow: colors.warning,
20
+ blue: colors.primary,
21
+ gray: colors.textDim,
22
+ };
23
+ const sourceColorMap = {
24
+ magenta: "#d33682",
25
+ cyan: colors.info,
26
+ green: colors.success,
27
+ yellow: colors.warning,
28
+ gray: colors.textDim,
29
+ white: colors.text,
30
+ };
17
31
  export const StreamingLogsViewer = ({ devboxId, breadcrumbItems = [{ label: "Logs", active: true }], onBack, }) => {
18
32
  const [logs, setLogs] = React.useState([]);
19
33
  const [loading, setLoading] = React.useState(true);
@@ -25,15 +39,40 @@ export const StreamingLogsViewer = ({ devboxId, breadcrumbItems = [{ label: "Log
25
39
  const [isPolling, setIsPolling] = React.useState(true);
26
40
  // Refs for cleanup
27
41
  const pollIntervalRef = React.useRef(null);
28
- // Calculate viewport
29
- const logsViewport = useViewportHeight({ overhead: 10, minHeight: 10 });
42
+ // Calculate viewport - overhead increased to reduce overdraw/flashing
43
+ const logsViewport = useViewportHeight({ overhead: 11, minHeight: 10 });
30
44
  // Handle Ctrl+C
31
45
  useExitOnCtrlC();
32
- // Fetch logs function
46
+ // Fetch logs function - only update state if logs actually changed
33
47
  const fetchLogs = React.useCallback(async () => {
34
48
  try {
35
49
  const newLogs = await getDevboxLogs(devboxId);
36
- setLogs(newLogs);
50
+ // Only update logs state if the logs have actually changed
51
+ // This prevents unnecessary re-renders that cause flashing in non-tmux terminals
52
+ setLogs((prevLogs) => {
53
+ // Quick length check first
54
+ if (prevLogs.length !== newLogs.length) {
55
+ return newLogs;
56
+ }
57
+ // If same length, check if last log entry is different (most common case for streaming)
58
+ if (newLogs.length > 0) {
59
+ const prevLast = prevLogs[prevLogs.length - 1];
60
+ const newLast = newLogs[newLogs.length - 1];
61
+ // Compare by timestamp and message for efficiency
62
+ if (prevLast &&
63
+ newLast &&
64
+ "timestamp" in prevLast &&
65
+ "timestamp" in newLast &&
66
+ "message" in prevLast &&
67
+ "message" in newLast &&
68
+ prevLast.timestamp === newLast.timestamp &&
69
+ prevLast.message === newLast.message) {
70
+ // Logs haven't changed, return previous state to avoid re-render
71
+ return prevLogs;
72
+ }
73
+ }
74
+ return newLogs;
75
+ });
37
76
  setError(null);
38
77
  if (loading)
39
78
  setLoading(false);
@@ -216,57 +255,79 @@ export const StreamingLogsViewer = ({ devboxId, breadcrumbItems = [{ label: "Log
216
255
  }
217
256
  const hasMore = actualScroll + visibleLogs.length < logs.length;
218
257
  const hasLess = actualScroll > 0;
219
- // Color maps
220
- const levelColorMap = {
221
- red: colors.error,
222
- yellow: colors.warning,
223
- blue: colors.primary,
224
- gray: colors.textDim,
225
- };
226
- const sourceColorMap = {
227
- magenta: "#d33682",
228
- cyan: colors.info,
229
- green: colors.success,
230
- yellow: colors.warning,
231
- gray: colors.textDim,
232
- white: colors.text,
233
- };
234
- return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, height: viewportHeight + 2, children: loading ? (_jsxs(Box, { children: [_jsx(Text, { color: colors.info, children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: colors.textDim, children: " Loading logs..." })] })) : error ? (_jsxs(Text, { color: colors.error, children: [figures.cross, " Error: ", error] })) : logs.length === 0 ? (_jsxs(Box, { children: [isPolling && (_jsxs(Text, { color: colors.info, children: [_jsx(Spinner, { type: "dots" }), " "] })), _jsx(Text, { color: colors.textDim, children: "Waiting for logs..." })] })) : (visibleLogs.map((log, index) => {
235
- const parts = parseAnyLogEntry(log);
236
- const sanitizedMessage = sanitizeMessage(parts.message);
237
- const MAX_MESSAGE_LENGTH = 1000;
238
- const fullMessage = sanitizedMessage.length > MAX_MESSAGE_LENGTH
239
- ? sanitizedMessage.substring(0, MAX_MESSAGE_LENGTH) + "..."
240
- : sanitizedMessage;
241
- const cmd = parts.cmd
242
- ? `$ ${parts.cmd.substring(0, 40)}${parts.cmd.length > 40 ? "..." : ""} `
243
- : "";
244
- const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
245
- const levelColor = levelColorMap[parts.levelColor] || colors.textDim;
246
- const sourceColor = sourceColorMap[parts.sourceColor] || colors.textDim;
247
- if (logsWrapMode) {
248
- 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));
258
+ // Build lines array - always render exactly viewportHeight lines for stable structure
259
+ // This prevents Ink from doing structural redraws that cause flashing
260
+ const renderLines = React.useMemo(() => {
261
+ const lines = [];
262
+ if (loading) {
263
+ // First line shows loading message
264
+ lines.push(_jsx(Box, { width: contentWidth, children: _jsx(Text, { color: colors.textDim, children: "\u25CF Loading logs..." }) }, "loading"));
265
+ }
266
+ else if (error) {
267
+ // First line shows error
268
+ lines.push(_jsx(Box, { width: contentWidth, children: _jsxs(Text, { color: colors.error, children: [figures.cross, " Error: ", error] }) }, "error"));
269
+ }
270
+ else if (logs.length === 0) {
271
+ // First line shows waiting message
272
+ lines.push(_jsx(Box, { width: contentWidth, children: _jsxs(Text, { color: colors.textDim, children: [isPolling ? "● " : "", "Waiting for logs..."] }) }, "waiting"));
273
+ }
274
+ else {
275
+ // Render visible log entries
276
+ for (let i = 0; i < visibleLogs.length; i++) {
277
+ const log = visibleLogs[i];
278
+ const parts = parseAnyLogEntry(log);
279
+ const sanitizedMessage = sanitizeMessage(parts.message);
280
+ const MAX_MESSAGE_LENGTH = 1000;
281
+ const fullMessage = sanitizedMessage.length > MAX_MESSAGE_LENGTH
282
+ ? sanitizedMessage.substring(0, MAX_MESSAGE_LENGTH) + "..."
283
+ : sanitizedMessage;
284
+ const cmd = parts.cmd
285
+ ? `$ ${parts.cmd.substring(0, 40)}${parts.cmd.length > 40 ? "..." : ""} `
286
+ : "";
287
+ const exitCode = parts.exitCode !== null ? `exit=${parts.exitCode} ` : "";
288
+ const levelColor = levelColorMap[parts.levelColor] || colors.textDim;
289
+ const sourceColor = sourceColorMap[parts.sourceColor] || colors.textDim;
290
+ if (logsWrapMode) {
291
+ lines.push(_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] }))] }) }, i));
292
+ }
293
+ else {
294
+ const shellPart = parts.shellName ? `(${parts.shellName}) ` : "";
295
+ const exitPart = exitCode ? ` ${exitCode}` : "";
296
+ const prefix = `${parts.timestamp} ${parts.level} [${parts.source}] ${shellPart}${cmd}`;
297
+ const suffix = exitPart;
298
+ const availableForMessage = contentWidth - prefix.length - suffix.length;
299
+ let displayMessage;
300
+ if (availableForMessage <= 3) {
301
+ displayMessage = "";
302
+ }
303
+ else if (fullMessage.length <= availableForMessage) {
304
+ displayMessage = fullMessage;
249
305
  }
250
306
  else {
251
- const shellPart = parts.shellName ? `(${parts.shellName}) ` : "";
252
- const exitPart = exitCode ? ` ${exitCode}` : "";
253
- const prefix = `${parts.timestamp} ${parts.level} [${parts.source}] ${shellPart}${cmd}`;
254
- const suffix = exitPart;
255
- const availableForMessage = contentWidth - prefix.length - suffix.length;
256
- let displayMessage;
257
- if (availableForMessage <= 3) {
258
- displayMessage = "";
259
- }
260
- else if (fullMessage.length <= availableForMessage) {
261
- displayMessage = fullMessage;
262
- }
263
- else {
264
- displayMessage =
265
- fullMessage.substring(0, availableForMessage - 3) + "...";
266
- }
267
- 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));
307
+ displayMessage =
308
+ fullMessage.substring(0, availableForMessage - 3) + "...";
268
309
  }
269
- })) }), _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: [" ", "logs"] }), logs.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [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", " "] }), isPolling ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: colors.success, children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: colors.success, children: " Live" })] })) : (_jsx(Text, { color: colors.textDim, children: "Paused" })), _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: [
310
+ lines.push(_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] }))] }) }, i));
311
+ }
312
+ }
313
+ }
314
+ // Pad with empty lines to maintain consistent structure
315
+ // This prevents Ink from doing structural redraws
316
+ while (lines.length < viewportHeight) {
317
+ lines.push(_jsx(Box, { width: contentWidth, children: _jsx(Text, { children: " " }) }, `pad-${lines.length}`));
318
+ }
319
+ return lines;
320
+ }, [
321
+ loading,
322
+ error,
323
+ logs.length,
324
+ visibleLogs,
325
+ isPolling,
326
+ logsWrapMode,
327
+ contentWidth,
328
+ viewportHeight,
329
+ ]);
330
+ return (_jsxs(_Fragment, { children: [_jsx(Breadcrumb, { items: breadcrumbItems }), _jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: colors.border, paddingX: 1, height: viewportHeight + 2, children: renderLines }), _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: [" ", "logs"] }), logs.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: colors.textDim, dimColor: true, children: [" ", "\u2022", " "] }), _jsxs(Text, { color: colors.textDim, dimColor: true, children: [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", " "] }), isPolling ? (_jsx(Text, { color: colors.success, children: "\u25CF Live" })) : (_jsx(Text, { color: colors.textDim, children: "\u25CB Paused" })), _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: [
270
331
  { key: "g/G", label: "Top/Bottom" },
271
332
  { key: "p", label: isPolling ? "Pause" : "Resume" },
272
333
  { key: "w", label: "Wrap" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runloop/rl-cli",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "Beautiful CLI for the Runloop platform",
5
5
  "type": "module",
6
6
  "bin": {