From 21b8caeb6777b2e8c2d2b3fe83e4af3163746ff9 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Fri, 30 Jan 2026 08:06:36 -0500 Subject: [PATCH] Fix carriage return handling to emulate line overwrite --- src/ansi.test.ts | 27 ++++++++++++++++++++++--- src/ansi.ts | 51 ++++++++++++++++++++++++++++++++++++------------ 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/src/ansi.test.ts b/src/ansi.test.ts index 0fa1611..c6fc227 100644 --- a/src/ansi.test.ts +++ b/src/ansi.test.ts @@ -285,14 +285,35 @@ test("multiple newlines with styling", () => { ); }); -test("carriage return is stripped", () => { +test("carriage return in \\r\\n is treated as line ending", () => { const result = ansiToHtml("line1\r\nline2"); expect(result).toBe("line1\nline2"); }); -test("standalone carriage return is stripped", () => { +test("standalone carriage return overwrites from line start", () => { const result = ansiToHtml("text\rmore"); - expect(result).toBe("textmore"); + expect(result).toBe("more"); +}); + +test("multiple carriage returns overwrite progressively", () => { + const result = ansiToHtml("loading...\rloading...\rdone "); + // Note: trailing spaces are trimmed by trimLineEndPreserveAnsi + expect(result).toBe("done"); +}); + +test("carriage return with newlines preserves lines", () => { + const result = ansiToHtml("line1\nloading...\rdone\nline3"); + expect(result).toBe("line1\ndone\nline3"); +}); + +test("carriage return with styling preserves style", () => { + const result = ansiToHtml("\x1b[31mloading...\rdone\x1b[0m"); + expect(result).toBe('done'); +}); + +test("carriage return at start of line", () => { + const result = ansiToHtml("\rtext"); + expect(result).toBe("text"); }); // 9. trimLineEndPreserveAnsi tests diff --git a/src/ansi.ts b/src/ansi.ts index d787844..ce2260e 100644 --- a/src/ansi.ts +++ b/src/ansi.ts @@ -274,20 +274,45 @@ export function ansiToHtml(text: string): string { continue; } - // Track newlines in the content - if (text[i] === "\n" || text[i] === "\r") { - if (text[i] === "\n") { - // Close span before newline to prevent background color bleeding - if (inSpan) { - result += ""; - } - result += "\n"; - // Reopen span after newline if we had styling - if (inSpan) { - result += ``; - } + // Track newlines and carriage returns + if (text[i] === "\n") { + // Close span before newline to prevent background color bleeding + if (inSpan) { + result += ""; } - // Skip carriage return - we only care about line feeds + result += "\n"; + // Reopen span after newline if we had styling + if (inSpan) { + result += ``; + } + i++; + continue; + } + + // Carriage return: overwrite from start of current line + if (text[i] === "\r") { + // Check if this is \r\n - if so, just skip the \r + if (text[i + 1] === "\n") { + i++; + continue; + } + + // Standalone \r: truncate back to last newline (or start) + // Need to handle open spans carefully + const lastNewline = result.lastIndexOf("\n"); + if (lastNewline >= 0) { + // Truncate after the last newline + result = result.substring(0, lastNewline + 1); + } else { + // No newline found, truncate everything + result = ""; + } + + // If we had a span open, we need to reopen it after truncation + if (inSpan) { + result += ``; + } + i++; continue; }