/** * TerminalPanel — xterm.js front-end over the /api/projects/{id}/terminal * WebSocket PTY bridge. * * Lifecycle: * - On mount + project change: opens a WS, attaches it to a fresh xterm. * - Resize: ResizeObserver -> fit addon -> send {"resize":[c,r]} control frame. * - On unmount: closes the WS (server reaps the PTY child). */ import { useEffect, useRef } from 'react' import useAppStore from '../store/appStore' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import '@xterm/xterm/css/xterm.css' export default function TerminalPanel() { const projectId = useAppStore((s) => s.project.id) const theme = useAppStore((s) => s.theme) const hostRef = useRef(null) const termRef = useRef(null) const wsRef = useRef(null) const fitRef = useRef(null) useEffect(() => { if (!projectId || !hostRef.current) return // Colour scheme picked to match the studio surfaces (CSS vars aren't // accessible from xterm directly so we hardcode a small palette). const dark = theme !== 'light' const term = new Terminal({ fontFamily: 'ui-monospace, "JetBrains Mono", Menlo, monospace', fontSize: 12.5, lineHeight: 1.15, cursorBlink: true, convertEol: false, scrollback: 5000, allowProposedApi: true, theme: dark ? { background: '#0b1220', foreground: '#e2e8f0', cursor: '#22d3ee', selectionBackground: '#1e3a8a' } : { background: '#f8fafc', foreground: '#0f172a', cursor: '#0284c7', selectionBackground: '#bfdbfe' }, }) const fit = new FitAddon() term.loadAddon(fit) term.open(hostRef.current) fit.fit() term.focus() termRef.current = term fitRef.current = fit const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const wsUrl = `${proto}//${window.location.host}` + `/api/projects/${projectId}/terminal` + `?cols=${term.cols}&rows=${term.rows}` const ws = new WebSocket(wsUrl) ws.binaryType = 'arraybuffer' wsRef.current = ws ws.onopen = () => { term.write('\x1b[2m[tesseract] terminal connected — bash/zsh at project root.\x1b[0m\r\n') } ws.onmessage = (ev) => { if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)) } else if (typeof ev.data === 'string') { term.write(ev.data) } } ws.onclose = () => { term.write('\r\n\x1b[2m[tesseract] terminal disconnected.\x1b[0m\r\n') } ws.onerror = () => { term.write('\r\n\x1b[31m[tesseract] terminal error.\x1b[0m\r\n') } const onData = term.onData((data) => { if (ws.readyState === WebSocket.OPEN) ws.send(data) }) const ro = new ResizeObserver(() => { try { fit.fit() if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ resize: [term.cols, term.rows] })) } } catch { /* ignore */ } }) ro.observe(hostRef.current) return () => { ro.disconnect() onData.dispose() try { ws.close() } catch { /* ignore */ } term.dispose() termRef.current = null fitRef.current = null wsRef.current = null } }, [projectId, theme]) if (!projectId) { return (