From c771ee4fbfd55e8fd76b155b8817d7b3328b7a2b Mon Sep 17 00:00:00 2001 From: Ciyan Ouyang Date: Thu, 24 Sep 2026 22:25:09 +0800 Subject: [PATCH] fix(tui): measure display width so CJK output cannot overflow the terminal _shared/width.ts counted every code point as one column, and the sub-coder result panel wrapped by String.length. East Asian wide characters take two columns, so a Chinese sub-coder report that fit by char count rendered wider than the terminal and pi-tui aborted the session: "Rendered line 147 exceeds terminal width (245 > 243)". This is the #48/#51 crash again, now for wide characters. - width.ts: add charWidth() (2 for East Asian Wide/Fullwidth and emoji, 0 for combining marks and zero-width characters, 1 otherwise) and use it in visibleWidth() and truncateLineToWidth(), which every custom widget relies on. - subagent: wrap report lines by display width and hard-split words wider than the line (space-free CJK runs), instead of by String.length. - Tests: width cases for CJK, fullwidth and combining marks, plus a regression built from the crashing line that measures columns independently of the code under test; it fails without this change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .pi/extensions/_shared/width.test.ts | 16 +++++ .pi/extensions/_shared/width.ts | 63 +++++++++++++++---- .../subagent/cjk-width-repro.test.ts | 36 +++++++++++ .pi/extensions/subagent/index.ts | 42 +++++++++++-- 4 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 .pi/extensions/subagent/cjk-width-repro.test.ts diff --git a/.pi/extensions/_shared/width.test.ts b/.pi/extensions/_shared/width.test.ts index 842fadbf..5b5d29ae 100644 --- a/.pi/extensions/_shared/width.test.ts +++ b/.pi/extensions/_shared/width.test.ts @@ -59,3 +59,19 @@ describe("truncateLineToWidth", () => { void honey; // keep import shape parity with the tracker }); }); + +describe("wide characters", () => { + it("counts CJK and fullwidth characters as two columns", () => { + expect(visibleWidth("中文")).toBe(4); + expect(visibleWidth("a中b")).toBe(4); + expect(visibleWidth("(全角)")).toBe(8); + }); + it("counts combining marks as zero columns", () => { + expect(visibleWidth("é")).toBe(1); + }); + it("truncates CJK text to the column budget", () => { + const out = truncateLineToWidth("研究报告:仓库实际位于子目录", 10); + expect(visibleWidth(out)).toBeLessThanOrEqual(10); + expect(stripAnsi(out).endsWith("…")).toBe(true); + }); +}); diff --git a/.pi/extensions/_shared/width.ts b/.pi/extensions/_shared/width.ts index 5e78ea11..fbdcba09 100644 --- a/.pi/extensions/_shared/width.ts +++ b/.pi/extensions/_shared/width.ts @@ -20,12 +20,48 @@ export function stripAnsi(s: string): string { return s.replace(SGR_RE, "").replace(OSC_RE, ""); } -// Approximation: ANSI-stripped char count (treats every visible char as width -// 1). Exact for ASCII / Latin / single-cell glyphs the tracker and status -// widgets emit. Wide CJK or emoji *under*count here, so callers should leave a -// small safety margin (see terminalColumns/SAFETY_MARGIN). +// Terminal column width of one code point: 0 for combining marks and +// zero-width characters, 2 for East Asian Wide/Fullwidth characters and +// emoji, 1 otherwise. Mirrors wcwidth closely enough for pi-tui's +// line-width check; CJK text counted as 1 column per char overflowed the +// terminal and crashed pi. +export function charWidth(ch: string): number { + const cp = ch.codePointAt(0) ?? 0; + if (cp === 0 || cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0; + if ( + (cp >= 0x0300 && cp <= 0x036f) || // combining diacritics + (cp >= 0x200b && cp <= 0x200f) || // zero-width space/joiners, marks + (cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors + (cp >= 0xe0100 && cp <= 0xe01ef) + ) { + return 0; + } + if ( + (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo + (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, punctuation + (cp >= 0x3041 && cp <= 0x33ff) || // kana, CJK symbols + (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext A + (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified + (cp >= 0xa000 && cp <= 0xa4cf) || // Yi + (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables + (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility + (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms + (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms + (cp >= 0xffe0 && cp <= 0xffe6) || + (cp >= 0x1f300 && cp <= 0x1f64f) || // emoji + (cp >= 0x1f900 && cp <= 0x1f9ff) || + (cp >= 0x20000 && cp <= 0x3fffd) // CJK Ext B+ + ) { + return 2; + } + return 1; +} + +// Display width of an ANSI-stripped string, in terminal columns. export function visibleWidth(s: string): number { - return [...stripAnsi(s)].length; + let w = 0; + for (const ch of stripAnsi(s)) w += charWidth(ch); + return w; } // Truncate `line` so its visible width fits `maxWidth`. ANSI escapes are @@ -57,17 +93,20 @@ export function truncateLineToWidth(line: string, maxWidth: number, ellipsis = " continue; } } - if (visible >= cutAt) break; - out += ch; - visible += 1; - i += 1; + const cp = line.codePointAt(i) ?? 0; + const glyph = String.fromCodePoint(cp); + const w = charWidth(glyph); + if (visible + w > cutAt) break; + out += glyph; + visible += w; + i += glyph.length; } return out + ellipsis + "\x1b[0m"; } -// Current terminal width, with a small safety margin to absorb wide Unicode -// chars that visibleWidth's char-count approximation under-measures. Falls -// back to `fallback` columns when stdout isn't a TTY (headless runs). +// Current terminal width, with a small safety margin for glyphs whose +// rendered width differs from charWidth's table (ambiguous-width symbols, +// newer emoji). Falls back to `fallback` columns when stdout isn't a TTY. const SAFETY_MARGIN = 2; export function terminalColumns(fallback = 80): number { const c = (process.stdout && (process.stdout as any).columns) | 0; diff --git a/.pi/extensions/subagent/cjk-width-repro.test.ts b/.pi/extensions/subagent/cjk-width-repro.test.ts new file mode 100644 index 00000000..83cf7877 --- /dev/null +++ b/.pi/extensions/subagent/cjk-width-repro.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { makeComponent } from "./index.ts"; + +// Independent of the code under test: CJK ideographs, CJK punctuation and +// fullwidth forms take two terminal columns, everything used here takes one. +const columns = (s: string) => + [...s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")].reduce( + (w, ch) => w + (/[\u3000-\u303f\u4e00-\u9fff\uff00-\uff60]/.test(ch) ? 2 : 1), + 0, + ); + +// Regression for a crash with CJK sub-coder reports: the wrap measured +// String.length, so a Chinese sentence fitting in 241 chars rendered +// 245 columns wide at terminal width 243 and pi-tui aborted the session +// ("Rendered line 147 exceeds terminal width (245 > 243)"). + +const report = + "**直接回答**:skill 成功获取。`github.com/pans0020/research-writing` 为 404,实际仓库是 **Pans0020/opencode-skills** 的 `research-writing/` 目录(含 4 个文件:SKILL.md 24.1KB、DEPLOY.md、QUICKSTART.md、README.md)。agentskill.sh 可正常访问。"; + +describe("CJK sub-coder report fits the pi-supplied width", () => { + for (const width of [243, 120, 80, 40]) { + it(`wraps to width ${width}`, () => { + const out = makeComponent(["✓ pans0020-research-writing", report, " …"]).render(width); + for (const line of out) expect(columns(line)).toBeLessThanOrEqual(width - 1); + // No text is lost: every CJK char of the report is still present. + const joined = out.join(""); + for (const ch of "直接回答成功获取实际仓库是目录可正常访问") expect(joined).toContain(ch); + }); + } + + it("wraps a long CJK run with no spaces", () => { + const out = makeComponent(["中".repeat(300)]).render(81); + expect(out.length).toBeGreaterThan(1); + for (const line of out) expect(columns(line)).toBeLessThanOrEqual(80); + }); +}); diff --git a/.pi/extensions/subagent/index.ts b/.pi/extensions/subagent/index.ts index 46b3ffdb..126b642f 100644 --- a/.pi/extensions/subagent/index.ts +++ b/.pi/extensions/subagent/index.ts @@ -9,6 +9,7 @@ import { type SubCoderResult, } from "./spawn.ts"; import { SubCoderTracker } from "./tracker.ts"; +import { charWidth, visibleWidth } from "../_shared/width.ts"; // The `dispatch` tool: the main little-coder spawns isolated child little-coder // sessions ("sub-coders") to research a focused question — they read the repo @@ -256,21 +257,50 @@ function stripAnsi(text: string): string { return text.replace(ANSI_RE, ""); } -/** Word-wrap plain text at whitespace; assumes long tokens are pre-chunked. */ +/** Split a word into pieces no wider than maxWidth display columns. */ +function splitByWidth(word: string, maxWidth: number): string[] { + const pieces: string[] = []; + let current = ""; + let width = 0; + for (const ch of word) { + const w = charWidth(ch); + if (width + w > maxWidth && current) { + pieces.push(current); + current = ""; + width = 0; + } + current += ch; + width += w; + } + if (current) pieces.push(current); + return pieces; +} + +/** + * Word-wrap plain text at whitespace, measuring display columns so CJK text + * (two columns per char, often with no spaces to break at) cannot overflow. + */ function wrapPlainText(text: string, maxWidth: number): string[] { - if (text.length <= maxWidth) return [text]; - const words = text.split(/\s+/); + if (visibleWidth(text) <= maxWidth) return [text]; + const words = text.split(/\s+/).flatMap((word) => + visibleWidth(word) > maxWidth ? splitByWidth(word, maxWidth) : [word], + ); const result: string[] = []; let current = ""; + let currentWidth = 0; for (const word of words) { if (!word) continue; - if (current.length === 0) { + const w = visibleWidth(word); + if (currentWidth === 0) { current = word; - } else if (current.length + 1 + word.length <= maxWidth) { + currentWidth = w; + } else if (currentWidth + 1 + w <= maxWidth) { current += " " + word; + currentWidth += 1 + w; } else { result.push(current); current = word; + currentWidth = w; } } if (current) result.push(current); @@ -280,7 +310,7 @@ function wrapPlainText(text: string, maxWidth: number): string[] { /** Wrap one ANSI-aware line to width, re-applying any leading SGR prefix. */ function wrapLine(line: string, width: number): string[] { const plain = stripAnsi(line); - if (plain.length <= width) return [line]; + if (visibleWidth(plain) <= width) return [line]; const { prefix } = extractAnsiPrefix(line); const wrappedLines = wrapPlainText(plain, width); return wrappedLines.map((l) => prefix + l);