Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions packages/cli-v3/src/utilities/windows.test.ts
Original file line number Diff line number Diff line change
@@ -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}...`);
});
});
127 changes: 115 additions & 12 deletions packages/cli-v3/src/utilities/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 updateAnsiState ignores previous SGR state on partial resets, causing terminal attribute leaks

updateAnsiState accepts a hasActiveSgr parameter but never uses it when the sequence ends with m (line 24). It only checks whether the current sequence's codes are non-reset codes. When a partial reset is encountered (e.g. \x1b[39m to reset foreground), the function returns false even if other attributes (e.g. bold from a prior \x1b[1;31m) are still active.

Example trace showing the bug

For input \x1b[1;31mhe\x1b[39mllo world with truncation at 4 visible chars:

  1. \x1b[1;31m → codes=[1,31], 1 not in resetCodes → hasActiveSgr = true
  2. 'h','e' → visible chars
  3. \x1b[39m → codes=[39], 39 IS in resetCodes → codes.some(code => !ansiResetCodes.has(code)) returns falsehasActiveSgr = false
  4. 'l','l' → 4 visible chars reached, truncate
  5. hasActiveSgr is false → no \x1b[0m appended

Result: \x1b[1;31mhe\x1b[39mll... — bold (code 1) leaks into subsequent terminal output.

The fix should consult hasActiveSgr when all codes in the current sequence are reset codes but none is a full reset (code 0). Only a full reset (\x1b[0m) or explicit reset of every active attribute should clear the state.

Suggested change
return codes.some((code) => !ansiResetCodes.has(code));
if (codes.some((code) => !ansiResetCodes.has(code))) return true;
if (codes.includes(0)) return false;
return hasActiveSgr;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

// 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);
Expand All @@ -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 + "...";
Expand Down