diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8829598c0..245c09a0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -284,6 +284,9 @@ importers: '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1(react@19.2.4) + '@xterm/addon-canvas': + specifier: ^0.7.0 + version: 0.7.0(@xterm/xterm@5.5.0) '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) @@ -1769,6 +1772,11 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + '@xterm/addon-canvas@0.7.0': + resolution: {integrity: sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + '@xterm/addon-fit@0.10.0': resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} peerDependencies: @@ -6018,6 +6026,10 @@ snapshots: '@xmldom/xmldom@0.9.10': {} + '@xterm/addon-canvas@0.7.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': dependencies: '@xterm/xterm': 5.5.0 diff --git a/ui/package.json b/ui/package.json index 582ac5a99..132d225c1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@vercel/analytics": "^2.0.1", + "@xterm/addon-canvas": "^0.7.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", diff --git a/ui/src/components/workspace/Terminal.tsx b/ui/src/components/workspace/Terminal.tsx index 6204e32d1..41434232b 100644 --- a/ui/src/components/workspace/Terminal.tsx +++ b/ui/src/components/workspace/Terminal.tsx @@ -1,9 +1,9 @@ import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import type { ReactElement } from 'react'; +import { CanvasAddon } from '@xterm/addon-canvas'; import { FitAddon } from '@xterm/addon-fit'; import { WebLinksAddon } from '@xterm/addon-web-links'; -import { WebglAddon } from '@xterm/addon-webgl'; import { Terminal as Xterm } from '@xterm/xterm'; import '@xterm/xterm/css/xterm.css'; @@ -112,7 +112,7 @@ export function TerminalView(props: TerminalViewProps): ReactElement { const term = new Xterm({ theme: darkTheme, fontFamily: - 'ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", "DejaVu Sans Mono", monospace', + 'ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", "DejaVu Sans Mono", "Noto Sans Mono CJK JP", "Noto Sans CJK JP", monospace', fontSize: 13, lineHeight: 1.2, cursorBlink: true, @@ -127,13 +127,20 @@ export function TerminalView(props: TerminalViewProps): ReactElement { term.loadAddon(new WebLinksAddon()); term.open(container); - let webgl: WebglAddon | null = null; + // Canvas renderer (not WebGL). The WebGL glyph atlas renders any styled + // CJK cell it can't rasterize — notably italic Japanese, since Noto Sans + // Mono CJK JP has no italic face — as a solid black box. Canvas rasterizes + // each cell via 2D fillText, which falls back through the browser font + // stack and synthesizes obliques, so bold/italic Japanese renders + // correctly. It's far faster than the DOM renderer (which made input echo + // sluggish in heavy TUIs like the agent CLIs) while avoiding WebGL's + // missing-glyph boxes. + let canvas: CanvasAddon | null = null; try { - webgl = new WebglAddon(); - webgl.onContextLoss(() => webgl?.dispose()); - term.loadAddon(webgl); + canvas = new CanvasAddon(); + term.loadAddon(canvas); } catch { - webgl = null; + canvas = null; } safeFit(fit); @@ -257,7 +264,7 @@ export function TerminalView(props: TerminalViewProps): ReactElement { } catch { // ignore } - webgl?.dispose(); + canvas?.dispose(); term.dispose(); }; }, [wsId, sessionId, wsUrl]);