fix(tui): stream truncateToWidth for large strings closes #2447
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
62
packages/tui/test/truncate-to-width.test.ts
Normal file
62
packages/tui/test/truncate-to-width.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user