import { useEffect, useRef } from "react"; import { Terminal } from "@xterm/xterm"; import { WebglAddon } from "@xterm/addon-webgl"; import { SearchAddon } from "@xterm/addon-search"; import { FitAddon } from "@xterm/addon-fit"; import { attachSurface, detachSurface, sendInput, resizeSurface } from "./socketBridge"; import { registerSearch, unregisterSearch } from "./searchRegistry"; const decoder = new TextDecoder(); const encoder = new TextEncoder(); function xtermTheme(p: Record) { return { background: p["bg-panel"], foreground: p["text-primary"], cursor: p["text-primary"], selectionBackground: p["search-match"], }; } export function TerminalView({ surfaceId, font, palette }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record | null }) { const ref = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const webglRef = useRef(null); useEffect(() => { if (!ref.current) return; // allowProposedApi is required by the search addon: its match decorations // call registerMarker/registerDecoration (proposed API). Without it findNext // throws and the scrollback search counter never updates. const term = new Terminal({ fontFamily: font ? `'${font.family}', monospace` : "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontSize: font?.size ?? 13, convertEol: false, scrollback: 10000, allowProposedApi: true, theme: palette ? xtermTheme(palette) : undefined, }); termRef.current = term; try { const webgl = new WebglAddon(); term.loadAddon(webgl); webglRef.current = webgl; } catch { // webgl unavailable → fall back to canvas/dom renderer silently } term.open(ref.current); const search = new SearchAddon(); term.loadAddon(search); registerSearch(surfaceId, search); const fit = new FitAddon(); term.loadAddon(fit); fitRef.current = fit; // Fit the grid to the container and tell the daemon the new size. Coalesced // through rAF so a burst of resize callbacks yields one resize per frame. let rafId = 0; let lastCols = 0, lastRows = 0; const doFit = () => { rafId = 0; try { fit.fit(); } catch { return; } if (term.cols !== lastCols || term.rows !== lastRows) { lastCols = term.cols; lastRows = term.rows; void resizeSurface(surfaceId, term.cols, term.rows); } }; const scheduleFit = () => { if (!rafId) rafId = requestAnimationFrame(doFit); }; const ro = new ResizeObserver(scheduleFit); ro.observe(ref.current); // Input → daemon. const inputDisposable = term.onData((data) => { void sendInput(surfaceId, encoder.encode(data)); }); let disposed = false; // Attach: fresh xterm instance, write snapshot, then stream live output. void attachSurface(surfaceId, (bytes) => { if (!disposed) term.write(decoder.decode(bytes)); }).then((res) => { if (disposed) return; if (res.snapshot) term.write(res.snapshot); // Fit to the actual container rather than the daemon's stored geometry, // then push the resulting size back so the PTY reflows to match. scheduleFit(); }); return () => { disposed = true; if (rafId) cancelAnimationFrame(rafId); ro.disconnect(); inputDisposable.dispose(); void detachSurface(surfaceId); unregisterSearch(surfaceId); term.dispose(); termRef.current = null; fitRef.current = null; webglRef.current = null; }; }, [surfaceId]); // eslint-disable-line react-hooks/exhaustive-deps // Live re-apply font and theme when config changes without remounting. // font and palette are memoized in App.tsx so stable identity = no spurious re-applies. useEffect(() => { const t = termRef.current; if (!t) return; if (font) { t.options.fontFamily = `'${font.family}', monospace`; t.options.fontSize = font.size; // The WebGL renderer caches rasterized glyphs in a texture atlas keyed by // the old font/size; without clearing it the grid keeps rendering stale // glyphs after a font change. webglRef.current?.clearTextureAtlas(); } if (palette) t.options.theme = xtermTheme(palette); requestAnimationFrame(() => { try { fitRef.current?.fit(); } catch { /* ignore */ } }); }, [font, palette]); // eslint-disable-line react-hooks/exhaustive-deps return
; }