fix(terminal): stabilize skills table width across Terminal.app and iTerm (#42849)

* Terminal: measure grapheme display width

* Tests: cover grapheme terminal width

* Terminal: wrap table cells by grapheme width

* Tests: cover emoji table alignment

* Terminal: refine table wrapping and width handling

* Terminal: stop shrinking CLI tables by one column

* Skills: use Terminal-safe emoji in list output

* Changelog: note terminal skills table fixes

* Skills: normalize emoji presentation across outputs

* Terminal: consume unsupported escape bytes in tables
This commit is contained in:
Vincent Koc
2026-03-11 09:13:10 -04:00
committed by GitHub
parent 10e6e27451
commit 04e103d10e
32 changed files with 299 additions and 67 deletions

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { sanitizeForLog, stripAnsi } from "./ansi.js";
import { sanitizeForLog, splitGraphemes, stripAnsi, visibleWidth } from "./ansi.js";
describe("terminal ansi helpers", () => {
it("strips ANSI and OSC8 sequences", () => {
@@ -11,4 +11,16 @@ describe("terminal ansi helpers", () => {
const input = "\u001B[31mwarn\u001B[0m\r\nnext\u0000line\u007f";
expect(sanitizeForLog(input)).toBe("warnnextline");
});
it("measures wide graphemes by terminal cell width", () => {
expect(visibleWidth("abc")).toBe(3);
expect(visibleWidth("📸 skill")).toBe(8);
expect(visibleWidth("表")).toBe(2);
expect(visibleWidth("\u001B[31m📸\u001B[0m")).toBe(2);
});
it("keeps emoji zwj sequences as single graphemes", () => {
expect(splitGraphemes("👨‍👩‍👧‍👦")).toEqual(["👨‍👩‍👧‍👦"]);
expect(visibleWidth("👨‍👩‍👧‍👦")).toBe(2);
});
});

View File

@@ -4,11 +4,29 @@ const OSC8_PATTERN = "\\x1b\\]8;;.*?\\x1b\\\\|\\x1b\\]8;;\\x1b\\\\";
const ANSI_REGEX = new RegExp(ANSI_SGR_PATTERN, "g");
const OSC8_REGEX = new RegExp(OSC8_PATTERN, "g");
const graphemeSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
export function stripAnsi(input: string): string {
return input.replace(OSC8_REGEX, "").replace(ANSI_REGEX, "");
}
export function splitGraphemes(input: string): string[] {
if (!input) {
return [];
}
if (!graphemeSegmenter) {
return Array.from(input);
}
try {
return Array.from(graphemeSegmenter.segment(input), (segment) => segment.segment);
} catch {
return Array.from(input);
}
}
/**
* Sanitize a value for safe interpolation into log messages.
* Strips ANSI escape sequences, C0 control characters (U+0000U+001F),
@@ -22,6 +40,75 @@ export function sanitizeForLog(v: string): string {
return out.replaceAll(String.fromCharCode(0x7f), "");
}
export function visibleWidth(input: string): number {
return Array.from(stripAnsi(input)).length;
function isZeroWidthCodePoint(codePoint: number): boolean {
return (
(codePoint >= 0x0300 && codePoint <= 0x036f) ||
(codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
(codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
(codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
(codePoint >= 0xfe20 && codePoint <= 0xfe2f) ||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
codePoint === 0x200d
);
}
function isFullWidthCodePoint(codePoint: number): boolean {
if (codePoint < 0x1100) {
return false;
}
return (
codePoint <= 0x115f ||
codePoint === 0x2329 ||
codePoint === 0x232a ||
(codePoint >= 0x2e80 && codePoint <= 0x3247 && codePoint !== 0x303f) ||
(codePoint >= 0x3250 && codePoint <= 0x4dbf) ||
(codePoint >= 0x4e00 && codePoint <= 0xa4c6) ||
(codePoint >= 0xa960 && codePoint <= 0xa97c) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
(codePoint >= 0xfe30 && codePoint <= 0xfe6b) ||
(codePoint >= 0xff01 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
(codePoint >= 0x1aff0 && codePoint <= 0x1aff3) ||
(codePoint >= 0x1aff5 && codePoint <= 0x1affb) ||
(codePoint >= 0x1affd && codePoint <= 0x1affe) ||
(codePoint >= 0x1b000 && codePoint <= 0x1b2ff) ||
(codePoint >= 0x1f200 && codePoint <= 0x1f251) ||
(codePoint >= 0x20000 && codePoint <= 0x3fffd)
);
}
const emojiLikePattern = /[\p{Extended_Pictographic}\p{Regional_Indicator}\u20e3]/u;
function graphemeWidth(grapheme: string): number {
if (!grapheme) {
return 0;
}
if (emojiLikePattern.test(grapheme)) {
return 2;
}
let sawPrintable = false;
for (const char of grapheme) {
const codePoint = char.codePointAt(0);
if (codePoint == null) {
continue;
}
if (isZeroWidthCodePoint(codePoint)) {
continue;
}
if (isFullWidthCodePoint(codePoint)) {
return 2;
}
sawPrintable = true;
}
return sawPrintable ? 1 : 0;
}
export function visibleWidth(input: string): number {
return splitGraphemes(stripAnsi(input)).reduce(
(sum, grapheme) => sum + graphemeWidth(grapheme),
0,
);
}

View File

@@ -83,6 +83,38 @@ describe("renderTable", () => {
}
});
it("trims leading spaces on wrapped ANSI-colored continuation lines", () => {
const out = renderTable({
width: 113,
columns: [
{ key: "Status", header: "Status", minWidth: 10 },
{ key: "Skill", header: "Skill", minWidth: 18, flex: true },
{ key: "Description", header: "Description", minWidth: 24, flex: true },
{ key: "Source", header: "Source", minWidth: 10 },
],
rows: [
{
Status: "✓ ready",
Skill: "🌤️ weather",
Description:
`\x1b[2mGet current weather and forecasts via wttr.in or Open-Meteo. ` +
`Use when: user asks about weather, temperature, or forecasts for any location.` +
`\x1b[0m`,
Source: "openclaw-bundled",
},
],
});
const lines = out
.trimEnd()
.split("\n")
.filter((line) => line.includes("Use when"));
expect(lines).toHaveLength(1);
expect(lines[0]).toContain("\u001b[2mUse when");
expect(lines[0]).not.toContain("│ Use when");
expect(lines[0]).not.toContain("│ \x1b[2m Use when");
});
it("respects explicit newlines in cell values", () => {
const out = renderTable({
width: 48,
@@ -99,6 +131,45 @@ describe("renderTable", () => {
expect(line1Index).toBeGreaterThan(-1);
expect(line2Index).toBe(line1Index + 1);
});
it("keeps table borders aligned when cells contain wide emoji graphemes", () => {
const width = 72;
const out = renderTable({
width,
columns: [
{ key: "Status", header: "Status", minWidth: 10 },
{ key: "Skill", header: "Skill", minWidth: 18 },
{ key: "Description", header: "Description", minWidth: 18, flex: true },
{ key: "Source", header: "Source", minWidth: 10 },
],
rows: [
{
Status: "✗ missing",
Skill: "📸 peekaboo",
Description: "Capture screenshots from macOS windows and keep table wrapping stable.",
Source: "openclaw-bundled",
},
],
});
for (const line of out.trimEnd().split("\n")) {
expect(visibleWidth(line)).toBe(width);
}
});
it("consumes unsupported escape sequences without hanging", () => {
const out = renderTable({
width: 48,
columns: [
{ key: "K", header: "K", minWidth: 6 },
{ key: "V", header: "V", minWidth: 12, flex: true },
],
rows: [{ K: "row", V: "before \x1b[2J after" }],
});
expect(out).toContain("before");
expect(out).toContain("after");
});
});
describe("wrapNoteMessage", () => {

View File

@@ -1,5 +1,5 @@
import { displayString } from "../utils.js";
import { visibleWidth } from "./ansi.js";
import { splitGraphemes, visibleWidth } from "./ansi.js";
type Align = "left" | "right" | "center";
@@ -94,13 +94,22 @@ function wrapLine(text: string, width: number): string[] {
}
}
const cp = text.codePointAt(i);
if (!cp) {
break;
let nextEsc = text.indexOf(ESC, i);
if (nextEsc < 0) {
nextEsc = text.length;
}
const ch = String.fromCodePoint(cp);
tokens.push({ kind: "char", value: ch });
i += ch.length;
if (nextEsc === i) {
// Consume unsupported escape bytes as plain characters so wrapping
// cannot stall on unknown ANSI/control sequences.
tokens.push({ kind: "char", value: ESC });
i += ESC.length;
continue;
}
const plainChunk = text.slice(i, nextEsc);
for (const grapheme of splitGraphemes(plainChunk)) {
tokens.push({ kind: "char", value: grapheme });
}
i = nextEsc;
}
const firstCharIndex = tokens.findIndex((t) => t.kind === "char");
@@ -139,7 +148,7 @@ function wrapLine(text: string, width: number): string[] {
const bufToString = (slice?: Token[]) => (slice ?? buf).map((t) => t.value).join("");
const bufVisibleWidth = (slice: Token[]) =>
slice.reduce((acc, t) => acc + (t.kind === "char" ? 1 : 0), 0);
slice.reduce((acc, t) => acc + (t.kind === "char" ? visibleWidth(t.value) : 0), 0);
const pushLine = (value: string) => {
const cleaned = value.replace(/\s+$/, "");
@@ -149,6 +158,20 @@ function wrapLine(text: string, width: number): string[] {
lines.push(cleaned);
};
const trimLeadingSpaces = (tokens: Token[]) => {
while (true) {
const firstCharIndex = tokens.findIndex((token) => token.kind === "char");
if (firstCharIndex < 0) {
return;
}
const firstChar = tokens[firstCharIndex];
if (!firstChar || !isSpaceChar(firstChar.value)) {
return;
}
tokens.splice(firstCharIndex, 1);
}
};
const flushAt = (breakAt: number | null) => {
if (buf.length === 0) {
return;
@@ -164,10 +187,7 @@ function wrapLine(text: string, width: number): string[] {
const left = buf.slice(0, breakAt);
const rest = buf.slice(breakAt);
pushLine(bufToString(left));
while (rest.length > 0 && rest[0]?.kind === "char" && isSpaceChar(rest[0].value)) {
rest.shift();
}
trimLeadingSpaces(rest);
buf.length = 0;
buf.push(...rest);
@@ -195,12 +215,16 @@ function wrapLine(text: string, width: number): string[] {
}
continue;
}
if (bufVisible + 1 > width && bufVisible > 0) {
const charWidth = visibleWidth(ch);
if (bufVisible + charWidth > width && bufVisible > 0) {
flushAt(lastBreakIndex);
}
if (bufVisible === 0 && isSpaceChar(ch)) {
continue;
}
buf.push(token);
bufVisible += 1;
bufVisible += charWidth;
if (isBreakChar(ch)) {
lastBreakIndex = buf.length;
}
@@ -231,6 +255,10 @@ function normalizeWidth(n: number | undefined): number | undefined {
return Math.floor(n);
}
export function getTerminalTableWidth(minWidth = 60, fallbackWidth = 120): number {
return Math.max(minWidth, process.stdout.columns ?? fallbackWidth);
}
export function renderTable(opts: RenderTableOptions): string {
const rows = opts.rows.map((row) => {
const next: Record<string, string> = {};