fix(tui): stream truncateToWidth for large strings closes #2447
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.61.1] - 2026-03-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -37,6 +37,117 @@ const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
|
|||||||
const WIDTH_CACHE_SIZE = 512;
|
const WIDTH_CACHE_SIZE = 512;
|
||||||
const widthCache = new Map<string, number>();
|
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.
|
* Calculate the terminal width of a single grapheme cluster.
|
||||||
* Based on code from the string-width library, but includes a possible-emoji
|
* 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
|
// Fast path: pure ASCII printable
|
||||||
let isPureAscii = true;
|
if (isPrintableAscii(str)) {
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
const code = str.charCodeAt(i);
|
|
||||||
if (code < 0x20 || code > 0x7e) {
|
|
||||||
isPureAscii = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isPureAscii) {
|
|
||||||
return str.length;
|
return str.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,76 +798,136 @@ export function truncateToWidth(
|
|||||||
ellipsis: string = "...",
|
ellipsis: string = "...",
|
||||||
pad: boolean = false,
|
pad: boolean = false,
|
||||||
): string {
|
): string {
|
||||||
const textVisibleWidth = visibleWidth(text);
|
if (maxWidth <= 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
if (textVisibleWidth <= maxWidth) {
|
if (text.length === 0) {
|
||||||
return pad ? text + " ".repeat(maxWidth - textVisibleWidth) : text;
|
return pad ? " ".repeat(maxWidth) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const ellipsisWidth = visibleWidth(ellipsis);
|
const ellipsisWidth = visibleWidth(ellipsis);
|
||||||
|
if (ellipsisWidth >= maxWidth) {
|
||||||
|
const textWidth = visibleWidth(text);
|
||||||
|
if (textWidth <= maxWidth) {
|
||||||
|
return pad ? text + " ".repeat(maxWidth - textWidth) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clippedEllipsis = truncateFragmentToWidth(ellipsis, maxWidth);
|
||||||
|
if (clippedEllipsis.width === 0) {
|
||||||
|
return pad ? " ".repeat(maxWidth) : "";
|
||||||
|
}
|
||||||
|
return finalizeTruncatedResult("", 0, clippedEllipsis.text, clippedEllipsis.width, maxWidth, pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPrintableAscii(text)) {
|
||||||
|
if (text.length <= maxWidth) {
|
||||||
|
return pad ? text + " ".repeat(maxWidth - text.length) : text;
|
||||||
|
}
|
||||||
const targetWidth = maxWidth - ellipsisWidth;
|
const targetWidth = maxWidth - ellipsisWidth;
|
||||||
|
return finalizeTruncatedResult(text.slice(0, targetWidth), targetWidth, ellipsis, ellipsisWidth, maxWidth, pad);
|
||||||
if (targetWidth <= 0) {
|
|
||||||
return ellipsis.substring(0, maxWidth);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate ANSI codes from visible content using grapheme segmentation
|
const targetWidth = maxWidth - ellipsisWidth;
|
||||||
let i = 0;
|
|
||||||
const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = [];
|
|
||||||
|
|
||||||
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) {
|
|
||||||
const nextAnsi = extractAnsiCode(text, end);
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
i = end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build truncated string from segments
|
|
||||||
let result = "";
|
let result = "";
|
||||||
let currentWidth = 0;
|
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");
|
||||||
|
|
||||||
for (const seg of segments) {
|
if (!hasAnsi && !hasTabs) {
|
||||||
if (seg.type === "ansi") {
|
for (const { segment } of segmenter.segment(text)) {
|
||||||
result += seg.value;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const grapheme = seg.value;
|
if (text[i] === "\t") {
|
||||||
// Skip empty graphemes to avoid issues with string-width calculation
|
if (keepContiguousPrefix && keptWidth + 3 <= targetWidth) {
|
||||||
if (!grapheme) continue;
|
if (pendingAnsi) {
|
||||||
|
result += pendingAnsi;
|
||||||
const graphemeWidth = visibleWidth(grapheme);
|
pendingAnsi = "";
|
||||||
|
}
|
||||||
if (currentWidth + graphemeWidth > targetWidth) {
|
result += "\t";
|
||||||
|
keptWidth += 3;
|
||||||
|
} else {
|
||||||
|
keepContiguousPrefix = false;
|
||||||
|
pendingAnsi = "";
|
||||||
|
}
|
||||||
|
visibleSoFar += 3;
|
||||||
|
if (visibleSoFar > maxWidth) {
|
||||||
|
overflowed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
i++;
|
||||||
result += grapheme;
|
continue;
|
||||||
currentWidth += graphemeWidth;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add reset code before ellipsis to prevent styling leaking into it
|
let end = i;
|
||||||
const truncated = `${result}\x1b[0m${ellipsis}`;
|
while (end < text.length && text[end] !== "\t") {
|
||||||
if (pad) {
|
const nextAnsi = extractAnsiCode(text, end);
|
||||||
const truncatedWidth = visibleWidth(truncated);
|
if (nextAnsi) {
|
||||||
return truncated + " ".repeat(Math.max(0, maxWidth - truncatedWidth));
|
break;
|
||||||
}
|
}
|
||||||
return truncated;
|
end++;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!overflowed && exhaustedInput) {
|
||||||
|
return pad ? text + " ".repeat(Math.max(0, maxWidth - visibleSoFar)) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
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