import { useEffect, useRef } from 'react'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { SearchAddon } from '@xterm/addon-search'; import { WebLinksAddon } from '@xterm/addon-web-links'; import '@xterm/xterm/css/xterm.css'; import { RefreshCw } from 'lucide-react'; import { useTerminalFit } from '@/hooks/terminal/useTerminalFit'; import { useTerminalSocket } from '@/hooks/terminal/useTerminalSocket'; import { useTerminalSelection } from '@/hooks/terminal/useTerminalSelection'; import { TerminalHotkeyBar } from '@/components/panes/terminal/TerminalHotkeyBar'; import { FloatingMenu } from '@/components/panes/terminal/FloatingMenu'; import { SearchBar } from '@/components/panes/terminal/SearchBar'; import { TERM_BG, XTERM_THEME } from '@/components/panes/terminal/theme'; // Architecture invariants (do not regress without reading the comments // next to each): // // - Resize is in-band on the WebSocket as a JSON text frame. The HTTP // /resize endpoint had a race with PTY-map registration; WS frames // don't. (lib/terminal-protocol.ts owns the wire encoding.) // - Server `init` text frame triggers term.clear() so a stale buffer // can't leak through the capture-pane replay. // - DOM renderer only (no @xterm/addon-webgl). The WebGL atlas bakes // glyphs via Canvas2D at load time and locks against whatever font // is registered then; @fontsource JBM Variable subsets don't cover // U+2500-25FF, so the atlas baked against the system fallback and // opencode/claude banners rendered garbled. DOM renderer uses real // text spans, so the browser's text rasterizer handles unicode // fallback per-character — boolab pattern, just works. // - Refit on document.fonts.ready (post-open) so xterm's cell metrics // don't bake against the system fallback. Boolab pattern: rAF inside // fonts.ready, plus a 150ms safety-net setTimeout. (useTerminalFit.) // // Decomposition (v2 Phase 9): this orchestrator constructs/disposes the // Terminal (spine effect) and composes three hooks — // - useTerminalFit: measurement + the pure-fit refit triggers. // - useTerminalSocket: WS lifecycle, term I/O handlers, bootstrap, // visibility reconnect, sticky Ctrl. // - useTerminalSelection: touch long-press, context menu, clipboard, // custom keys, registries, menu actions. // The spine effect is registered FIRST so termRef is populated before the // hooks' effects run on mount. (On unmount that means term.dispose() runs // before the hooks' cleanups — verified safe: no hook cleanup touches the // terminal, and the WS message handler null-guards on termRef.) interface Props { sessionId: string; paneId: string; label: string; active?: boolean; } export function TerminalPane({ sessionId, paneId, label, active = false }: Props) { const containerRef = useRef(null); const termRef = useRef(null); const searchRef = useRef(null); // Spine: construct + dispose the Terminal. Registered before the hooks so // termRef/searchRef are set when their effects run on mount. NOTE: `label` // is intentionally NOT a dependency — it is positional ("Terminal N") and // renumbers on pane add/remove; keeping it out of these deps stops the live // terminal from tearing down + reconnecting on a cosmetic renumber. The // terminal registry's label is refreshed by useTerminalSelection's // [paneId, label] effect instead. useEffect(() => { const container = containerRef.current; if (!container) return; // On mobile viewports drop to 11px so opencode/claude get enough cols. const fontSize = typeof window !== 'undefined' && window.innerWidth < 768 ? 11 : 13; const term = new Terminal({ convertEol: true, cursorBlink: true, allowProposedApi: true, fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', 'Fira Code', Menlo, monospace", fontSize, // Locked cell metrics so DOM-renderer rows line up: integer-multiple // line height and zero letter spacing keep glyph spans cell-aligned. // globals.css enforces the same with !important so an inherited rule // can't fight us. lineHeight: 1.0, letterSpacing: 0, scrollback: 10_000, fastScrollModifier: 'shift', altClickMovesCursor: false, theme: XTERM_THEME, }); termRef.current = term; const fitAddon = new FitAddon(); const search = new SearchAddon(); searchRef.current = search; term.loadAddon(fitAddon); term.loadAddon(search); term.loadAddon(new WebLinksAddon()); term.open(container); return () => { try { term.dispose(); } catch { /* ignore */ } termRef.current = null; searchRef.current = null; }; }, [sessionId, paneId]); const fit = useTerminalFit({ termRef, containerRef, sessionId, paneId }); const socket = useTerminalSocket({ termRef, sessionId, paneId, fit: fit.fit, getSize: fit.getSize, setSize: fit.setSize, }); const selection = useTerminalSelection({ termRef, containerRef, sessionId, paneId, label, send: socket.send, }); // Refit when this pane becomes the visible/focused one (e.g. mobile pane // switch, maximize toggle). Routes through useTerminalFit().fit so the // visualViewport keyboard clip is applied consistently (v2 Phase 9 dedup — // previously this effect re-implemented the cell-metric→resize math WITHOUT // the keyboard clip). term.onResize sends the resize WS frame if cols or // rows actually changed. useEffect(() => { if (!active) return; const raf = requestAnimationFrame(() => fit.fit()); return () => cancelAnimationFrame(raf); }, [active, fit.fit]); return (
{/* xterm CSS overrides live in src/styles/globals.css (boolab pattern). Putting them in an inline