diff --git a/client/package-lock.json b/client/package-lock.json index b0470cc2..f440eb29 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fontsource/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", + "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "d3": "^7.9.0", @@ -2073,6 +2074,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xterm/addon-canvas": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz", + "integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, "node_modules/@xterm/addon-fit": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", diff --git a/client/package.json b/client/package.json index 547cd448..216779bf 100644 --- a/client/package.json +++ b/client/package.json @@ -13,6 +13,7 @@ "dependencies": { "@fontsource/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", + "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "d3": "^7.9.0", diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index 4b956acb..b485c6f1 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -14,6 +14,7 @@ import { useEffect, useRef } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; +import { CanvasAddon } from "@xterm/addon-canvas"; import "@xterm/xterm/css/xterm.css"; interface TerminalViewProps { @@ -29,6 +30,24 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) { const fit = new FitAddon(); term.loadAddon(fit); if (containerRef.current) term.open(containerRef.current); + + // Draw each cell at a fixed position instead of letting the browser lay the + // row out as text. xterm's default DOM renderer aligns a row with a single + // `letter-spacing`, which only holds while every glyph comes from one + // monospace font — Vietnamese diacritics and CJK fall through to a fallback + // face whose advance width differs, so glyphs shove their neighbours along + // and the row's tail spills onto the row below. The buffer is correct + // throughout (copying the line yields clean text); only the painting drifts, + // and no CSS can correct it. Best-effort: a browser that cannot give a 2D + // context keeps the DOM renderer rather than losing the terminal. + let canvas: CanvasAddon | null = null; + try { + canvas = new CanvasAddon(); + term.loadAddon(canvas); + } catch { + canvas = null; + } + fit.fit(); // When the pane's program has NOT enabled wheel reporting, xterm falls back @@ -160,6 +179,7 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) { resizeDisposable.dispose(); dataDisposable.dispose(); ws?.close(); + canvas?.dispose(); term.dispose(); }; }, [runId, wsBaseUrl]); diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index 94c46e17..0a661354 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -16,6 +16,7 @@ const openMock = vi.fn(); const disposeMock = vi.fn(); const wheelHandlerMock = vi.fn(); const keyHandlerMock = vi.fn(); +const loadAddonMock = vi.fn(); // Mutable stand-ins for the two live terminal facts the wheel handler reads. const termState = { modes: { mouseTrackingMode: "none" as "none" | "x10" | "vt200" | "drag" | "any" }, @@ -50,7 +51,7 @@ vi.mock("@xterm/xterm", () => ({ return { dispose: vi.fn() }; }, dispose: disposeMock, - loadAddon: vi.fn(), + loadAddon: loadAddonMock, attachCustomWheelEventHandler: wheelHandlerMock, attachCustomKeyEventHandler: keyHandlerMock, })), @@ -58,6 +59,9 @@ vi.mock("@xterm/xterm", () => ({ vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })), })); +vi.mock("@xterm/addon-canvas", () => ({ + CanvasAddon: vi.fn().mockImplementation(() => ({ __canvas: true, dispose: vi.fn() })), +})); class MockWebSocket { static instances: MockWebSocket[] = []; @@ -94,6 +98,7 @@ describe("TerminalView", () => { writeMock.mockClear(); wheelHandlerMock.mockClear(); keyHandlerMock.mockClear(); + loadAddonMock.mockClear(); openMock.mockClear(); }); @@ -179,6 +184,18 @@ describe("TerminalView", () => { expect(preventPlainTab).not.toHaveBeenCalled(); }); + it("renders through the canvas addon, not xterm's DOM renderer", () => { + // The DOM renderer aligns a row with one `letter-spacing`, which holds only + // while every glyph shares a font. Vietnamese diacritics and CJK fall to a + // fallback face with a different advance, so glyphs shove their neighbours + // and the row's tail spills onto the row below — buffer clean, painting + // wrong. The canvas renderer draws each cell at a fixed position instead. + render(); + expect(loadAddonMock.mock.calls.some(([a]) => (a as { __canvas?: boolean }).__canvas)).toBe( + true + ); + }); + describe("size reporting (tmux must never be wider than the terminal)", () => { // A tmux pane wider than xterm wraps each over-long row's tail onto the // next row's left margin, and tmux never repaints a row it believes