mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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("[31mab[0m", 1)).toBe("[31ma[0m");
|
||||
expect(truncateToVisibleWidth("[31m表文[0m", 1)).toBe("[31m[0m");
|
||||
expect(visibleWidth(truncateToVisibleWidth("[31m表文[0m", 1))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user