import { useCallback, useEffect, useRef, useState } 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 { ChevronDown, ChevronUp, Maximize2, RefreshCw, X } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api/client'; import { chatInputsRegistry, sendToChat, sendToTerminal, terminalsRegistry, type ChatInputRegistration, } from '@/lib/events'; import { cn } from '@/lib/utils'; // 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. // - 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. interface Props { sessionId: string; paneId: string; label: string; active?: boolean; } // Terminal background matches the pane container's `bg-[#0b0f14]` so any // sub-cell rounding remainder is invisible. Update both if the theme changes. const TERM_BG = '#0b0f14'; const XTERM_THEME = { background: TERM_BG, foreground: '#d6deeb', cursor: '#82aaff', selectionBackground: '#1d3b53', black: '#011627', red: '#ef5350', green: '#22da6e', yellow: '#c5e478', blue: '#82aaff', magenta: '#c792ea', cyan: '#7fdbca', white: '#d6deeb', brightBlack: '#575656', brightRed: '#ef5350', brightGreen: '#22da6e', brightYellow: '#ffeb95', brightBlue: '#82aaff', brightMagenta: '#c792ea', brightCyan: '#7fdbca', brightWhite: '#ffffff', }; type ConnState = 'connecting' | 'open' | 'reconnecting' | 'disconnected'; const MAX_RECONNECT_ATTEMPTS = 5; const RECONNECT_DELAYS_MS = [500, 1000, 2000, 4000, 8000]; const LONG_PRESS_MS = 500; const LONG_PRESS_TOLERANCE_PX = 10; // xterm 5 ships no public dimensions API — `_core._renderService.dimensions` // is internal but stable across the package versions we ship. function cellSize(term: Terminal, container: HTMLElement): { w: number; h: number } { // eslint-disable-next-line @typescript-eslint/no-explicit-any const dims = (term as any)._core?._renderService?.dimensions?.css?.cell; if (dims && dims.width > 0 && dims.height > 0) { return { w: dims.width, h: dims.height }; } const rect = container.getBoundingClientRect(); return { w: rect.width / Math.max(term.cols, 1), h: rect.height / Math.max(term.rows, 1) }; } function pointToCell( term: Terminal, container: HTMLElement, clientX: number, clientY: number, ): { col: number; bufferRow: number } { const rect = container.getBoundingClientRect(); const { w, h } = cellSize(term, container); const localX = Math.max(0, clientX - rect.left); const localY = Math.max(0, clientY - rect.top); const col = Math.min(term.cols - 1, Math.floor(localX / Math.max(w, 1))); const screenRow = Math.min(term.rows - 1, Math.floor(localY / Math.max(h, 1))); const bufferRow = term.buffer.active.viewportY + screenRow; return { col, bufferRow }; } const WORD_RE = /[\w.~$\-/]+/g; function wordRangeAt(line: string, col: number): { start: number; end: number } | null { for (const m of line.matchAll(WORD_RE)) { const start = m.index ?? 0; const end = start + m[0].length; if (col >= start && col < end) return { start, end }; if (start > col) return null; } return null; } export function TerminalPane({ sessionId, paneId, label, active = false }: Props) { const containerRef = useRef(null); const termRef = useRef(null); const searchRef = useRef(null); const wsRef = useRef(null); const reconnectTimerRef = useRef | null>(null); const reconnectAttemptRef = useRef(0); const closedByUserRef = useRef(false); // Last size pushed through term.onResize. Mirrors xterm's authoritative // cols/rows; consulted on every WS open so the resize frame carries the // currently-measured size, not a stale lastSize from before the latest fit. const lastSizeRef = useRef<{ cols: number; rows: number }>({ cols: 80, rows: 24 }); const reconnectFnRef = useRef<() => void>(() => {}); const [connState, setConnState] = useState('connecting'); const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); const [searchOpen, setSearchOpen] = useState(false); const [chatInputs, setChatInputs] = useState([]); const setMenuRef = useRef(setMenu); setMenuRef.current = setMenu; const setSearchOpenRef = useRef(setSearchOpen); setSearchOpenRef.current = setSearchOpen; const setChatInputsRef = useRef(setChatInputs); setChatInputsRef.current = setChatInputs; // v1.10.8d: sticky Ctrl for the on-screen hotkey bar (boolab pattern). // ctrlArmedRef is read synchronously inside term.onData (per keystroke); // ctrlArmed state mirror exists so the Ctrl button can highlight in React. // Auto-disarms after 5s so a stray tap doesn't quietly mangle the next // keystroke minutes later. const [ctrlArmed, setCtrlArmed] = useState(false); const ctrlArmedRef = useRef(false); const ctrlTimerRef = useRef | null>(null); const setCtrlArmedSync = useCallback((armed: boolean) => { ctrlArmedRef.current = armed; setCtrlArmed(armed); if (ctrlTimerRef.current) { clearTimeout(ctrlTimerRef.current); ctrlTimerRef.current = null; } if (armed) { ctrlTimerRef.current = setTimeout(() => { ctrlArmedRef.current = false; setCtrlArmed(false); ctrlTimerRef.current = null; }, 5000); } }, []); const armCtrl = useCallback(() => { setCtrlArmedSync(!ctrlArmedRef.current); }, [setCtrlArmedSync]); // sendInput: write to the WS as a binary frame (server-side discriminator // routes binary to PTY, text to JSON control). Used by the hotkey bar. const sendInput = useCallback((text: string) => { if (!text) return; const ws = wsRef.current; if (!ws || ws.readyState !== WebSocket.OPEN) return; try { ws.send(new TextEncoder().encode(text)); } catch { /* ignore */ } }, []); // fitFull lives inside the main useEffect (closure over `disposed`/`ctr`); // expose via a ref so the hotkey bar's fit button and the visualViewport // resize handler can reach it. const fitFullRef = useRef<() => void>(() => {}); const triggerFit = useCallback(() => { fitFullRef.current(); }, []); useEffect(() => { const container = containerRef.current; if (!container) return; const ctr: HTMLDivElement = container; let disposed = false; closedByUserRef.current = false; reconnectAttemptRef.current = 0; // 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 fit = new FitAddon(); const search = new SearchAddon(); searchRef.current = search; term.loadAddon(fit); term.loadAddon(search); term.loadAddon(new WebLinksAddon()); term.open(ctr); // Bypass FitAddon's proposeDimensions(), which subtracts the native // scrollbar's reserved width even after CSS hides it (boolab fix). We // compute cols/rows directly from host.clientWidth/Height ÷ cell metrics. // Falls through (no-op) if cell metrics aren't measured yet — a later // refit (fonts.ready / setTimeout / ResizeObserver) will catch it. // // v1.10.8d: row count uses the VISIBLE height (clipped against the // visualViewport) rather than raw clientHeight. h-dvh on the root only // shrinks for the URL bar — not the iOS keyboard — so without the clip // we allocate rows for the area hidden behind the keyboard, and bash's // cursor (pushed down by MOTD on SSH login) ends up below the fold. const fitFull = (): void => { if (disposed) return; const t = termRef.current; if (!t) return; if (!t.element || !(t.element as HTMLElement).offsetParent) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const core: any = (t as any)._core; const cellW: number | undefined = core?._renderService?.dimensions?.css?.cell?.width; const cellH: number | undefined = core?._renderService?.dimensions?.css?.cell?.height; if (!cellW || !cellH || cellW <= 0 || cellH <= 0) return; let visibleHeight = ctr.clientHeight; const vp2 = typeof window !== 'undefined' ? window.visualViewport : null; if (vp2) { const rect = ctr.getBoundingClientRect(); // visualViewport.height is the layout viewport minus the keyboard. // rect.bottom is in layout-viewport coordinates. clip to whichever // is smaller. offsetTop/pageTop adjustments aren't needed at zoom=1. const visibleBottom = Math.min(rect.bottom, vp2.height); const clipped = Math.max(0, visibleBottom - rect.top); if (clipped > 0) visibleHeight = clipped; } const cols = Math.max(2, Math.floor(ctr.clientWidth / cellW)); const rows = Math.max(1, Math.floor(visibleHeight / cellH)); if (cols !== t.cols || rows !== t.rows) { try { t.resize(cols, rows); } catch { /* ignore */ } } }; fitFullRef.current = fitFull; // boolab pattern: term.onResize fires synchronously inside term.resize() // and on init. We always update lastSizeRef so a fresh WS connect (or // reconnect) carries the current measured size; we send a resize frame // when the WS is open. No gating by an "is-started" flag — the WS itself // is the gate (it ignores writes when not open). term.onResize(({ cols, rows }) => { lastSizeRef.current = { cols, rows }; if (cols < 2 || rows < 1) return; const ws = wsRef.current; if (!ws || ws.readyState !== WebSocket.OPEN) return; try { ws.send(JSON.stringify({ type: 'resize', cols, rows })); } catch { /* ignore */ } }); // Keystrokes go out as a BINARY frame so the server can disambiguate them // from JSON control frames. TextEncoder is in every modern browser. // // Sticky Ctrl: when armed (via the hotkey bar), apply Ctrl to the next // single ASCII printable (0x40-0x7e — '@', A-Z, [, \, ], ^, _, `, a-z, // {, |, }, ~) and disarm. Multi-byte sequences (arrows, Esc, F-keys) pass // through untouched and disarm so a stuck arm doesn't poison them. term.onData((data) => { const ws = wsRef.current; if (!ws || ws.readyState !== WebSocket.OPEN) return; let out = data; if (ctrlArmedRef.current) { if (data.length === 1) { const c = data.charCodeAt(0); if (c >= 0x40 && c <= 0x7e) { out = String.fromCharCode(c & 0x1f); } } setCtrlArmedSync(false); } try { ws.send(new TextEncoder().encode(out)); } catch { /* ignore */ } }); function buildWsUrl(): string { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const { cols, rows } = lastSizeRef.current; return `${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}?cols=${cols}&rows=${rows}`; } function scheduleReconnect(): void { if (disposed || closedByUserRef.current) return; const attempt = reconnectAttemptRef.current; if (attempt >= MAX_RECONNECT_ATTEMPTS) { setConnState('disconnected'); return; } const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)]!; reconnectAttemptRef.current = attempt + 1; if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = setTimeout(connect, delay); } function connect(): void { if (disposed || closedByUserRef.current) return; const existing = wsRef.current; if (existing && existing.readyState !== WebSocket.CLOSED) return; let ws: WebSocket; try { ws = new WebSocket(buildWsUrl()); } catch { scheduleReconnect(); return; } ws.binaryType = 'arraybuffer'; wsRef.current = ws; ws.addEventListener('open', () => { if (disposed) return; reconnectAttemptRef.current = 0; setConnState('open'); // boolab pattern: send our currently-measured size as a resize frame // on every WS open. Server's PTY adopts it immediately; no race // window between attach and resize. const { cols, rows } = lastSizeRef.current; if (cols >= 2 && rows >= 1) { try { ws.send(JSON.stringify({ type: 'resize', cols, rows })); } catch { /* ignore */ } } }); ws.addEventListener('message', (e) => { const t = termRef.current; if (!t) return; if (typeof e.data === 'string') { try { const parsed = JSON.parse(e.data) as { type?: string; code?: number }; if (parsed.type === 'init') { // boolab pattern: wipe any pre-attach buffer state, then the // server's next frame (a single binary capture-pane replay) // will paint the current tmux pane state in cleanly. Re-send // our measured size in case the server's init size (from the // WS query string) is stale relative to a fit that landed // between opening and now. try { t.clear(); } catch { /* ignore */ } const { cols, rows } = lastSizeRef.current; if (cols >= 2 && rows >= 1) { try { ws.send(JSON.stringify({ type: 'resize', cols, rows })); } catch { /* ignore */ } } return; } if (parsed.type === 'exit') { t.write(`\r\n\x1b[2m[process exited with code ${parsed.code ?? 0}]\x1b[0m\r\n`); return; } } catch { /* not JSON — fall through and write as text */ } t.write(e.data); } else { t.write(new Uint8Array(e.data as ArrayBuffer)); } }); ws.addEventListener('close', () => { if (disposed) return; wsRef.current = null; if (closedByUserRef.current) return; setConnState('reconnecting'); scheduleReconnect(); }); ws.addEventListener('error', () => { // close fires after; let close own the retry. }); } function manualReconnect(): void { if (disposed) return; closedByUserRef.current = false; reconnectAttemptRef.current = 0; if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } try { wsRef.current?.close(); } catch { /* ignore */ } setConnState('reconnecting'); setTimeout(connect, 50); } reconnectFnRef.current = manualReconnect; // Init: rAF-defer one frame so xterm's render service can populate // _renderService.dimensions, then fitFull → /start (idempotent, sizes // the tmux session) → connect WS. The WS handler also ensureSession's // as belt-and-suspenders, so a /start failure is non-fatal. const initRaf = requestAnimationFrame(() => { if (disposed) return; fitFull(); const { cols, rows } = lastSizeRef.current; api.terminals .start(sessionId, paneId, cols, rows) .catch(() => { /* WS handler will ensureSession itself — non-fatal */ }) .finally(() => { if (disposed) return; connect(); }); }); // Boolab pattern: term measures cell metrics on open() against whatever // font is registered at that instant; if the font hasn't loaded yet we // get a fallback measurement and the wrong cols. Refit when // document.fonts.ready resolves. let fontRaf: number | null = null; if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) { document.fonts.ready .then(() => { if (disposed) return; fontRaf = requestAnimationFrame(() => fitFull()); }) .catch(() => { /* ignore */ }); } // Second-pass refit: mobile browsers can settle layout slightly late, // causing the rAF fit to measure a stale clientWidth. const delayedFit = setTimeout(() => fitFull(), 150); // ResizeObserver: refit on any container size change. No debounce — // term.onResize itself is the throttle (no-op when cols/rows unchanged). const ro = new ResizeObserver(() => { fitFull(); }); ro.observe(ctr); const onVis = (): void => { if (document.visibilityState !== 'visible') return; const ws = wsRef.current; if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) { manualReconnect(); } else { // Refit in case the layout changed while hidden. Single rAF so the // measurement runs after the tab's first composited frame back in // the foreground. requestAnimationFrame(() => { if (disposed) return; fitFull(); }); } }; document.addEventListener('visibilitychange', onVis); // v1.10.8d: visualViewport.resize fires when the iOS keyboard opens or // closes — the layout viewport stays full-height (so our h-dvh root // doesn't shrink), but the visual viewport contracts above the keyboard. // Refit so xterm's row count matches the visible area, then scroll to // bottom so the cursor stays above the keyboard fold (boolab pattern). let onVpResize: (() => void) | null = null; const vp = typeof window !== 'undefined' ? window.visualViewport : null; if (vp) { onVpResize = (): void => { if (disposed) return; fitFull(); const t = termRef.current; if (t) { try { t.scrollToBottom(); } catch { /* ignore */ } } }; vp.addEventListener('resize', onVpResize); } // ============================================================ // v1.10.4 features (long-press menu, right-click, search, registry) // Kept verbatim — independent of the WS/fit path that v1.10.8c fixes. // ============================================================ function pasteFromClipboard(): void { if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') { toast.error('Paste blocked — long-press input area instead'); return; } navigator.clipboard .readText() .then((text) => { if (!text) return; const ws = wsRef.current; if (ws && ws.readyState === WebSocket.OPEN) { try { ws.send(new TextEncoder().encode(text)); } catch { /* ignore */ } } }) .catch(() => { toast.error('Paste blocked — long-press input area instead'); }); } term.attachCustomKeyEventHandler((e) => { if (e.type !== 'keydown') return true; const mod = e.ctrlKey || e.metaKey; if (!mod) return true; const isC = e.key === 'c' || e.key === 'C'; const isV = e.key === 'v' || e.key === 'V'; const isF = e.key === 'f' || e.key === 'F'; if (isC) { if (term.hasSelection()) { const sel = term.getSelection(); if (sel) { navigator.clipboard.writeText(sel).catch(() => { toast.error('Clipboard write failed'); }); } return false; } return !e.shiftKey; } if (isV) { pasteFromClipboard(); return false; } if (isF) { setSearchOpenRef.current(true); return false; } return true; }); // Long-press selection + floating menu (touch). let lpTimer: ReturnType | null = null; let lpStart: { x: number; y: number } | null = null; let lpAnchor: { col: number; bufferRow: number } | null = null; let inSelection = false; function clearLp(): void { if (lpTimer !== null) { clearTimeout(lpTimer); lpTimer = null; } lpStart = null; } function selectWord(col: number, bufferRow: number): boolean { const line = term.buffer.active.getLine(bufferRow); if (!line) return false; const text = line.translateToString(true); const range = wordRangeAt(text, col); if (!range) return false; term.select(range.start, bufferRow, range.end - range.start); return true; } function extendSelection( anchor: { col: number; bufferRow: number }, to: { col: number; bufferRow: number }, ): void { const a = anchor; const b = to; let s: { col: number; row: number }; let e: { col: number; row: number }; if (a.bufferRow < b.bufferRow || (a.bufferRow === b.bufferRow && a.col <= b.col)) { s = { col: a.col, row: a.bufferRow }; e = { col: b.col, row: b.bufferRow }; } else { s = { col: b.col, row: b.bufferRow }; e = { col: a.col, row: a.bufferRow }; } const rowsBetween = e.row - s.row; const length = rowsBetween * term.cols + (e.col - s.col) + 1; term.select(s.col, s.row, length); } function onTouchStart(e: TouchEvent): void { if (e.touches.length !== 1) return; const t = e.touches[0]!; if ((e.target as Element | null)?.closest('[data-term-menu]')) return; lpStart = { x: t.clientX, y: t.clientY }; lpAnchor = pointToCell(term, ctr, t.clientX, t.clientY); inSelection = false; lpTimer = setTimeout(() => { if (disposed || !lpAnchor) return; const ok = selectWord(lpAnchor.col, lpAnchor.bufferRow); if (!ok) return; inSelection = true; setMenuRef.current({ x: t.clientX, y: Math.max(t.clientY - 50, 8) }); }, LONG_PRESS_MS); } function onTouchMove(e: TouchEvent): void { if (e.touches.length !== 1) return; const t = e.touches[0]!; if (inSelection && lpAnchor) { e.preventDefault(); const to = pointToCell(term, ctr, t.clientX, t.clientY); extendSelection(lpAnchor, to); return; } if (!lpStart) return; const dx = t.clientX - lpStart.x; const dy = t.clientY - lpStart.y; if (Math.abs(dx) > LONG_PRESS_TOLERANCE_PX || Math.abs(dy) > LONG_PRESS_TOLERANCE_PX) { clearLp(); } } function onTouchEnd(): void { if (!inSelection) clearLp(); inSelection = false; } function onTouchCancel(): void { clearLp(); inSelection = false; } ctr.addEventListener('touchstart', onTouchStart, { passive: true }); ctr.addEventListener('touchmove', onTouchMove, { passive: false }); ctr.addEventListener('touchend', onTouchEnd, { passive: true }); ctr.addEventListener('touchcancel', onTouchCancel, { passive: true }); function onContextMenu(e: MouseEvent): void { e.preventDefault(); setMenuRef.current({ x: e.clientX, y: e.clientY }); } ctr.addEventListener('contextmenu', onContextMenu); function onDocPointerDown(e: PointerEvent): void { const t = e.target as Element | null; if (t && t.closest('[data-term-menu]')) return; setMenuRef.current(null); } document.addEventListener('pointerdown', onDocPointerDown); const unregister = terminalsRegistry.register( paneId, label, () => { try { term.focus(); } catch { /* ignore */ } }, () => setSearchOpenRef.current(true), () => pasteFromClipboard(), ); const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => { if (pane_id !== paneId) return; const ws = wsRef.current; if (!ws || ws.readyState !== WebSocket.OPEN) return; const payload = text.endsWith('\n') ? text : `${text}\n`; try { ws.send(new TextEncoder().encode(payload)); } catch { /* ignore */ } }); setChatInputsRef.current(chatInputsRegistry.list()); const unsubChats = chatInputsRegistry.subscribe(() => { setChatInputsRef.current(chatInputsRegistry.list()); }); return () => { disposed = true; closedByUserRef.current = true; cancelAnimationFrame(initRaf); if (fontRaf !== null) cancelAnimationFrame(fontRaf); clearTimeout(delayedFit); if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } if (ctrlTimerRef.current) { clearTimeout(ctrlTimerRef.current); ctrlTimerRef.current = null; } if (lpTimer !== null) clearTimeout(lpTimer); ro.disconnect(); document.removeEventListener('visibilitychange', onVis); if (vp && onVpResize) vp.removeEventListener('resize', onVpResize); document.removeEventListener('pointerdown', onDocPointerDown); ctr.removeEventListener('touchstart', onTouchStart); ctr.removeEventListener('touchmove', onTouchMove); ctr.removeEventListener('touchend', onTouchEnd); ctr.removeEventListener('touchcancel', onTouchCancel); ctr.removeEventListener('contextmenu', onContextMenu); unsubscribe(); unsubChats(); unregister(); const ws = wsRef.current; if (ws) { try { ws.close(1000, 'unmount'); } catch { /* ignore */ } wsRef.current = null; } try { term.dispose(); } catch { /* ignore */ } termRef.current = null; searchRef.current = null; reconnectFnRef.current = () => {}; }; }, [sessionId, paneId, label]); // Refit when this pane becomes the visible/focused one (e.g. mobile pane // switch, maximize toggle). term.onResize will send the resize WS frame if // cols or rows actually changed. useEffect(() => { if (!active) return; const raf = requestAnimationFrame(() => { const t = termRef.current; const host = containerRef.current; if (!t || !host) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const core: any = (t as any)._core; const cellW: number | undefined = core?._renderService?.dimensions?.css?.cell?.width; const cellH: number | undefined = core?._renderService?.dimensions?.css?.cell?.height; if (!cellW || !cellH || cellW <= 0 || cellH <= 0) return; const cols = Math.max(2, Math.floor(host.clientWidth / cellW)); const rows = Math.max(1, Math.floor(host.clientHeight / cellH)); if (cols !== t.cols || rows !== t.rows) { try { t.resize(cols, rows); } catch { /* ignore */ } } }); return () => cancelAnimationFrame(raf); }, [active]); function actCopy(): void { const term = termRef.current; if (!term) return; const sel = term.getSelection(); if (!sel) { setMenu(null); return; } navigator.clipboard.writeText(sel).catch(() => toast.error('Clipboard write failed')); term.clearSelection(); setMenu(null); } function actPaste(): void { const reg = terminalsRegistry.get(paneId); reg?.paste(); setMenu(null); } function actSelectAll(): void { termRef.current?.selectAll(); setMenu(null); } function actSearch(): void { setSearchOpen(true); setMenu(null); } function actSendToChat(chatId: string): void { const term = termRef.current; if (!term) return; const sel = term.getSelection(); if (!sel) { setMenu(null); return; } sendToChat.emit({ chat_id: chatId, text: sel }); setMenu(null); } const hasSelection = !!termRef.current?.getSelection(); return (
{/* xterm CSS overrides live in src/styles/globals.css (boolab pattern). Putting them in an inline