diff --git a/packages/protocol/src/terminal-snapshot.test.ts b/packages/protocol/src/terminal-snapshot.test.ts index 0262f0e45f..96d82a9205 100644 --- a/packages/protocol/src/terminal-snapshot.test.ts +++ b/packages/protocol/src/terminal-snapshot.test.ts @@ -47,3 +47,33 @@ describe("renderTerminalSnapshotToAnsi", () => { expect(ansi).toContain("ABCDEFGHIJ\r\nKLMNOP"); }); }); + +describe("renderTerminalSnapshotToAnsi wide chars", () => { + it("skips wide-char continuation cells so the glyph keeps its two columns", () => { + // The daemon captures "통" as the glyph followed by an empty continuation + // cell. Emitting anything for that cell pushes the rest of the row right. + const state: TerminalState = { + rows: 1, + cols: 10, + scrollback: [], + grid: [[{ char: "통" }, { char: "" }, { char: "합" }, { char: "" }, { char: "!" }]], + cursor: { row: 0, col: 5 }, + }; + + expect(renderTerminalSnapshotToAnsi(state)).toContain("통합!"); + }); + + it("replays snapshots from an old daemon verbatim, including its continuation spaces", () => { + // Old daemons already collapsed the continuation to " ". The serializer + // cannot tell that apart from a real space, so it must not guess. + const state: TerminalState = { + rows: 1, + cols: 10, + scrollback: [], + grid: [cells("통 합 !")], + cursor: { row: 0, col: 6 }, + }; + + expect(renderTerminalSnapshotToAnsi(state)).toContain("통 합 !"); + }); +}); diff --git a/packages/protocol/src/terminal-snapshot.ts b/packages/protocol/src/terminal-snapshot.ts index f978cd884b..1860067573 100644 --- a/packages/protocol/src/terminal-snapshot.ts +++ b/packages/protocol/src/terminal-snapshot.ts @@ -87,6 +87,12 @@ function renderTerminalRow(row: TerminalCell[], padToCols?: number): string { for (let index = 0; index < length; index += 1) { const cell = row[index] ?? { char: " " }; + // An empty char marks a wide-char continuation cell. The glyph in the + // previous cell already advances the cursor two columns, so emitting + // anything here would shift the rest of the row one column right. + if (cell.char === "") { + continue; + } const nextStyle = getTerminalStyle(cell); if (!terminalStylesEqual(previousStyle, nextStyle)) { output.push(styleToAnsi(nextStyle)); diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 9881f4bdfd..f83b6b9ba3 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -24,6 +24,8 @@ import { } from "node:fs"; import { spawnSync } from "node:child_process"; import * as pty from "node-pty"; +import xterm from "@xterm/headless"; +import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises"; @@ -50,6 +52,10 @@ function getLines(state: ReturnType): string[] { return state.grid.map(rowToText); } +function rowHasChar(row: TerminalRow, char: string): boolean { + return row.some((cell) => cell.char === char); +} + // Wait for terminal state to match expected lines async function waitForLines( session: TerminalSession, @@ -523,6 +529,44 @@ describe("createTerminal", () => { expect(withoutFlags.scrollbackWrapped).toBeUndefined(); }); + it("restores CJK text without injected spaces when a snapshot round-trips through the client", async () => { + // Each Hangul syllable is a wide char: xterm stores it as a 2-cell pair + // whose second cell has no chars. Capturing that continuation as " " made + // every restored syllable three columns wide (paseo#3434). + const text = "통합 구현은 전체 테스트를 통과했습니다"; + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + cols: 80, + rows: 10, + command: process.execPath, + args: [ + "-e", + `process.stdout.write(${JSON.stringify(text)}); setInterval(() => {}, 100000);`, + ], + }), + ); + + const state = await waitForState(session, (current) => rowHasChar(current.grid[0], "다")); + + // Replay the snapshot the way the client does on tab switch and read the + // row back the way a selection copy would. + const restored = new xterm.Terminal({ cols: 80, rows: 10, allowProposedApi: true }); + try { + await new Promise((resolve) => + restored.write(renderTerminalSnapshotToAnsi(state), resolve), + ); + expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe(text); + } finally { + restored.dispose(); + } + + // The continuation cell must stay empty so the restore path can skip it. + expect(state.grid[0][0].char).toBe("통"); + expect(state.grid[0][1].char).toBe(""); + }); + it("captures exit diagnostics from the terminal buffer", async () => { const session = trackSession( await createTerminal({ diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index b9d1b74017..1e708062b8 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -580,7 +580,9 @@ function extractCell(terminal: TerminalType, row: number, col: number): Terminal const bg = bgMode !== 0 ? cell.getBgColor() : undefined; return { - char: cell.getChars() || " ", + // Wide chars (CJK) span two cells; xterm returns "" for the zero-width + // continuation. Collapsing it to " " injects a real space on restore. + char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", fg, bg, fgMode: fgMode !== 0 ? fgMode : undefined, @@ -639,7 +641,9 @@ function extractScrollback( const fg = fgMode !== 0 ? cell.getFgColor() : undefined; const bg = bgMode !== 0 ? cell.getBgColor() : undefined; rowCells.push({ - char: cell.getChars() || " ", + // Wide chars (CJK) span two cells; xterm returns "" for the zero-width + // continuation. Collapsing it to " " injects a real space on restore. + char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", fg, bg, fgMode: fgMode !== 0 ? fgMode : undefined,