5c297c6483
xterm left fontFamily unset, so it used the browser/OS default (courier-new/monospace). That face is missing Vietnamese precomposed diacritics on many systems, so the browser silently substitutes those glyphs from another face with a different advance width — corrupting the cell even under the canvas renderer, which only stops a glyph from shoving its neighbours, not from overflowing its own cell. Pin the terminal to the app's own bundled JetBrains Mono and import its vietnamese subset (previously only latin was loaded).
203 lines
9.1 KiB
TypeScript
203 lines
9.1 KiB
TypeScript
/**
|
||
* @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ĩ <vinnt@smartgift.vn>
|
||
*/
|
||
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<HTMLDivElement>(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<typeof setTimeout> | 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 <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
|
||
}
|