"use client"; /** * XTerminal component * * Renders a real xterm.js terminal. The parent (TerminalPanel via useTerminalSessions) * owns the WebSocket connection and session lifecycle. This component receives the * sessionId and control functions as props and registers for output data specific * to its session. * * Handles resize, copy/paste, theme, exit overlay, and reconnect. */ import { useEffect, useRef, useCallback } from "react"; import type { ReactElement, CSSProperties } from "react"; import "@xterm/xterm/css/xterm.css"; import type { Terminal as XTerm } from "@xterm/xterm"; import type { FitAddon as XFitAddon } from "@xterm/addon-fit"; import type { SessionStatus } from "@/hooks/useTerminalSessions"; // ========================================== // Types // ========================================== export interface XTerminalProps { /** Session identifier (provided by useTerminalSessions) */ sessionId: string; /** Send keyboard input to this session */ sendInput: (sessionId: string, data: string) => void; /** Notify the server of a terminal resize */ resize: (sessionId: string, cols: number, rows: number) => void; /** Close this PTY session */ closeSession: (sessionId: string) => void; /** * Register a callback to receive output for this session. * Returns an unsubscribe function. */ registerOutputCallback: (sessionId: string, cb: (data: string) => void) => () => void; /** Whether the WebSocket is currently connected */ isConnected: boolean; /** Current PTY process status */ sessionStatus: SessionStatus; /** Exit code, populated when sessionStatus === 'exited' */ exitCode?: number; /** * Called when the user clicks the restart button after the session has exited. * The parent is responsible for closing the old session and creating a new one. */ onRestart?: () => void; /** Optional CSS class name for the outer container */ className?: string; /** Optional inline styles for the outer container */ style?: CSSProperties; /** Whether the terminal is visible (used to trigger re-fit on tab switch) */ isVisible?: boolean; } // ========================================== // Theme helpers // ========================================== /** * Read a CSS variable value from :root via computed styles. * Falls back to the provided default value if not available (e.g., during SSR). */ function getCssVar(varName: string, fallback: string): string { if (typeof document === "undefined") return fallback; const value = getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); return value.length > 0 ? value : fallback; } /** * Build an xterm.js ITheme object from the current design system CSS variables. */ function buildXtermTheme(): Record { return { background: getCssVar("--bg-deep", "#080b12"), foreground: getCssVar("--text", "#eef3ff"), cursor: getCssVar("--success", "#14b8a6"), cursorAccent: getCssVar("--bg-deep", "#080b12"), selectionBackground: `${getCssVar("--primary", "#2f80ff")}40`, selectionForeground: getCssVar("--text", "#eef3ff"), selectionInactiveBackground: `${getCssVar("--muted", "#8f9db7")}30`, // Standard ANSI colors mapped to design system black: getCssVar("--bg-deep", "#080b12"), red: getCssVar("--danger", "#e5484d"), green: getCssVar("--success", "#14b8a6"), yellow: getCssVar("--warn", "#f59e0b"), blue: getCssVar("--primary", "#2f80ff"), magenta: getCssVar("--purple", "#8b5cf6"), cyan: "#06b6d4", white: getCssVar("--text-2", "#c5d0e6"), brightBlack: getCssVar("--muted", "#8f9db7"), brightRed: "#f06a6f", brightGreen: "#2dd4bf", brightYellow: "#fbbf24", brightBlue: "#56a0ff", brightMagenta: "#a78bfa", brightCyan: "#22d3ee", brightWhite: getCssVar("--text", "#eef3ff"), }; } // ========================================== // Component // ========================================== /** * XTerminal renders a real PTY terminal powered by xterm.js. * The parent provides the sessionId and control functions; this component * registers for output data and manages the xterm.js instance lifecycle. */ export function XTerminal({ sessionId, sendInput, resize, closeSession: _closeSession, registerOutputCallback, isConnected, sessionStatus, exitCode, onRestart, className = "", style, isVisible = true, }: XTerminalProps): ReactElement { const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); const resizeObserverRef = useRef(null); const isTerminalMountedRef = useRef(false); const hasExited = sessionStatus === "exited"; // ========================================== // Fit helper // ========================================== const fitAndResize = useCallback((): void => { const fitAddon = fitAddonRef.current; const terminal = terminalRef.current; if (!fitAddon || !terminal) return; try { fitAddon.fit(); resize(sessionId, terminal.cols, terminal.rows); } catch { // Ignore fit errors (e.g., when container has zero dimensions) } }, [resize, sessionId]); // ========================================== // Mount xterm.js terminal (client-only) // ========================================== useEffect(() => { if (!containerRef.current || isTerminalMountedRef.current) return; let cancelled = false; const mountTerminal = async (): Promise => { // Dynamic imports ensure DOM-dependent xterm.js modules are never loaded server-side const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([ import("@xterm/xterm"), import("@xterm/addon-fit"), import("@xterm/addon-web-links"), ]); if (cancelled || !containerRef.current) return; const theme = buildXtermTheme(); const terminal = new Terminal({ fontFamily: "var(--mono, 'Fira Code', 'Cascadia Code', monospace)", fontSize: 13, lineHeight: 1.4, cursorBlink: true, cursorStyle: "block", scrollback: 10000, theme, allowTransparency: false, convertEol: true, // Accessibility screenReaderMode: false, }); const fitAddon = new FitAddon(); const webLinksAddon = new WebLinksAddon(); terminal.loadAddon(fitAddon); terminal.loadAddon(webLinksAddon); terminal.open(containerRef.current); terminalRef.current = terminal; fitAddonRef.current = fitAddon; isTerminalMountedRef.current = true; // Initial fit try { fitAddon.fit(); } catch { // Container might not have dimensions yet } // Set up ResizeObserver for automatic re-fitting const observer = new ResizeObserver(() => { fitAndResize(); }); observer.observe(containerRef.current); resizeObserverRef.current = observer; }; void mountTerminal(); return (): void => { cancelled = true; }; // Intentionally empty dep array — mount once only }, []); // ========================================== // Register output callback for this session // ========================================== useEffect(() => { const unregister = registerOutputCallback(sessionId, (data: string) => { terminalRef.current?.write(data); }); return unregister; }, [sessionId, registerOutputCallback]); // ========================================== // Re-fit when visibility changes // ========================================== useEffect(() => { if (isVisible) { // Small delay allows CSS transitions to complete before fitting const id = setTimeout(fitAndResize, 50); return (): void => { clearTimeout(id); }; } return undefined; }, [isVisible, fitAndResize]); // ========================================== // Wire terminal input → sendInput // ========================================== useEffect(() => { const terminal = terminalRef.current; if (!terminal) return; const disposable = terminal.onData((data: string): void => { sendInput(sessionId, data); }); return (): void => { disposable.dispose(); }; }, [sendInput, sessionId]); // ========================================== // Update xterm theme when data-theme attribute changes // ========================================== useEffect(() => { const observer = new MutationObserver(() => { const terminal = terminalRef.current; if (terminal) { terminal.options.theme = buildXtermTheme(); } }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"], }); return (): void => { observer.disconnect(); }; }, []); // ========================================== // Cleanup on unmount // ========================================== useEffect(() => { return (): void => { // Cleanup ResizeObserver resizeObserverRef.current?.disconnect(); resizeObserverRef.current = null; // Dispose xterm terminal terminalRef.current?.dispose(); terminalRef.current = null; fitAddonRef.current = null; isTerminalMountedRef.current = false; }; }, []); // ========================================== // Restart handler // ========================================== const handleRestart = useCallback((): void => { const terminal = terminalRef.current; if (terminal) { terminal.clear(); } // Notify parent to close old session and create a new one onRestart?.(); }, [onRestart]); // ========================================== // Render // ========================================== const containerStyle: CSSProperties = { flex: 1, overflow: "hidden", position: "relative", backgroundColor: "var(--bg-deep)", ...style, }; return (
{/* Status bar — show when not connected and not exited */} {!isConnected && !hasExited && (
Connecting to terminal...
)} {/* Exit overlay */} {hasExited && (
)} {/* xterm.js render target */}
); }