import { useState, useEffect, useRef, useCallback } from "react"; import { X, Trash2, Terminal as TerminalIcon, RefreshCw } from "lucide-react"; import { useTerminal } from "../hooks/useTerminal"; import { useTerminalSessions } from "../hooks/useTerminalSessions"; import "@xterm/xterm/css/xterm.css"; import type { Terminal as XTerm, ITerminalAddon } from "@xterm/xterm"; import type { FitAddon } from "@xterm/addon-fit"; /** Timeout for xterm.js dynamic imports + terminal.open() setup. */ const XTERM_INIT_TIMEOUT_MS = 10000; /** Whether the current device is likely mobile (touch-primary, small viewport). */ function isMobileDevice(): boolean { if (typeof window === "undefined") return false; const hasTouchScreen = "ontouchstart" in window || (navigator as any).maxTouchPoints > 0; const isNarrow = window.innerWidth <= 768; return hasTouchScreen && isNarrow; } /** * Compute how many CSS pixels the virtual keyboard covers from the bottom * of the layout viewport. Returns 0 on desktop or when visualViewport is * unavailable. */ function getKeyboardOverlap(): number { if (typeof window === "undefined" || !window.visualViewport) return 0; const vv = window.visualViewport; // The keyboard pushes the visual viewport up so that its bottom edge // no longer aligns with the layout viewport bottom. return Math.max(0, window.innerHeight - vv.offsetTop - vv.height); } interface TerminalModalProps { isOpen: boolean; onClose: () => void; initialCommand?: string; } /** * Interactive terminal modal component using xterm.js and node-pty. * * Provides a fully functional PTY terminal where users can execute commands * in the project's working directory. Features include: * - Real-time bidirectional communication via WebSocket * - Multiple terminal tabs with session persistence * - xterm.js for proper terminal emulation * - Copy/paste support * - Terminal zoom (Ctrl++/Ctrl+-/Ctrl+0) * - Auto-resizing to container * - Reconnection support * * The terminal spawns a real shell (bash/zsh/powershell based on platform). */ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModalProps) { const [error, setError] = useState(null); const [exitCode, setExitCode] = useState(null); const [xtermReady, setXtermReady] = useState(false); const [xtermInitError, setXtermInitError] = useState(null); const [openGeneration, setOpenGeneration] = useState(0); const [keyboardOverlap, setKeyboardOverlap] = useState(0); const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const hasInitialCommandRun = useRef(false); const xtermInitializedRef = useRef(false); // Bump open generation whenever the modal opens so the initialCommand // effect re-evaluates after a close/reopen cycle (deps may be identical). useEffect(() => { if (isOpen) { setOpenGeneration((g) => g + 1); } }, [isOpen]); // Track virtual keyboard overlap on mobile so the terminal entry area // stays visible above the keyboard. On desktop this is a no-op. useEffect(() => { if (!isOpen || !isMobileDevice()) return; const vv = window.visualViewport; if (!vv) return; const update = () => setKeyboardOverlap(getKeyboardOverlap()); update(); // initial measurement vv.addEventListener("resize", update); vv.addEventListener("scroll", update); return () => { vv.removeEventListener("resize", update); vv.removeEventListener("scroll", update); setKeyboardOverlap(0); }; }, [isOpen]); // Use the session management hook const { tabs, activeTab, isReady, bootstrapError, createTab, closeTab, setActiveTab, updateTabTitle, restartActiveTab, retryBootstrap, } = useTerminalSessions(); // Get the WebSocket connection for the active session const { connectionStatus, sendInput, resize, onData, onConnect, onExit, onScrollback, reconnect } = useTerminal(activeTab?.sessionId ?? null); // Initialize xterm.js when session is ready // Depends on `isReady`, `activeTab`, and xtermReady to properly reinitialize on tab switch useEffect(() => { if (!isOpen || !isReady || !activeTab || !terminalRef.current) return; // If session changed, we need to reinitialize xterm const currentSessionId = activeTab.sessionId; // Clean up existing xterm if switching sessions if (xtermRef.current && xtermInitializedRef.current !== currentSessionId) { xtermRef.current.dispose(); xtermRef.current = null; fitAddonRef.current = null; xtermInitializedRef.current = false; setXtermReady(false); setXtermInitError(null); } // If already initialized for this session, skip if (xtermInitializedRef.current === currentSessionId && xtermRef.current) { return; } let mounted = true; let watchdogTimer: ReturnType; const initTerminal = async () => { // Dynamically import xterm modules with watchdog timeout const importsPromise = Promise.all([ import("@xterm/xterm"), import("@xterm/addon-fit"), import("@xterm/addon-web-links"), ]); // Watchdog: reject if imports + setup take too long const timeoutPromise = new Promise((_resolve, reject) => { watchdogTimer = setTimeout(() => { reject(new Error("xterm initialization timed out")); }, XTERM_INIT_TIMEOUT_MS); }); let terminal: InstanceType; let fitAddon: InstanceType; try { const [{ Terminal: TerminalCtor }, { FitAddon: FitAddonCtor }, { WebLinksAddon }] = await Promise.race([importsPromise, timeoutPromise]); if (!mounted || !terminalRef.current || xtermRef.current) return; // Create terminal instance terminal = new TerminalCtor({ cursorBlink: true, cursorStyle: "block", fontSize: 14, fontFamily: "monospace", theme: { background: "#1e1e1e", foreground: "#d4d4d4", cursor: "#d4d4d4", selectionBackground: "#264f78", black: "#1e1e1e", red: "#f48771", green: "#4ec9b0", yellow: "#dcdcaa", blue: "#569cd6", magenta: "#c586c0", cyan: "#9cdcfe", white: "#d4d4d4", }, allowProposedApi: true, scrollback: 5000, }); // Load addons fitAddon = new FitAddonCtor(); terminal.loadAddon(fitAddon); const webLinksAddon = new WebLinksAddon(); terminal.loadAddon(webLinksAddon); // Try to load WebGL addon for better performance try { const { WebglAddon } = await import("@xterm/addon-webgl"); const webglAddon = new WebglAddon(); webglAddon.onContextLoss(() => { webglAddon.dispose(); }); terminal.loadAddon(webglAddon); } catch { // WebGL not available, fallback to canvas } // Open terminal in container terminal.open(terminalRef.current); // Clear watchdog — imports and open() succeeded within deadline clearTimeout(watchdogTimer); // Initial fit setTimeout(() => { fitAddon.fit(); }, 50); xtermRef.current = terminal; fitAddonRef.current = fitAddon; xtermInitializedRef.current = currentSessionId; // Signal that xterm is ready so the subscription effect re-runs setXtermReady(true); // Clear any prior xterm init error setXtermInitError(null); // Handle data from terminal (user input) const dataHandler = terminal.onData((data) => { sendInput(data); }); // Handle resize const resizeHandler = () => { if (fitAddonRef.current && xtermRef.current) { try { (fitAddonRef.current as InstanceType).fit(); const { cols, rows } = xtermRef.current; resize(cols, rows); } catch { // Ignore fit errors } } }; window.addEventListener("resize", resizeHandler); return () => { dataHandler.dispose(); window.removeEventListener("resize", resizeHandler); }; } catch (err) { clearTimeout(watchdogTimer); if (!mounted) return; const message = err instanceof Error ? err.message : "xterm initialization failed"; setXtermInitError(message); } }; const cleanupPromise = initTerminal(); return () => { mounted = false; clearTimeout(watchdogTimer); cleanupPromise.then((cleanup) => cleanup?.()); // Don't dispose xterm here - it should persist across tab switches // Only dispose when the modal is fully closed }; }, [isOpen, isReady, activeTab?.sessionId, sendInput, resize]); // Cleanup xterm when modal closes useEffect(() => { if (isOpen) return; // Modal is closed - cleanup xterm if (xtermRef.current) { xtermRef.current.dispose(); xtermRef.current = null; } fitAddonRef.current = null; xtermInitializedRef.current = false; setXtermReady(false); setXtermInitError(null); hasInitialCommandRun.current = false; setError(null); setExitCode(null); }, [isOpen]); // Subscribe to terminal data. // Depends on `xtermReady` so subscriptions are established after the // async xterm initialization completes and xtermRef.current is set. // Depends on `activeTab?.sessionId` (not just `activeTab?.id`) so that // creating a new tab triggers rebinding to the new session's WebSocket // callbacks. Without sessionId, the effect would miss session switches // that happen within the same modal session. useEffect(() => { if (!xtermReady || !xtermRef.current || !activeTab) return; const unsubData = onData((data) => { xtermRef.current?.write(data); }); const unsubScrollback = onScrollback((data) => { xtermRef.current?.write(data); }); const unsubConnect = onConnect((info) => { // Update tab title with shell name updateTabTitle(activeTab.id, info.shell.split("/").pop() || info.shell); }); const unsubExit = onExit((code) => { setExitCode(code); xtermRef.current?.write(`\r\n\x1b[33m[Process exited with code ${code}]\x1b[0m\r\n`); }); return () => { unsubData(); unsubScrollback(); unsubConnect(); unsubExit(); }; }, [xtermReady, activeTab?.sessionId, activeTab?.id, onData, onScrollback, onConnect, onExit, updateTabTitle]); // Run initial command when connected. // Tracks the last command that was sent so that a new command provided // while the terminal is already open (e.g., running a different script) // will be executed immediately without requiring a modal close/reopen. // Depends on openGeneration so the command re-fires after close/reopen. useEffect(() => { if (connectionStatus === "connected" && initialCommand && hasInitialCommandRun.current !== initialCommand && activeTab) { hasInitialCommandRun.current = initialCommand; // Small delay to let shell initialize setTimeout(() => { sendInput(initialCommand + "\n"); }, 500); } }, [connectionStatus, initialCommand, sendInput, activeTab, openGeneration]); // Handle keyboard shortcuts (zoom) useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (!e.ctrlKey && !e.metaKey) return; // Zoom in: Ctrl/Cmd + Plus if (e.code === "Equal" || e.code === "NumpadAdd") { e.preventDefault(); if (xtermRef.current) { const currentSize = xtermRef.current.options.fontSize || 14; xtermRef.current.options.fontSize = Math.min(currentSize + 1, 32); fitAddonRef.current && (fitAddonRef.current as InstanceType).fit(); } return; } // Zoom out: Ctrl/Cmd + Minus if (e.code === "Minus" || e.code === "NumpadSubtract") { e.preventDefault(); if (xtermRef.current) { const currentSize = xtermRef.current.options.fontSize || 14; xtermRef.current.options.fontSize = Math.max(currentSize - 1, 8); fitAddonRef.current && (fitAddonRef.current as InstanceType).fit(); } return; } // Reset zoom: Ctrl/Cmd + 0 if (e.code === "Digit0" || e.code === "Numpad0") { e.preventDefault(); if (xtermRef.current) { xtermRef.current.options.fontSize = 14; fitAddonRef.current && (fitAddonRef.current as InstanceType).fit(); } return; } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isOpen]); // Handle escape key to close useEffect(() => { if (!isOpen) return; const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(); } }; document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); }, [isOpen, onClose]); // Focus terminal when connected useEffect(() => { if (connectionStatus === "connected" && xtermRef.current) { setTimeout(() => { xtermRef.current?.focus(); }, 100); } }, [connectionStatus]); // Handle overlay click to close const handleOverlayClick = useCallback( (e: React.MouseEvent) => { if (e.target === e.currentTarget) onClose(); }, [onClose] ); // Handle clear button const handleClear = useCallback(() => { xtermRef.current?.clear(); }, []); // Handle restart - create new session in the current tab const handleRestart = useCallback(async () => { // Clear terminal display xtermRef.current?.clear(); setExitCode(null); hasInitialCommandRun.current = false; // Restart the active tab's session try { await restartActiveTab(); } catch (err: any) { setError(err.message || "Failed to restart terminal session"); } }, [restartActiveTab]); // Reinitialize xterm UI without recreating the session. // Used when xterm initialization fails/stalls but the backend session is fine. const handleReinitialize = useCallback(() => { // Dispose any partially-initialized xterm if (xtermRef.current) { xtermRef.current.dispose(); xtermRef.current = null; } fitAddonRef.current = null; xtermInitializedRef.current = false; // Clear error state and reset readiness so the init effect re-runs setXtermInitError(null); setXtermReady(false); }, []); if (!isOpen) return null; const getStatusIndicator = () => { switch (connectionStatus) { case "connected": return ; case "connecting": case "reconnecting": return ; case "disconnected": return ; default: return null; } }; // Determine loading state — when bootstrapError or xtermInitError is set, we are NOT loading // (we have a definitive error to show instead of an indefinite spinner). const isLoading = !isReady || (!activeTab && !bootstrapError) || (!xtermReady && !xtermInitError); return (
0 ? { "--keyboard-overlap": `${keyboardOverlap}px` } as React.CSSProperties : undefined } > {/* Header — on mobile (≤768px) flex-wrap stacks tabs and actions on separate rows; .terminal-title is hidden; action button labels are hidden (icons only) */}
{/* Tab Bar */}
{tabs.map((tab) => (
setActiveTab(tab.id)} title={tab.title} role="tab" aria-selected={tab.isActive} > {tab.title} {tabs.length > 1 && ( )}
))}
{/* Status indicator */}
{getStatusIndicator()}
{/* Actions — labels hidden on mobile via .terminal-action-label */}
{connectionStatus === "disconnected" && activeTab && ( )} {exitCode !== null && ( )}
{/* Error message */} {error && (
{error}
)} {/* Terminal container */}
{isLoading && !bootstrapError && (
Starting terminal...
)} {bootstrapError && !activeTab && (
Failed to start terminal: {bootstrapError}
)} {xtermInitError && activeTab && (
Terminal UI failed to initialize: {xtermInitError}
)} {/* Always render the xterm container (no display:none) so that terminal.open() can measure its dimensions even during a tab switch. The loading overlay (position: absolute) visually covers it until xterm is ready. Use key={sessionId} to force a clean DOM remount when switching tabs — this prevents stale xterm state from the previous session. */}
{/* Connection status bar */}
{connectionStatus === "connected" && "Connected"} {connectionStatus === "connecting" && "Connecting..."} {connectionStatus === "reconnecting" && "Reconnecting..."} {connectionStatus === "disconnected" && "Disconnected"} {exitCode !== null && ( Exit: {exitCode} )} Ctrl++/- zoom • Ctrl+L clear • Esc close
); }