import { useEffect, useRef } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; interface LiveTerminalProps { /** WebSocket URL for terminal I/O */ wsUrl: string; /** Callback when terminal exits */ onExit?: () => void; /** Callback when connected */ onConnected?: () => void; /** Callback for errors */ onError?: (error: string) => void; } export default function LiveTerminal({ wsUrl, onExit, onConnected, onError }: LiveTerminalProps) { const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); const wsRef = useRef(null); // Initialize terminal and WebSocket useEffect(() => { if (!containerRef.current) return; const terminal = new Terminal({ cursorBlink: true, fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, monospace", fontSize: 14, lineHeight: 1.2, theme: { background: "#1a1a1a", foreground: "#d4d4d4", cursor: "#d4d4d4", black: "#1a1a1a", red: "#f87171", green: "#4ade80", yellow: "#facc15", blue: "#60a5fa", magenta: "#c084fc", cyan: "#22d3ee", white: "#d4d4d4", brightBlack: "#525252", brightRed: "#fca5a5", brightGreen: "#86efac", brightYellow: "#fde047", brightBlue: "#93c5fd", brightMagenta: "#d8b4fe", brightCyan: "#67e8f9", brightWhite: "#f5f5f5", }, }); const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); terminal.open(containerRef.current); fitAddon.fit(); terminalRef.current = terminal; fitAddonRef.current = fitAddon; // Connect WebSocket const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { onConnected?.(); // Send initial size ws.send( JSON.stringify({ type: "resize", cols: terminal.cols, rows: terminal.rows, }) ); }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.type === "output" && msg.data) { const decoded = atob(msg.data); terminal.write(decoded); } else if (msg.type === "exit") { onExit?.(); } else if (msg.type === "error") { onError?.(msg.message); } } catch (e) { console.error("Failed to parse WebSocket message:", e); } }; ws.onerror = () => { onError?.("WebSocket connection error"); }; ws.onclose = () => { onExit?.(); }; // Handle terminal input terminal.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: "input", data: btoa(data), }) ); } }); // Handle resize const resizeObserver = new ResizeObserver(() => { fitAddon.fit(); if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: "resize", cols: terminal.cols, rows: terminal.rows, }) ); } }); resizeObserver.observe(containerRef.current); // Focus terminal terminal.focus(); return () => { resizeObserver.disconnect(); ws.close(); terminal.dispose(); terminalRef.current = null; fitAddonRef.current = null; wsRef.current = null; }; }, [wsUrl, onConnected, onExit, onError]); return (
); }