/** * @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. * Always painted dark, in the palette of the user's chosen DARK theme * (`lib/terminalTheme.ts`), recoloured live without re-attaching. * An image dropped on it is saved on the server (`POST /api/run/drop-image`) * and its PATH pasted into the pane — what a local terminal does on a drop, * and how `claude` attaches an image. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useMemo, useRef, useState, type DragEvent } from "react"; import { useTranslation } from "react-i18next"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { CanvasAddon } from "@xterm/addon-canvas"; import "@xterm/xterm/css/xterm.css"; import { useTheme } from "../../hooks/useTheme"; import { terminalThemeFor } from "../../lib/terminalTheme"; import { api } from "../../lib/api"; type DropState = "hint" | "uploading" | "failed" | "imagesOnly"; const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files"); interface TerminalViewProps { runId: string; wsBaseUrl: string; /** The pane's process ended (a double Ctrl+C, `exit`, a `claude` that quit). * There is nothing left to attach to, so the owner swaps this terminal back * out for its run setup form instead of leaving a dead pane on screen. */ onExit?: () => void; } export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) { const containerRef = useRef(null); // Read through a ref: an inline `onExit` from the parent changes identity on // every render, and as an effect dep it would tear the terminal down and // re-attach on each one. const onExitRef = useRef(onExit); onExitRef.current = onExit; // Always the DARK theme's palette, even in light mode: Claude Code's TUI // assumes a dark background. Read through a ref at construction so a theme // change never re-runs the attach effect; the effect below recolours live. const darkTheme = useTheme().themeFor.dark; const palette = useMemo(() => terminalThemeFor(darkTheme), [darkTheme]); const paletteRef = useRef(palette); paletteRef.current = palette; const termRef = useRef(null); const { t } = useTranslation("run"); const [drop, setDrop] = useState(null); // dragenter/dragleave fire for every child crossed, so count them: the hint // only goes away when the pointer leaves the terminal itself. const dragDepth = useRef(0); const flashTimer = useRef | null>(null); const flash = (state: DropState) => { setDrop(state); if (flashTimer.current) clearTimeout(flashTimer.current); flashTimer.current = setTimeout(() => setDrop(null), 2500); }; useEffect( () => () => { if (flashTimer.current) clearTimeout(flashTimer.current); }, [] ); const onDrop = async (e: DragEvent) => { if (!hasFiles(e)) return; // dragged text keeps xterm's own handling e.preventDefault(); dragDepth.current = 0; const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/")); if (images.length === 0) return flash("imagesOnly"); setDrop("uploading"); try { const saved = await Promise.all(images.map((f) => api.run.dropImage(f))); // paste(), not a raw send: it honours bracketed-paste mode, so claude // sees one pasted path rather than typed keystrokes. termRef.current?.paste(saved.map((r) => r.path).join(" ") + " "); termRef.current?.focus(); setDrop(null); } catch { flash("failed"); } }; 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. // NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the // cursor to column 0 on a line feed — and xterm's own docs say not to use // it on a PTY, where termios already does that translation for the streams // that want it. tmux scrolls a pane by emitting a bare LF and then keeps // drawing at the column it was on; with this on, that draw landed at // column 0 instead, stamping the first characters of the line into the // left margin of the rows below. That is the torn left edge of issue #33, // and why it only showed up once content scrolled. const term = new Terminal({ fontSize: 13, cursorBlink: true, fontFamily: '"JetBrains Mono", monospace', theme: paletteRef.current, }); termRef.current = term; 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(); // Take the keyboard as soon as the pane is on screen: the terminal IS the // page here, and having to click it first meant the first thing typed went // nowhere. A click elsewhere still moves focus away, as in any editor. term.focus(); // 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 = () => { // The attach itself carries this terminal's size. tmux sizes the window // to the client that attached most recently, so spawning the attach at a // default 80×24 and correcting it from the first `resize` message a beat // later makes the pane's program (claude) redraw its whole UI at 80 // columns first — and everything painted at that width stays in the // scrollback as torn fragments along the left margin, visible as soon as // you scroll back (issue #33). Happens on every attach: page load, // lane switch, and each 1.5 s reconnect. const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : ""; const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`); 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`); onExitRef.current?.(); } 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(); termRef.current = null; }; }, [runId, wsBaseUrl]); useEffect(() => { if (termRef.current) termRef.current.options.theme = palette; }, [palette]); return (
{ if (!hasFiles(e)) return; e.preventDefault(); dragDepth.current += 1; setDrop("hint"); }} onDragOver={(e) => { if (!hasFiles(e)) return; e.preventDefault(); // without it the browser refuses the drop e.dataTransfer.dropEffect = "copy"; }} onDragLeave={(e) => { if (!hasFiles(e)) return; dragDepth.current = Math.max(0, dragDepth.current - 1); if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d)); }} onDrop={(e) => void onDrop(e)} >
{drop && ( // pointer-events-none: the drop must land on the terminal, not on this.
{t(`drop.${drop}`)}
)}
); }