From 786a326a7162f6865d2a6be2a5ff7fd77b9dd8cf Mon Sep 17 00:00:00 2001 From: robinwander Date: Sun, 4 Jan 2026 22:15:25 -0600 Subject: [PATCH] fix(tui): trim trailing whitespace in wrapped lines to prevent width overflow --- packages/tui/src/utils.ts | 5 +++-- packages/tui/test/wrap-ansi.test.ts | 19 +++++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 1a7b7767..2b2a4513 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -498,7 +498,7 @@ function wrapSingleLine(line: string, width: number): string[] { const totalNeeded = currentVisibleLength + tokenVisibleLength; if (totalNeeded > width && currentVisibleLength > 0) { - // Add specific reset for underline only (preserves background) + // Trim trailing whitespace, then add underline reset (not full reset, to preserve background) let lineToWrap = currentLine.trimEnd(); const lineEndReset = tracker.getLineEndReset(); if (lineEndReset) { @@ -527,7 +527,8 @@ function wrapSingleLine(line: string, width: number): string[] { wrapped.push(currentLine); } - return wrapped.length > 0 ? wrapped : [""]; + // Trailing whitespace can cause lines to exceed the requested width + return wrapped.length > 0 ? wrapped.map((line) => line.trimEnd()) : [""]; } const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/; diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts index d7acb47c..c747106e 100644 --- a/packages/tui/test/wrap-ansi.test.ts +++ b/packages/tui/test/wrap-ansi.test.ts @@ -12,14 +12,24 @@ describe("wrapTextWithAnsi", () => { const wrapped = wrapTextWithAnsi(text, 40); - // First line should NOT contain underline code - it's just "read this thread " - assert.strictEqual(wrapped[0], "read this thread "); + // First line should NOT contain underline code - it's just "read this thread" + assert.strictEqual(wrapped[0], "read this thread"); // Second line should start with underline, have URL content assert.strictEqual(wrapped[1].startsWith(underlineOn), true); assert.ok(wrapped[1].includes("https://")); }); + it("should not have whitespace before underline reset code", () => { + const underlineOn = "\x1b[4m"; + const underlineOff = "\x1b[24m"; + const textWithUnderlinedTrailingSpace = `${underlineOn}underlined text here ${underlineOff}more`; + + const wrapped = wrapTextWithAnsi(textWithUnderlinedTrailingSpace, 18); + + assert.ok(!wrapped[0].includes(` ${underlineOff}`)); + }); + it("should not bleed underline to padding - each line should end with reset for underline only", () => { const underlineOn = "\x1b[4m"; const underlineOff = "\x1b[24m"; @@ -101,6 +111,11 @@ describe("wrapTextWithAnsi", () => { } }); + it("should truncate trailing whitespace that exceeds width", () => { + const twoSpacesWrappedToWidth1 = wrapTextWithAnsi(" ", 1); + assert.ok(visibleWidth(twoSpacesWrappedToWidth1[0]) <= 1); + }); + it("should preserve color codes across wraps", () => { const red = "\x1b[31m"; const reset = "\x1b[0m";