diff --git a/packages/cli-v3/src/utilities/windows.test.ts b/packages/cli-v3/src/utilities/windows.test.ts new file mode 100644 index 00000000000..425d69cfa8b --- /dev/null +++ b/packages/cli-v3/src/utilities/windows.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { truncateMessage } from "./windows.js"; + +const originalIsTTY = process.stdout.isTTY; +const originalColumns = process.stdout.columns; + +function mockStdout(options: { isTTY: boolean; columns?: number | undefined }) { + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: options.isTTY, + }); + + Object.defineProperty(process.stdout, "columns", { + configurable: true, + value: options.columns, + }); +} + +afterEach(() => { + Object.defineProperty(process.stdout, "isTTY", { + configurable: true, + value: originalIsTTY, + }); + + Object.defineProperty(process.stdout, "columns", { + configurable: true, + value: originalColumns, + }); +}); + +describe("truncateMessage", () => { + it("returns the original message when stdout is not a TTY", () => { + mockStdout({ isTTY: false, columns: undefined }); + + const message = "a".repeat(500); + + expect(truncateMessage(message)).toBe(message); + }); + + it("returns the original message when stdout has no columns", () => { + mockStdout({ isTTY: true, columns: undefined }); + + const message = "a".repeat(500); + + expect(truncateMessage(message)).toBe(message); + }); + + it("does not truncate short messages", () => { + expect(truncateMessage("hello", 20)).toBe("hello"); + }); + + it("truncates long plain messages", () => { + expect(truncateMessage("hello world", 12)).toBe("hell..."); + }); + + it("truncates ANSI-colored messages without counting escape codes", () => { + const message = "\x1b[31mhello world\x1b[39m"; + + expect(truncateMessage(message, 12)).toBe("\x1b[31mhell\x1b[0m..."); + }); + + it("preserves terminal hyperlink escapes when truncating", () => { + const open = "\u001b]8;;https://trigger.dev\u0007"; + const close = "\u001b]8;;\u0007"; + const message = `${open}hello world${close}`; + + expect(truncateMessage(message, 12)).toBe(`${open}hell${close}...`); + }); +}); diff --git a/packages/cli-v3/src/utilities/windows.ts b/packages/cli-v3/src/utilities/windows.ts index 3ebf403f43b..dc9b982a0a1 100644 --- a/packages/cli-v3/src/utilities/windows.ts +++ b/packages/cli-v3/src/utilities/windows.ts @@ -7,18 +7,56 @@ export function escapeImportPath(path: string) { return isWindows ? path.replaceAll("\\", "\\\\") : path; } +const terminalHyperlinkPattern = /\u001b]8;;[^\u0007]*\u0007/y; +const ansiEscapePattern = /\x1b\[[0-9;]*[a-zA-Z]/y; +const terminalHyperlinkClose = "\u001b]8;;\u0007"; +const ansiReset = "\u001b[0m"; +const ansiResetCodes = new Set([0, 22, 23, 24, 25, 27, 28, 29, 39, 49, 54, 55, 59]); + +function updateAnsiState(sequence: string, hasActiveSgr: boolean): boolean { + if (!sequence.endsWith("m")) { + return hasActiveSgr; + } + + const rawCodes = sequence.slice(2, -1); + const codes = rawCodes === "" ? [0] : rawCodes.split(";").map(Number); + + return codes.some((code) => !ansiResetCodes.has(code)); +} + // Removes ANSI escape sequences to get actual visible length function getVisibleLength(str: string): number { - return ( - str - // Remove terminal hyperlinks: \u001b]8;;URL\u0007TEXT\u001b]8;;\u0007 - .replace(/\u001b]8;;[^\u0007]*\u0007/g, "") - // Remove standard ANSI escape sequences (colors, cursor movement, etc.) - .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").length - ); + let visibleLength = 0; + + for (let index = 0; index < str.length; ) { + terminalHyperlinkPattern.lastIndex = index; + const terminalHyperlinkMatch = terminalHyperlinkPattern.exec(str); + + if (terminalHyperlinkMatch) { + index += terminalHyperlinkMatch[0].length; + continue; + } + + ansiEscapePattern.lastIndex = index; + const ansiEscapeMatch = ansiEscapePattern.exec(str); + + if (ansiEscapeMatch) { + index += ansiEscapeMatch[0].length; + continue; + } + + visibleLength += 1; + index += 1; + } + + return visibleLength; } -function truncateMessage(msg: string, maxLength?: number): string { +export function truncateMessage(msg: string, maxLength?: number): string { + if (maxLength === undefined && (!process.stdout.isTTY || process.stdout.columns == null)) { + return msg; + } + const terminalWidth = maxLength ?? process.stdout.columns ?? 80; const availableWidth = terminalWidth - 5; // Reserve some space for the spinner and padding const visibleLength = getVisibleLength(msg); @@ -27,11 +65,76 @@ function truncateMessage(msg: string, maxLength?: number): string { return msg; } + const maxVisibleLength = availableWidth - 3; + // We need to truncate based on visible characters, but preserve ANSI sequences - // Simple approach: truncate character by character until we fit - let truncated = msg; - while (getVisibleLength(truncated) > availableWidth - 3) { - truncated = truncated.slice(0, -1); + let truncated = ""; + let truncatedVisibleLength = 0; + let hasActiveSgr = false; + let hasActiveHyperlink = false; + + for (let index = 0; index < msg.length && truncatedVisibleLength < maxVisibleLength; ) { + terminalHyperlinkPattern.lastIndex = index; + const terminalHyperlinkMatch = terminalHyperlinkPattern.exec(msg); + + if (terminalHyperlinkMatch) { + const sequence = terminalHyperlinkMatch[0]; + truncated += sequence; + hasActiveHyperlink = sequence !== terminalHyperlinkClose; + index += sequence.length; + continue; + } + + ansiEscapePattern.lastIndex = index; + const ansiEscapeMatch = ansiEscapePattern.exec(msg); + + if (ansiEscapeMatch) { + const sequence = ansiEscapeMatch[0]; + truncated += sequence; + hasActiveSgr = updateAnsiState(sequence, hasActiveSgr); + index += sequence.length; + continue; + } + + truncated += msg[index]; + truncatedVisibleLength += 1; + index += 1; + + if (truncatedVisibleLength === maxVisibleLength) { + while (index < msg.length) { + terminalHyperlinkPattern.lastIndex = index; + const trailingTerminalHyperlinkMatch = terminalHyperlinkPattern.exec(msg); + + if (trailingTerminalHyperlinkMatch) { + const sequence = trailingTerminalHyperlinkMatch[0]; + truncated += sequence; + hasActiveHyperlink = sequence !== terminalHyperlinkClose; + index += sequence.length; + continue; + } + + ansiEscapePattern.lastIndex = index; + const trailingAnsiEscapeMatch = ansiEscapePattern.exec(msg); + + if (trailingAnsiEscapeMatch) { + const sequence = trailingAnsiEscapeMatch[0]; + truncated += sequence; + hasActiveSgr = updateAnsiState(sequence, hasActiveSgr); + index += sequence.length; + continue; + } + + break; + } + } + } + + if (hasActiveHyperlink) { + truncated += terminalHyperlinkClose; + } + + if (hasActiveSgr) { + truncated += ansiReset; } return truncated + "...";