Skip to content

Commit f857e8d

Browse files
ly-wang19claude
andauthored
fix(terminal): wrap long wide-char words by visible width, not code-point count (#96746)
wrapNoteMessage measures every fit decision in visible columns, but its splitLongWord fallback (for a single word longer than the line budget) sliced the word into groups of maxLen code points. maxLen is a visible-column budget, so a run of wide characters (CJK / fullwidth / emoji, 2 columns each) produced lines up to twice the budget — e.g. a 24-char CJK word at maxWidth 20 emitted a 40-column line. Accumulate grapheme visible width (the same unit visibleWidth uses) and start a new segment when the next grapheme would exceed maxLen, so every wrapped segment fits the budget; a single grapheme wider than the budget still emits, preserving progress. ASCII wrapping is unchanged. Co-authored-by: ly-wang19 <[email protected]> Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
1 parent a048aea commit f857e8d

2 files changed

Lines changed: 29 additions & 4 deletions

File tree

packages/terminal-core/src/note.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Terminal Core module implements note behavior.
22
import { AsyncLocalStorage } from "node:async_hooks";
33
import { note as clackNote } from "@clack/prompts";
4-
import { visibleWidth } from "./ansi.js";
4+
import { splitGraphemes, visibleWidth } from "./ansi.js";
55
import { stylePromptTitle } from "./prompt-style.js";
66
import { normalizeLowercaseStringOrEmpty } from "./string.js";
77

@@ -26,10 +26,23 @@ function splitLongWord(word: string, maxLen: number): string[] {
2626
if (maxLen <= 0) {
2727
return [word];
2828
}
29-
const chars = Array.from(word);
29+
// maxLen is a visible-column budget, so accumulate grapheme visible width (CJK/emoji count as 2
30+
// columns) instead of code-point count; otherwise a wide-char run overflows the line by up to 2x.
3031
const parts: string[] = [];
31-
for (let i = 0; i < chars.length; i += maxLen) {
32-
parts.push(chars.slice(i, i + maxLen).join(""));
32+
let current = "";
33+
let currentWidth = 0;
34+
for (const grapheme of splitGraphemes(word)) {
35+
const width = visibleWidth(grapheme);
36+
if (current && currentWidth + width > maxLen) {
37+
parts.push(current);
38+
current = "";
39+
currentWidth = 0;
40+
}
41+
current += grapheme;
42+
currentWidth += width;
43+
}
44+
if (current) {
45+
parts.push(current);
3346
}
3447
return parts.length > 0 ? parts : [word];
3548
}

packages/terminal-core/src/table.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,4 +383,16 @@ describe("wrapNoteMessage", () => {
383383
expect(wrapNoteMessage(new Error("boom"), { maxWidth: 20, columns: 80 })).toBe("Error: boom");
384384
expect(wrapNoteMessage({ message: "boom" }, { maxWidth: 20, columns: 80 })).toBe("");
385385
});
386+
387+
it("keeps wrapped lines within the visible-column budget for wide (CJK) words", () => {
388+
// A long CJK run with no separators reaches splitLongWord; each fullwidth char is 2 columns,
389+
// so splitting by code-point count would emit lines up to 2x the budget.
390+
const input = "東京特許許可局長今日休暇許可局長今日休暇東京特許";
391+
const lines = wrapNoteMessage(input, { maxWidth: 20, columns: 80 }).split("\n");
392+
393+
for (const line of lines) {
394+
expect(visibleWidth(line)).toBeLessThanOrEqual(20);
395+
}
396+
expect(lines.join("")).toBe(input);
397+
});
386398
});

0 commit comments

Comments
 (0)