import { useEffect, useRef } from 'react'; import { Terminal } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; import { WebLinksAddon } from 'xterm-addon-web-links'; import 'xterm/css/xterm.css'; import { api } from '@/api/client'; import { sendToTerminal, terminalsRegistry } from '@/lib/events'; interface Props { sessionId: string; paneId: string; label: string; } // Minimal dark theme. xterm.js renders against its own canvas; CSS variables // don't reach it, so we hardcode. Matches the obsidian-dark base in spirit. const XTERM_THEME = { background: '#0b0f14', 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', }; export function TerminalPane({ sessionId, paneId, label }: Props) { const containerRef = useRef(null); const wsRef = useRef(null); useEffect(() => { const container = containerRef.current; if (!container) return; let disposed = false; let resizeDebounceTimer: ReturnType | null = null; const term = new Terminal({ fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace', fontSize: 13, lineHeight: 1.2, cursorBlink: true, scrollback: 10_000, theme: XTERM_THEME, allowProposedApi: true, }); const fit = new FitAddon(); term.loadAddon(fit); term.loadAddon(new WebLinksAddon()); term.open(container); try { fit.fit(); } catch { /* container not yet sized */ } // POST start kicks the tmux window into existence before the WS upgrade. // It's idempotent so a refresh just no-ops. Failures fall through to the // WS handler which will also call ensureWindow. api.terminals.start(sessionId, paneId).catch(() => { /* surfaced by WS error if it matters */ }); const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const initialCols = term.cols; const initialRows = term.rows; const wsUrl = `${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}` + `?cols=${initialCols}&rows=${initialRows}`; const ws = new WebSocket(wsUrl); ws.binaryType = 'arraybuffer'; wsRef.current = ws; ws.onmessage = (e) => { if (typeof e.data === 'string') { // Control frame from server (e.g. {"type":"exit","code":0}). try { const parsed = JSON.parse(e.data) as { type?: string; code?: number }; if (parsed.type === 'exit') { term.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 */ } term.write(e.data); } else { term.write(new Uint8Array(e.data)); } }; ws.onclose = () => { if (disposed) return; term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n'); }; term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(data); } }); const fireResize = () => { try { fit.fit(); } catch { return; } const cols = term.cols; const rows = term.rows; api.terminals.resize(sessionId, paneId, cols, rows).catch(() => { /* transient — next resize will catch up */ }); }; const ro = new ResizeObserver(() => { if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer); resizeDebounceTimer = setTimeout(fireResize, 100); }); ro.observe(container); const unregister = terminalsRegistry.register(paneId, label); const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => { if (pane_id !== paneId) return; if (ws.readyState !== WebSocket.OPEN) return; const payload = text.endsWith('\n') ? text : `${text}\n`; ws.send(payload); }); return () => { disposed = true; unsubscribe(); unregister(); if (resizeDebounceTimer !== null) clearTimeout(resizeDebounceTimer); ro.disconnect(); try { ws.close(); } catch { /* ignore */ } wsRef.current = null; term.dispose(); }; }, [sessionId, paneId, label]); return (
); }