/** * @file TerminalView.tsx * @description Renders one lane's live terminal — a real `xterm.js` instance * attached via WebSocket to the server's `/ws-pty/:runId` path (see * server/lib/pty-attach.js), which is itself a `node-pty`-backed * `tmux attach-session`. Binary WS frames are raw PTY bytes in both * directions; a JSON text frame carries the initial `resize` on mount and * the server's one-shot `exit` notice when the pane process ends. * The socket auto-reconnects: a server restart kills the attach PTY, and * without a retry the pane silently freezes on its last painted frame — it * still LOOKS live, but no keystroke or mouse report reaches tmux again. * @author Nguyễn Ngọc Trí Vĩ */ 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 { runId: string; wsBaseUrl: string; } export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) { const containerRef = useRef(null); useEffect(() => { // An explicit fontFamily pins every glyph to one bundled webfont. Left // unset, xterm falls back to the browser/OS default (`courier-new`), // which on Windows lacks Vietnamese precomposed diacritics — the OS then // substitutes those specific glyphs from a different face with a // mismatched advance width, corrupting the cell it's drawn into even // under the canvas renderer (which only stops a glyph from shoving its // *neighbours*, not from overflowing its own cell). JetBrains Mono is // already bundled for the rest of the UI; main.tsx additionally imports // its `vietnamese` subset so this covers the diacritics too. const term = new Terminal({ convertEol: true, fontSize: 13, cursorBlink: true, fontFamily: '"JetBrains Mono", monospace', }); 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 // to converting each wheel notch on an alt-screen buffer into a cursor-key // press (ESC[A / ESC[B). In a `claude` pane that reads as arrow up/down — // the wheel silently walks the prompt history instead of scrolling. Kill // ONLY that branch: xterm runs this same handler before sending a real SGR // mouse report too (`bindMouse`'s wheel case consults it), so a blanket // `false` also blocks tmux's own wheel scrolling. `x10` counts as off here // because that protocol reports button presses only, never the wheel — so // xterm takes the emulation path for it as well. term.attachCustomWheelEventHandler(() => { const mode = term.modes.mouseTrackingMode; const tracked = mode !== "none" && mode !== "x10"; return tracked || term.buffer.active.type !== "alternate"; }); // xterm sends Shift+Tab as ESC[Z but — unlike plain Tab — never marks the // event cancelled, so the browser still runs its default action and moves // focus out of the terminal. The pane then only ever sees the first press, // which is why `shift+tab to cycle` (permission mode in claude) looked dead // here while working in a real terminal. Returning true keeps xterm's own // handling; the preventDefault only stops focus from escaping. term.attachCustomKeyEventHandler((ev) => { if (ev.type === "keydown" && ev.key === "Tab" && ev.shiftKey) ev.preventDefault(); return true; }); const decoder = new TextDecoder(); let ws: WebSocket | null = null; /** * Tell the server this terminal's size. tmux draws the pane at whatever * width it last heard, and a pane WIDER than xterm is silently corrupting: * every over-long row autowraps its tail onto the next row's left margin, * and tmux — believing that row unchanged — never repaints over it. The * result is stray characters glued to the left edge of blank lines, which * only becomes visible once a scroll repaints history. Reproduced with a * one-column difference; two columns leaves two characters per line. * * A zero-size fit is never reported: the console collapses with CSS * (`display:none` leaves the container 0×0), and fitting to that would * resize the real tmux pane down to nothing. */ const sendSize = () => { if (!ws || ws.readyState !== WebSocket.OPEN) return; if (term.cols < 2 || term.rows < 2) return; ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); }; let retryTimer: ReturnType | null = null; let ended = false; // server said the pane exited — nothing left to attach to let disposed = false; const connect = () => { const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`); ws = sock; // Server sends PTY bytes as binary frames — default binaryType ("blob") // would hand onmessage a Blob that the string checks below never match, // silently dropping all terminal output. "arraybuffer" keeps it sync. sock.binaryType = "arraybuffer"; sock.onopen = () => { sendSize(); }; sock.onmessage = (event) => { const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]"; // `stream: true` carries a UTF-8 multi-byte char's trailing bytes over // to the next decode() call when a PTY chunk splits mid-character — // without it, a Vietnamese/accented char split across two WS frames // decodes to garbage on both sides (U+FFFD or stray bytes). const data = isArrayBuffer ? decoder.decode(event.data as ArrayBuffer, { stream: true }) : event.data; if (typeof data === "string") { // A JSON control frame is the only thing that starts with `{"type"`. if (data.startsWith('{"type"')) { try { const msg = JSON.parse(data); if (msg.type === "exit") { ended = true; term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`); } return; } catch { /* not JSON — fall through and render as PTY output */ } } term.write(data); } }; sock.onclose = () => { if (disposed || ended) return; // tmux keeps the session alive across a server restart, so re-attaching // repaints the pane in full — no output is lost by retrying. retryTimer = setTimeout(connect, 1500); }; }; connect(); const dataDisposable = term.onData((data) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); }); // xterm's own size change is the event worth reporting, not the // container's: cols also move when the webfont finishes loading or the // page zooms, neither of which resizes the element ResizeObserver watches. const resizeDisposable = term.onResize(sendSize); // Re-assert on focus. Several attach clients can share one tmux session // (a second tab, another lane pane), and tmux's `window-size latest` sizes // the window to whichever client was last active — resizing this pane out // from under a terminal that is never told. Coming back to it says so. const reassert = () => sendSize(); term.textarea?.addEventListener("focus", reassert); const resizeObserver = new ResizeObserver(() => { // Never fit a collapsed console — see sendSize. const el = containerRef.current; if (!el || !el.clientWidth || !el.clientHeight) return; fit.fit(); // a real change fires onResize, which reports it }); if (containerRef.current) resizeObserver.observe(containerRef.current); return () => { disposed = true; if (retryTimer) clearTimeout(retryTimer); resizeObserver.disconnect(); term.textarea?.removeEventListener("focus", reassert); resizeDisposable.dispose(); dataDisposable.dispose(); ws?.close(); canvas?.dispose(); term.dispose(); }; }, [runId, wsBaseUrl]); return
; }