diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index e4b752eb..2bfb9ac1 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `truncateToWidth()` to stream truncation for very large strings, keep contiguous prefixes, and always terminate truncated SGR styling safely ([#2447](https://github.com/badlogic/pi-mono/issues/2447)) + ## [0.61.1] - 2026-03-20 ### Fixed diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 228b2420..ad67efdd 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -37,6 +37,117 @@ const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; const WIDTH_CACHE_SIZE = 512; const widthCache = new Map(); +function isPrintableAscii(str: string): boolean { + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code < 0x20 || code > 0x7e) { + return false; + } + } + return true; +} + +function truncateFragmentToWidth(text: string, maxWidth: number): { text: string; width: number } { + if (maxWidth <= 0 || text.length === 0) { + return { text: "", width: 0 }; + } + + if (isPrintableAscii(text)) { + const clipped = text.slice(0, maxWidth); + return { text: clipped, width: clipped.length }; + } + + const hasAnsi = text.includes("\x1b"); + const hasTabs = text.includes("\t"); + if (!hasAnsi && !hasTabs) { + let result = ""; + let width = 0; + for (const { segment } of segmenter.segment(text)) { + const w = graphemeWidth(segment); + if (width + w > maxWidth) { + break; + } + result += segment; + width += w; + } + return { text: result, width }; + } + + let result = ""; + let width = 0; + let i = 0; + let pendingAnsi = ""; + + while (i < text.length) { + const ansi = extractAnsiCode(text, i); + if (ansi) { + pendingAnsi += ansi.code; + i += ansi.length; + continue; + } + + if (text[i] === "\t") { + if (width + 3 > maxWidth) { + break; + } + if (pendingAnsi) { + result += pendingAnsi; + pendingAnsi = ""; + } + result += "\t"; + width += 3; + i++; + continue; + } + + let end = i; + while (end < text.length && text[end] !== "\t") { + const nextAnsi = extractAnsiCode(text, end); + if (nextAnsi) { + break; + } + end++; + } + + for (const { segment } of segmenter.segment(text.slice(i, end))) { + const w = graphemeWidth(segment); + if (width + w > maxWidth) { + return { text: result, width }; + } + if (pendingAnsi) { + result += pendingAnsi; + pendingAnsi = ""; + } + result += segment; + width += w; + } + i = end; + } + + return { text: result, width }; +} + +function finalizeTruncatedResult( + prefix: string, + prefixWidth: number, + ellipsis: string, + ellipsisWidth: number, + maxWidth: number, + pad: boolean, +): string { + const reset = "\x1b[0m"; + const visibleWidth = prefixWidth + ellipsisWidth; + let result: string; + + if (ellipsis.length > 0) { + result = `${prefix}${reset}${ellipsis}${reset}`; + } else { + result = `${prefix}${reset}`; + } + + return pad ? result + " ".repeat(Math.max(0, maxWidth - visibleWidth)) : result; +} + /** * Calculate the terminal width of a single grapheme cluster. * Based on code from the string-width library, but includes a possible-emoji @@ -91,15 +202,7 @@ export function visibleWidth(str: string): number { } // Fast path: pure ASCII printable - let isPureAscii = true; - for (let i = 0; i < str.length; i++) { - const code = str.charCodeAt(i); - if (code < 0x20 || code > 0x7e) { - isPureAscii = false; - break; - } - } - if (isPureAscii) { + if (isPrintableAscii(str)) { return str.length; } @@ -695,76 +798,136 @@ export function truncateToWidth( ellipsis: string = "...", pad: boolean = false, ): string { - const textVisibleWidth = visibleWidth(text); + if (maxWidth <= 0) { + return ""; + } - if (textVisibleWidth <= maxWidth) { - return pad ? text + " ".repeat(maxWidth - textVisibleWidth) : text; + if (text.length === 0) { + return pad ? " ".repeat(maxWidth) : ""; } const ellipsisWidth = visibleWidth(ellipsis); - const targetWidth = maxWidth - ellipsisWidth; + if (ellipsisWidth >= maxWidth) { + const textWidth = visibleWidth(text); + if (textWidth <= maxWidth) { + return pad ? text + " ".repeat(maxWidth - textWidth) : text; + } - if (targetWidth <= 0) { - return ellipsis.substring(0, maxWidth); + const clippedEllipsis = truncateFragmentToWidth(ellipsis, maxWidth); + if (clippedEllipsis.width === 0) { + return pad ? " ".repeat(maxWidth) : ""; + } + return finalizeTruncatedResult("", 0, clippedEllipsis.text, clippedEllipsis.width, maxWidth, pad); } - // Separate ANSI codes from visible content using grapheme segmentation - let i = 0; - const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = []; + if (isPrintableAscii(text)) { + if (text.length <= maxWidth) { + return pad ? text + " ".repeat(maxWidth - text.length) : text; + } + const targetWidth = maxWidth - ellipsisWidth; + return finalizeTruncatedResult(text.slice(0, targetWidth), targetWidth, ellipsis, ellipsisWidth, maxWidth, pad); + } + + const targetWidth = maxWidth - ellipsisWidth; + let result = ""; + let pendingAnsi = ""; + let visibleSoFar = 0; + let keptWidth = 0; + let keepContiguousPrefix = true; + let overflowed = false; + let exhaustedInput = false; + const hasAnsi = text.includes("\x1b"); + const hasTabs = text.includes("\t"); + + if (!hasAnsi && !hasTabs) { + for (const { segment } of segmenter.segment(text)) { + const width = graphemeWidth(segment); + if (keepContiguousPrefix && keptWidth + width <= targetWidth) { + result += segment; + keptWidth += width; + } else { + keepContiguousPrefix = false; + } + visibleSoFar += width; + if (visibleSoFar > maxWidth) { + overflowed = true; + break; + } + } + exhaustedInput = !overflowed; + } else { + let i = 0; + while (i < text.length) { + const ansi = extractAnsiCode(text, i); + if (ansi) { + pendingAnsi += ansi.code; + i += ansi.length; + continue; + } + + if (text[i] === "\t") { + if (keepContiguousPrefix && keptWidth + 3 <= targetWidth) { + if (pendingAnsi) { + result += pendingAnsi; + pendingAnsi = ""; + } + result += "\t"; + keptWidth += 3; + } else { + keepContiguousPrefix = false; + pendingAnsi = ""; + } + visibleSoFar += 3; + if (visibleSoFar > maxWidth) { + overflowed = true; + break; + } + i++; + continue; + } - while (i < text.length) { - const ansiResult = extractAnsiCode(text, i); - if (ansiResult) { - segments.push({ type: "ansi", value: ansiResult.code }); - i += ansiResult.length; - } else { - // Find the next ANSI code or end of string let end = i; - while (end < text.length) { + while (end < text.length && text[end] !== "\t") { const nextAnsi = extractAnsiCode(text, end); - if (nextAnsi) break; + if (nextAnsi) { + break; + } end++; } - // Segment this non-ANSI portion into graphemes - const textPortion = text.slice(i, end); - for (const seg of segmenter.segment(textPortion)) { - segments.push({ type: "grapheme", value: seg.segment }); + + for (const { segment } of segmenter.segment(text.slice(i, end))) { + const width = graphemeWidth(segment); + if (keepContiguousPrefix && keptWidth + width <= targetWidth) { + if (pendingAnsi) { + result += pendingAnsi; + pendingAnsi = ""; + } + result += segment; + keptWidth += width; + } else { + keepContiguousPrefix = false; + pendingAnsi = ""; + } + + visibleSoFar += width; + if (visibleSoFar > maxWidth) { + overflowed = true; + break; + } + } + if (overflowed) { + break; } i = end; } + exhaustedInput = i >= text.length; } - // Build truncated string from segments - let result = ""; - let currentWidth = 0; - - for (const seg of segments) { - if (seg.type === "ansi") { - result += seg.value; - continue; - } - - const grapheme = seg.value; - // Skip empty graphemes to avoid issues with string-width calculation - if (!grapheme) continue; - - const graphemeWidth = visibleWidth(grapheme); - - if (currentWidth + graphemeWidth > targetWidth) { - break; - } - - result += grapheme; - currentWidth += graphemeWidth; + if (!overflowed && exhaustedInput) { + return pad ? text + " ".repeat(Math.max(0, maxWidth - visibleSoFar)) : text; } - // Add reset code before ellipsis to prevent styling leaking into it - const truncated = `${result}\x1b[0m${ellipsis}`; - if (pad) { - const truncatedWidth = visibleWidth(truncated); - return truncated + " ".repeat(Math.max(0, maxWidth - truncatedWidth)); - } - return truncated; + return finalizeTruncatedResult(result, keptWidth, ellipsis, ellipsisWidth, maxWidth, pad); } /** diff --git a/packages/tui/test/truncate-to-width.test.ts b/packages/tui/test/truncate-to-width.test.ts new file mode 100644 index 00000000..fdfa9d4d --- /dev/null +++ b/packages/tui/test/truncate-to-width.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { truncateToWidth, visibleWidth } from "../src/utils.js"; + +describe("truncateToWidth", () => { + it("keeps output within width for very large unicode input", () => { + const text = "🙂界".repeat(100_000); + const truncated = truncateToWidth(text, 40, "…"); + + assert.ok(visibleWidth(truncated) <= 40); + assert.strictEqual(truncated.endsWith("…\x1b[0m"), true); + }); + + it("preserves ANSI styling for kept text and resets before and after ellipsis", () => { + const text = `\x1b[31m${"hello ".repeat(1000)}\x1b[0m`; + const truncated = truncateToWidth(text, 20, "…"); + + assert.ok(visibleWidth(truncated) <= 20); + assert.strictEqual(truncated.includes("\x1b[31m"), true); + assert.strictEqual(truncated.endsWith("\x1b[0m…\x1b[0m"), true); + }); + + it("handles malformed ANSI escape prefixes without hanging", () => { + const text = `abc\x1bnot-ansi ${"🙂".repeat(1000)}`; + const truncated = truncateToWidth(text, 20, "…"); + + assert.ok(visibleWidth(truncated) <= 20); + }); + + it("clips wide ellipsis safely and brackets it with resets", () => { + assert.strictEqual(truncateToWidth("abcdef", 1, "🙂"), ""); + assert.strictEqual(truncateToWidth("abcdef", 2, "🙂"), "\x1b[0m🙂\x1b[0m"); + assert.ok(visibleWidth(truncateToWidth("abcdef", 2, "🙂")) <= 2); + }); + + it("returns the original text when it already fits even if ellipsis is too wide", () => { + assert.strictEqual(truncateToWidth("a", 2, "🙂"), "a"); + assert.strictEqual(truncateToWidth("界", 2, "🙂"), "界"); + }); + + it("pads truncated output to requested width", () => { + const truncated = truncateToWidth("🙂界🙂界🙂界", 8, "…", true); + assert.strictEqual(visibleWidth(truncated), 8); + }); + + it("adds a trailing reset when truncating without an ellipsis", () => { + const truncated = truncateToWidth(`\x1b[31m${"hello".repeat(100)}`, 10, ""); + assert.ok(visibleWidth(truncated) <= 10); + assert.strictEqual(truncated.endsWith("\x1b[0m"), true); + }); + + it("keeps a contiguous prefix instead of skipping a wide grapheme and resuming later", () => { + const truncated = truncateToWidth("🙂\t界 \x1b_abc\x07", 7, "…", true); + assert.strictEqual(truncated, "🙂\t\x1b[0m…\x1b[0m "); + }); +}); + +describe("visibleWidth", () => { + it("counts tabs inline and skips ANSI inline", () => { + assert.strictEqual(visibleWidth("\t\x1b[31m界\x1b[0m"), 5); + }); +});