Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .pi/extensions/_shared/width.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
63 changes: 51 additions & 12 deletions .pi/extensions/_shared/width.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions .pi/extensions/subagent/cjk-width-repro.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
42 changes: 36 additions & 6 deletions .pi/extensions/subagent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down