diff --git a/packages/terminal-core/src/ansi.test.ts b/packages/terminal-core/src/ansi.test.ts index 533f1d6abb69..832313ba940e 100644 --- a/packages/terminal-core/src/ansi.test.ts +++ b/packages/terminal-core/src/ansi.test.ts @@ -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); + }); }); diff --git a/packages/terminal-core/src/ansi.ts b/packages/terminal-core/src/ansi.ts index cf226b102bab..bb5c164d87cb 100644 --- a/packages/terminal-core/src/ansi.ts +++ b/packages/terminal-core/src/ansi.ts @@ -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; +} diff --git a/packages/terminal-core/src/table.test.ts b/packages/terminal-core/src/table.test.ts index 13ad57348ceb..59caaa688aeb 100644 --- a/packages/terminal-core/src/table.test.ts +++ b/packages/terminal-core/src/table.test.ts @@ -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, diff --git a/packages/terminal-core/src/table.ts b/packages/terminal-core/src/table.ts index e662bf106a9c..06366ac3704e 100644 --- a/packages/terminal-core/src/table.ts +++ b/packages/terminal-core/src/table.ts @@ -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[] {