fix(terminal): clamp wide graphemes in narrow table cells

Clamps ANSI-aware terminal table cells before padding so width-2 graphemes cannot push borders out of alignment in width-1/narrow columns.

Fixes #88556.

Proof:
- node scripts/run-vitest.mjs run packages/terminal-core/src/ansi.test.ts packages/terminal-core/src/table.test.ts
- CI run 26717035619; check-dependencies red only for unrelated current-main deadcode issue ui/src/ui/browser-redact.ts, also red on main run 26717029674. checks-node-agentic-agents-core rerun failed in unrelated src/agents/bash-tools*.test.ts outside this PR diff.

Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com>
This commit is contained in:
Jayesh Betala
2026-05-31 21:24:47 +05:30
committed by GitHub
parent 2870a28aa9
commit 29dd7847fd
4 changed files with 116 additions and 7 deletions

View File

@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { sanitizeForLog, splitGraphemes, stripAnsi, visibleWidth } from "./ansi.js";
import {
sanitizeForLog,
splitGraphemes,
stripAnsi,
truncateToVisibleWidth,
visibleWidth,
} from "./ansi.js";
describe("terminal ansi helpers", () => {
it("strips ANSI and OSC8 sequences", () => {
@@ -34,4 +40,23 @@ describe("terminal ansi helpers", () => {
expect(splitGraphemes("👨‍👩‍👧‍👦")).toEqual(["👨‍👩‍👧‍👦"]);
expect(visibleWidth("👨‍👩‍👧‍👦")).toBe(2);
});
it("truncates to a visible-width budget without splitting wide graphemes", () => {
expect(truncateToVisibleWidth("abc", 2)).toBe("ab");
expect(truncateToVisibleWidth("abc", 5)).toBe("abc");
expect(truncateToVisibleWidth("anything", 0)).toBe("");
// A wide grapheme that cannot fit the remaining budget is dropped whole,
// never emitted half-width, so the result never exceeds the budget.
expect(truncateToVisibleWidth("表文", 2)).toBe("表");
expect(truncateToVisibleWidth("表", 1)).toBe("");
expect(visibleWidth(truncateToVisibleWidth("📸📸", 1))).toBeLessThanOrEqual(1);
});
it("preserves ANSI sequences when truncating styled text", () => {
// Trailing reset is retained even when its grapheme is dropped, so the cell
// does not bleed styling into surrounding padding.
expect(truncateToVisibleWidth("ab", 1)).toBe("a");
expect(truncateToVisibleWidth("表文", 1)).toBe("");
expect(visibleWidth(truncateToVisibleWidth("表文", 1))).toBe(0);
});
});

View File

@@ -118,3 +118,50 @@ export function visibleWidth(input: string): number {
0,
);
}
/**
* Truncate to at most `maxWidth` visible columns, dropping whole grapheme
* clusters that would overflow while preserving ANSI sequences verbatim
* (they have zero visible width). A single wide grapheme that cannot fit the
* remaining budget is dropped rather than emitted partially, so the result is
* always `visibleWidth(result) <= maxWidth`. Callers that need a fixed width
* pad the (possibly short) remainder themselves.
*/
export function truncateToVisibleWidth(input: string, maxWidth: number): string {
if (maxWidth <= 0) {
return "";
}
if (visibleWidth(input) <= maxWidth) {
return input;
}
const ansi = new RegExp(`${ANSI_OSC_PATTERN}|${ANSI_CSI_PATTERN}`, "g");
let out = "";
let used = 0;
let pos = 0;
// Once the visible budget is spent we stop emitting graphemes but keep
// copying ANSI sequences, so trailing resets/link-closes still land and the
// truncated cell does not bleed styling into the padding or border.
let budgetSpent = false;
const appendVisible = (segment: string): void => {
if (budgetSpent) {
return;
}
for (const grapheme of splitGraphemes(segment)) {
const width = graphemeWidth(grapheme);
if (used + width > maxWidth) {
budgetSpent = true;
return;
}
out += grapheme;
used += width;
}
};
let match: RegExpExecArray | null;
while ((match = ansi.exec(input)) !== null) {
appendVisible(input.slice(pos, match.index));
out += match[0];
pos = match.index + match[0].length;
}
appendVisible(input.slice(pos));
return out;
}

View File

@@ -200,6 +200,39 @@ describe("renderTable", () => {
}
});
it("keeps borders aligned when a wide grapheme lands in a narrow cell", () => {
// A width-2 CJK/emoji glyph in a column whose content width is 1 cannot be
// wrapped, so padCell must clamp it instead of overflowing the cell and
// pushing the right border out of alignment.
const out = renderTable({
border: "ascii",
padding: 0,
columns: [{ key: "B", header: "B", minWidth: 1, maxWidth: 1 }],
rows: [{ B: "表" }],
});
const lines = out.trimEnd().split("\n");
for (const line of lines) {
expect(visibleWidth(line)).toBe(3);
}
});
it("keeps borders aligned when a narrow flex column receives wide content", () => {
const out = renderTable({
width: 10,
border: "ascii",
columns: [
{ key: "A", header: "long header here" },
{ key: "B", header: "", flex: true },
],
rows: [{ A: "data", B: "📸" }],
});
const lines = out.trimEnd().split("\n");
const headerWidth = visibleWidth(lines[0] ?? "");
for (const line of lines) {
expect(visibleWidth(line)).toBe(headerWidth);
}
});
it("consumes unsupported escape sequences without hanging", () => {
const out = renderTable({
width: 48,

View File

@@ -1,4 +1,4 @@
import { splitGraphemes, visibleWidth } from "./ansi.js";
import { splitGraphemes, truncateToVisibleWidth, visibleWidth } from "./ansi.js";
import { displayString } from "./display-string.js";
type Align = "left" | "right" | "center";
@@ -48,20 +48,24 @@ function repeat(ch: string, n: number): string {
}
function padCell(text: string, width: number, align: Align): string {
const w = visibleWidth(text);
// A single grapheme wider than the cell (e.g. a width-2 CJK/emoji glyph in a
// width-1 column) survives wrapLine intact, so clamp here to keep every cell
// exactly `width` columns and preserve the border-alignment invariant.
const content = visibleWidth(text) > width ? truncateToVisibleWidth(text, width) : text;
const w = visibleWidth(content);
if (w >= width) {
return text;
return content;
}
const pad = width - w;
if (align === "right") {
return `${repeat(" ", pad)}${text}`;
return `${repeat(" ", pad)}${content}`;
}
if (align === "center") {
const left = Math.floor(pad / 2);
const right = pad - left;
return `${repeat(" ", left)}${text}${repeat(" ", right)}`;
return `${repeat(" ", left)}${content}${repeat(" ", right)}`;
}
return `${text}${repeat(" ", pad)}`;
return `${content}${repeat(" ", pad)}`;
}
function wrapLine(text: string, width: number): string[] {