diff --git a/app/src/components/terminal/copyCleanup.test.ts b/app/src/components/terminal/copyCleanup.test.ts index cd4a3bf..3ab54dd 100644 --- a/app/src/components/terminal/copyCleanup.test.ts +++ b/app/src/components/terminal/copyCleanup.test.ts @@ -208,6 +208,14 @@ describe("rejoinHardWraps", () => { expect(rejoinHardWraps(`${a}\n の続き`, { cols: 69 })).toBe(`${a}の続き`); }); + it("joins astral wide characters (CJK Ext-B) without inserting a space", () => { + const a = " " + "\u{20000}".repeat(33); // 2 + 66 = 68 cells + expect(cellWidth(a)).toBe(68); + expect(rejoinHardWraps(`${a}\n \u{20001}\u{20002}`, { cols: 69 })).toBe(`${a}\u{20001}\u{20002}`); + const e = " " + "\u{1F600}".repeat(33); // emoji, also two cells each + expect(rejoinHardWraps(`${e}\n \u{1F601}`, { cols: 69 })).toBe(`${e}\u{1F601}`); + }); + it("uses the selection's start column for a first line that began mid-row", () => { // Selection started at column 18, so the first line is only 51 // characters but ended at column 69 of its row. diff --git a/app/src/components/terminal/copyCleanup.ts b/app/src/components/terminal/copyCleanup.ts index 15c3c0b..7948d45 100644 --- a/app/src/components/terminal/copyCleanup.ts +++ b/app/src/components/terminal/copyCleanup.ts @@ -172,7 +172,10 @@ function toRow(text: string, offset: number): Row { const lastToken = (s: string) => s.slice(s.lastIndexOf(" ") + 1); const firstToken = (s: string) => s.split(" ", 1)[0]; -const isWide = (ch: string | undefined) => !!ch && charWidth(ch) === 2; +/** First / last *code point* — an astral character (CJK Ext-B, most emoji) is two UTF-16 units. */ +const firstChar = (s: string) => (s ? String.fromCodePoint(s.codePointAt(0)!) : ""); +const lastChar = (s: string) => Array.from(s.slice(-2)).pop() ?? ""; +const isWide = (ch: string) => !!ch && charWidth(ch) === 2; /** * Re-joins rows that Claude Code broke to fit the terminal width. @@ -235,8 +238,7 @@ function joinerFor(prev: Row, cur: Row, head: Row, headPartial: boolean, cols: n const next = firstToken(cur.body); if (prev.end + 1 + cellWidth(next) <= width) return null; - const prevLast = prev.body[prev.body.length - 1]; - if (isWide(prevLast) && isWide(cur.body[0])) return ""; + if (isWide(lastChar(prev.body)) && isWide(firstChar(cur.body))) return ""; if (prev.end === width && cellWidth(lastToken(prev.body)) + cellWidth(next) > width - cur.lead) { return ""; }