fix(tui): stream truncateToWidth for large strings closes #2447

This commit is contained in:
Mario Zechner
2026-03-21 10:06:29 +01:00
parent 78833dfcbc
commit 77db2e4c18
3 changed files with 289 additions and 60 deletions

View File

@@ -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

View File

@@ -37,6 +37,117 @@ const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
const WIDTH_CACHE_SIZE = 512;
const widthCache = new Map<string, number>();
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);
}
/**

View File

@@ -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);
});
});