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(); // xterm.js auto-answers device queries (Device Attributes, cursor/status reports, // OSC color queries, DECRPM mode reports) by emitting the reply through onData. // But the daemon's alacritty grid is the authoritative emulator and already // answers these on the PTY (see spacesh-core grid.rs `take_replies` → // `write_input`). Forwarding xterm's duplicate — which arrives a full IPC // roundtrip late — lands in the shell's input buffer after it stopped reading // the reply, so the shell echoes it as literal escape gibberish and the prompt // shifts. Drop these standalone reports; never the user's keystrokes/paste/mouse // (mouse reports end in M/m and are real user input the program asked for). function isDeviceReport(data: string): boolean { if (data.charCodeAt(0) !== 0x1b) return false; return ( /^\x1b\[[?>=]?[0-9;]*[cntR]$/.test(data) || // DA1/DA2 (c), DSR status (n), text-area/cell-size (t), cursor position (R) /^\x1b\[\?[0-9;]*u$/.test(data) || // kitty keyboard QUERY reply (\x1b[?flags u) — NOT key input \x1b[u /^\x1b\[\?[0-9;]*\$[py]$/.test(data) || // DECRPM mode report /^\x1b\][0-9]+;[^\x07\x1b]*(?:\x07|\x1b\\)$/.test(data) || // OSC color / query reply (BEL- or ST-terminated) /^\x1bP[\s\S]*\x1b\\$/.test(data) || // any DCS report (XTVERSION / DECRQSS / status string) /^\x1b_[\s\S]*\x1b\\$/.test(data) // any APC report (kitty graphics) ); } // Appended after the user font so Nerd Font icon glyphs (Private Use Area) render // via fallback instead of blank boxes, without changing the base monospace font. const NERD_FALLBACK = "'Symbols Nerd Font Mono'"; const fontStack = (family: string | null) => family ? `'${family}', ${NERD_FALLBACK}, monospace` : `'JetBrains Mono Variable', 'JetBrains Mono', ${NERD_FALLBACK}, monospace`; function xtermTheme(p: Record) { return { background: p["term-bg"] ?? p["bg-panel"], foreground: p["text-primary"], cursor: p["text-primary"], selectionBackground: p["search-match"], }; } export function TerminalView({ surfaceId, font, palette, focused }: { surfaceId: string; font: { family: string; size: number } | null; palette: Record | null; focused?: boolean }) { const ref = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const webglRef = useRef(null); // A background theme makes term-bg fully transparent so the panel's glass fill // shows through. allowTransparency is construction-time only, so it's part of the // effect key to force a remount when it flips. WebGL stays on in both modes — the // glass/blur lives on a sibling layer (see LayoutEngine), not an ancestor, so the // WebGL canvas composites its transparent background without the WKWebView // clipping/smearing artifacts that backdrop-filter ancestors cause. const transparent = palette?.["term-bg"] === "rgba(0,0,0,0)"; 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: fontStack(font?.family ?? null), fontSize: font?.size ?? 13, convertEol: false, scrollback: 10000, allowProposedApi: true, allowTransparency: transparent, 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) => { if (isDeviceReport(data)) return; // daemon answers the PTY authoritatively; xterm's dup arrives late and echoes void sendInput(surfaceId, encoder.encode(data)); }); let disposed = false; // The Nerd Font fallback may finish loading after the first paint; once it's // ready, drop the WebGL glyph atlas so cached blank cells re-rasterize with icons. void document.fonts.load("16px 'Symbols Nerd Font Mono'").then(() => { if (!disposed) webglRef.current?.clearTextureAtlas(); }).catch(() => {}); // 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, transparent]); // eslint-disable-line react-hooks/exhaustive-deps // Keyboard focus cycling (cmd+]/[) only changes the focusedId state — it never // touches the DOM, so the new panel's xterm textarea stays unfocused and keys // keep flowing to the old terminal. Mouse clicks don't hit this because the // click lands on the textarea directly. Drive xterm focus from the prop. useEffect(() => { if (focused) termRef.current?.focus(); }, [focused]); // 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 = fontStack(font.family); 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
; }