惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
J
Java Code Geeks
I
InfoQ
V
Visual Studio Blog
M
MIT News - Artificial intelligence
H
Help Net Security
博客园_首页
Blog — PlanetScale
Blog — PlanetScale
F
Fortinet All Blogs
Apple Machine Learning Research
Apple Machine Learning Research
人人都是产品经理
人人都是产品经理
G
Google Developers Blog
A
About on SuperTechFans
腾讯CDC
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Last Week in AI
Last Week in AI
小众软件
小众软件
aimingoo的专栏
aimingoo的专栏
罗磊的独立博客
大猫的无限游戏
大猫的无限游戏
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
云风的 BLOG
云风的 BLOG
S
SegmentFault 最新的问题
WordPress大学
WordPress大学

Recent Commits to openclaw:main

test: merge chat side-result checks · openclaw/openclaw@ddd2c2a test: merge cron history checks · openclaw/openclaw@f7eb746 test: merge responsive navigation shell checks · openclaw/openclaw@c2e4b47 docs(changelog): add codex oauth fixes · openclaw/openclaw@628e6cd test: merge navigation routing cases · openclaw/openclaw@5d8cecb Tests: mock channel registry bundled fallback · openclaw/openclaw@2b08233 Secrets: avoid broad web search discovery for single plugin config · openclaw/openclaw@a464f59 test: merge config view browser checks · openclaw/openclaw@20cf511 fix(status): align oauth health with runtime · openclaw/openclaw@eed7116 feat: add macOS screen snapshots for monitor preview (#67954) thanks … · openclaw/openclaw@f377db1 fix: report shared auth scopes in hello-ok (#67810) thanks @BunsDev · openclaw/openclaw@0b6c39b Auto-reply: avoid eager bundled route fallback · openclaw/openclaw@3ea1bf4 Tests: narrow session binding contract setup · openclaw/openclaw@54e4e16 fix(macOS): enable undo/redo in webchat composer text input (#34962) · openclaw/openclaw@00951dc Tests: speed up channel setup promotion · openclaw/openclaw@82b529a Docs: refresh agent instructions · openclaw/openclaw@5775fe2 fix(auth): serialize OAuth refresh across agents to fix #26322 (#67876) · openclaw/openclaw@8e79080 test: allow ollama public surface boundary test · openclaw/openclaw@7d4f1a6 Docs: add test performance guardrails · openclaw/openclaw@89706d3 Tests: restore context-engine usage proof · openclaw/openclaw@e4c4f95 Tests: slim context engine runtime coverage · openclaw/openclaw@74c198f ci: retry failed custom checkouts · openclaw/openclaw@0ee5baf test: trim duplicate provider auth onboarding cases · openclaw/openclaw@1ffc02e matrix: fix sessions_spawn --thread subagent session spawning (#67643) · openclaw/openclaw@1ce2596 test: reduce auth choice fixture churn · openclaw/openclaw@857b9cd test: mock health status config boundaries · openclaw/openclaw@9d5ab4a test: mock onboard config io boundary · openclaw/openclaw@299694d test: mock legacy state plugin boundaries · openclaw/openclaw@2713089 test: mock channel install boundaries · openclaw/openclaw@b945248 test: mock doctor preview channel boundaries · openclaw/openclaw@b1a3ad4
fix(terminal): clamp wide graphemes in narrow table cells...
jbetala7 · 2026-05-31 · via Recent Commits to openclaw:main

File tree

  • packages/terminal-core/src

Original file line numberDiff line numberDiff line change

@@ -1,5 +1,11 @@

11

import { describe, expect, it } from "vitest";

2-

import { sanitizeForLog, splitGraphemes, stripAnsi, visibleWidth } from "./ansi.js";

2+

import {

3+

sanitizeForLog,

4+

splitGraphemes,

5+

stripAnsi,

6+

truncateToVisibleWidth,

7+

visibleWidth,

8+

} from "./ansi.js";

39
410

describe("terminal ansi helpers", () => {

511

it("strips ANSI and OSC8 sequences", () => {

@@ -34,4 +40,23 @@ describe("terminal ansi helpers", () => {

3440

expect(splitGraphemes("👨‍👩‍👧‍👦")).toEqual(["👨‍👩‍👧‍👦"]);

3541

expect(visibleWidth("👨‍👩‍👧‍👦")).toBe(2);

3642

});

43+
44+

it("truncates to a visible-width budget without splitting wide graphemes", () => {

45+

expect(truncateToVisibleWidth("abc", 2)).toBe("ab");

46+

expect(truncateToVisibleWidth("abc", 5)).toBe("abc");

47+

expect(truncateToVisibleWidth("anything", 0)).toBe("");

48+

// A wide grapheme that cannot fit the remaining budget is dropped whole,

49+

// never emitted half-width, so the result never exceeds the budget.

50+

expect(truncateToVisibleWidth("表文", 2)).toBe("表");

51+

expect(truncateToVisibleWidth("表", 1)).toBe("");

52+

expect(visibleWidth(truncateToVisibleWidth("📸📸", 1))).toBeLessThanOrEqual(1);

53+

});

54+
55+

it("preserves ANSI sequences when truncating styled text", () => {

56+

// Trailing reset is retained even when its grapheme is dropped, so the cell

57+

// does not bleed styling into surrounding padding.

58+

expect(truncateToVisibleWidth("ab", 1)).toBe("a");

59+

expect(truncateToVisibleWidth("表文", 1)).toBe("");

60+

expect(visibleWidth(truncateToVisibleWidth("表文", 1))).toBe(0);

61+

});

3762

});

Original file line numberDiff line numberDiff line change

@@ -118,3 +118,50 @@ export function visibleWidth(input: string): number {

118118

0,

119119

);

120120

}

121+
122+

/**

123+

* Truncate to at most `maxWidth` visible columns, dropping whole grapheme

124+

* clusters that would overflow while preserving ANSI sequences verbatim

125+

* (they have zero visible width). A single wide grapheme that cannot fit the

126+

* remaining budget is dropped rather than emitted partially, so the result is

127+

* always `visibleWidth(result) <= maxWidth`. Callers that need a fixed width

128+

* pad the (possibly short) remainder themselves.

129+

*/

130+

export function truncateToVisibleWidth(input: string, maxWidth: number): string {

131+

if (maxWidth <= 0) {

132+

return "";

133+

}

134+

if (visibleWidth(input) <= maxWidth) {

135+

return input;

136+

}

137+

const ansi = new RegExp(`${ANSI_OSC_PATTERN}|${ANSI_CSI_PATTERN}`, "g");

138+

let out = "";

139+

let used = 0;

140+

let pos = 0;

141+

// Once the visible budget is spent we stop emitting graphemes but keep

142+

// copying ANSI sequences, so trailing resets/link-closes still land and the

143+

// truncated cell does not bleed styling into the padding or border.

144+

let budgetSpent = false;

145+

const appendVisible = (segment: string): void => {

146+

if (budgetSpent) {

147+

return;

148+

}

149+

for (const grapheme of splitGraphemes(segment)) {

150+

const width = graphemeWidth(grapheme);

151+

if (used + width > maxWidth) {

152+

budgetSpent = true;

153+

return;

154+

}

155+

out += grapheme;

156+

used += width;

157+

}

158+

};

159+

let match: RegExpExecArray | null;

160+

while ((match = ansi.exec(input)) !== null) {

161+

appendVisible(input.slice(pos, match.index));

162+

out += match[0];

163+

pos = match.index + match[0].length;

164+

}

165+

appendVisible(input.slice(pos));

166+

return out;

167+

}

Original file line numberDiff line numberDiff line change

@@ -200,6 +200,39 @@ describe("renderTable", () => {

200200

}

201201

});

202202
203+

it("keeps borders aligned when a wide grapheme lands in a narrow cell", () => {

204+

// A width-2 CJK/emoji glyph in a column whose content width is 1 cannot be

205+

// wrapped, so padCell must clamp it instead of overflowing the cell and

206+

// pushing the right border out of alignment.

207+

const out = renderTable({

208+

border: "ascii",

209+

padding: 0,

210+

columns: [{ key: "B", header: "B", minWidth: 1, maxWidth: 1 }],

211+

rows: [{ B: "表" }],

212+

});

213+

const lines = out.trimEnd().split("\n");

214+

for (const line of lines) {

215+

expect(visibleWidth(line)).toBe(3);

216+

}

217+

});

218+
219+

it("keeps borders aligned when a narrow flex column receives wide content", () => {

220+

const out = renderTable({

221+

width: 10,

222+

border: "ascii",

223+

columns: [

224+

{ key: "A", header: "long header here" },

225+

{ key: "B", header: "", flex: true },

226+

],

227+

rows: [{ A: "data", B: "📸" }],

228+

});

229+

const lines = out.trimEnd().split("\n");

230+

const headerWidth = visibleWidth(lines[0] ?? "");

231+

for (const line of lines) {

232+

expect(visibleWidth(line)).toBe(headerWidth);

233+

}

234+

});

235+
203236

it("consumes unsupported escape sequences without hanging", () => {

204237

const out = renderTable({

205238

width: 48,

Original file line numberDiff line numberDiff line change

@@ -1,4 +1,4 @@

1-

import { splitGraphemes, visibleWidth } from "./ansi.js";

1+

import { splitGraphemes, truncateToVisibleWidth, visibleWidth } from "./ansi.js";

22

import { displayString } from "./display-string.js";

33
44

type Align = "left" | "right" | "center";

@@ -48,20 +48,24 @@ function repeat(ch: string, n: number): string {

4848

}

4949
5050

function padCell(text: string, width: number, align: Align): string {

51-

const w = visibleWidth(text);

51+

// A single grapheme wider than the cell (e.g. a width-2 CJK/emoji glyph in a

52+

// width-1 column) survives wrapLine intact, so clamp here to keep every cell

53+

// exactly `width` columns and preserve the border-alignment invariant.

54+

const content = visibleWidth(text) > width ? truncateToVisibleWidth(text, width) : text;

55+

const w = visibleWidth(content);

5256

if (w >= width) {

53-

return text;

57+

return content;

5458

}

5559

const pad = width - w;

5660

if (align === "right") {

57-

return `${repeat(" ", pad)}${text}`;

61+

return `${repeat(" ", pad)}${content}`;

5862

}

5963

if (align === "center") {

6064

const left = Math.floor(pad / 2);

6165

const right = pad - left;

62-

return `${repeat(" ", left)}${text}${repeat(" ", right)}`;

66+

return `${repeat(" ", left)}${content}${repeat(" ", right)}`;

6367

}

64-

return `${text}${repeat(" ", pad)}`;

68+

return `${content}${repeat(" ", pad)}`;

6569

}

6670
6771

function wrapLine(text: string, width: number): string[] {