/** * Interactive Terminal Component * * Full terminal emulation using xterm.js with WebSocket connection to the backend. * Supports input/output streaming, terminal resizing, and reconnection handling. */ import { useEffect, useRef, useCallback, useState } from 'react' import { Terminal as XTerm } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import '@xterm/xterm/css/xterm.css' interface TerminalProps { projectName: string terminalId: string isActive: boolean } // WebSocket message types for terminal I/O interface TerminalInputMessage { type: 'input' data: string // base64 encoded } interface TerminalResizeMessage { type: 'resize' cols: number rows: number } interface TerminalOutputMessage { type: 'output' data: string // base64 encoded } interface TerminalExitMessage { type: 'exit' code: number } type TerminalServerMessage = TerminalOutputMessage | TerminalExitMessage // Clean terminal theme colors const TERMINAL_THEME = { background: '#09090b', // zinc-950 foreground: '#fafafa', // zinc-50 cursor: '#3b82f6', // blue-500 cursorAccent: '#09090b', selectionBackground: 'rgba(59, 130, 246, 0.3)', selectionForeground: '#ffffff', black: '#09090b', red: '#ef4444', green: '#22c55e', yellow: '#eab308', blue: '#3b82f6', magenta: '#a855f7', cyan: '#06b6d4', white: '#fafafa', brightBlack: '#52525b', brightRed: '#f87171', brightGreen: '#4ade80', brightYellow: '#facc15', brightBlue: '#60a5fa', brightMagenta: '#c084fc', brightCyan: '#22d3ee', brightWhite: '#ffffff', } // Reconnection configuration const RECONNECT_DELAY_BASE = 1000 const RECONNECT_DELAY_MAX = 30000 export function Terminal({ projectName, terminalId, isActive }: TerminalProps) { const containerRef = useRef(null) const terminalRef = useRef(null) const fitAddonRef = useRef(null) const wsRef = useRef(null) const reconnectTimeoutRef = useRef(null) const reconnectAttempts = useRef(0) const isInitializedRef = useRef(false) const isConnectingRef = useRef(false) const hasExitedRef = useRef(false) // Track intentional disconnection to prevent auto-reconnect race condition const isManualCloseRef = useRef(false) // Store connect function in ref to avoid useEffect dependency issues const connectRef = useRef<(() => void) | null>(null) // Track last project/terminal to avoid duplicate connect on initial activation const lastProjectRef = useRef(null) const lastTerminalIdRef = useRef(null) // Track isActive in a ref to avoid stale closure issues in connect() const isActiveRef = useRef(isActive) const [isConnected, setIsConnected] = useState(false) const [hasExited, setHasExited] = useState(false) const [exitCode, setExitCode] = useState(null) // Keep ref in sync with state for use in callbacks without re-creating them useEffect(() => { hasExitedRef.current = hasExited }, [hasExited]) // Keep isActiveRef in sync with isActive prop to avoid stale closures useEffect(() => { isActiveRef.current = isActive }, [isActive]) /** * Encode string to base64 */ const encodeBase64 = useCallback((str: string): string => { // Handle Unicode by encoding to UTF-8 first const encoder = new TextEncoder() const bytes = encoder.encode(str) let binary = '' for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]) } return btoa(binary) }, []) /** * Decode base64 to string */ const decodeBase64 = useCallback((base64: string): string => { try { const binary = atob(base64) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) } const decoder = new TextDecoder() return decoder.decode(bytes) } catch { console.error('Failed to decode base64 data') return '' } }, []) /** * Send a message through the WebSocket */ const sendMessage = useCallback( (message: TerminalInputMessage | TerminalResizeMessage) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify(message)) } }, [] ) /** * Send resize message to server */ const sendResize = useCallback( (cols: number, rows: number) => { const message: TerminalResizeMessage = { type: 'resize', cols, rows, } sendMessage(message) }, [sendMessage] ) /** * Fit terminal to container and notify server of new dimensions */ const fitTerminal = useCallback(() => { if (fitAddonRef.current && terminalRef.current) { try { // Try to get proposed dimensions first const dimensions = fitAddonRef.current.proposeDimensions() const hasValidDimensions = dimensions && dimensions.cols && dimensions.rows && !isNaN(dimensions.cols) && !isNaN(dimensions.rows) && dimensions.cols >= 1 && dimensions.rows >= 1 if (hasValidDimensions) { // Valid dimensions - fit the terminal fitAddonRef.current.fit() } // Always send resize with current terminal dimensions // This ensures the server has the correct size even if fit() was skipped const { cols, rows } = terminalRef.current if (cols > 0 && rows > 0) { sendResize(cols, rows) } } catch { // Container may not be visible yet, ignore } } }, [sendResize]) /** * Connect to the terminal WebSocket */ const connect = useCallback(() => { // Use isActiveRef.current instead of isActive to avoid stale closure issues // when connect is called from setTimeout callbacks if (!projectName || !terminalId || !isActiveRef.current) return // Prevent multiple simultaneous connection attempts if ( isConnectingRef.current || wsRef.current?.readyState === WebSocket.CONNECTING || wsRef.current?.readyState === WebSocket.OPEN ) { return } isConnectingRef.current = true // Clear any pending reconnection if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) reconnectTimeoutRef.current = null } // Build WebSocket URL with terminal ID const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const host = window.location.host const wsUrl = `${protocol}//${host}/api/terminal/ws/${encodeURIComponent(projectName)}/${encodeURIComponent(terminalId)}` try { const ws = new WebSocket(wsUrl) wsRef.current = ws ws.onopen = () => { isConnectingRef.current = false setIsConnected(true) setHasExited(false) setExitCode(null) reconnectAttempts.current = 0 // Send initial size after connection if (terminalRef.current) { const { cols, rows } = terminalRef.current sendResize(cols, rows) } } ws.onmessage = (event) => { try { const message: TerminalServerMessage = JSON.parse(event.data) switch (message.type) { case 'output': { const decoded = decodeBase64(message.data) if (decoded && terminalRef.current) { terminalRef.current.write(decoded) } break } case 'exit': { setHasExited(true) setExitCode(message.code) if (terminalRef.current) { terminalRef.current.writeln('') terminalRef.current.writeln( `\x1b[33m[Shell exited with code ${message.code}]\x1b[0m` ) terminalRef.current.writeln( '\x1b[90mPress any key to reconnect...\x1b[0m' ) } break } } } catch { console.error('Failed to parse terminal WebSocket message') } } ws.onclose = () => { isConnectingRef.current = false setIsConnected(false) wsRef.current = null // Only reconnect if still active, not intentionally exited, and not manually closed // Use isActiveRef.current to get the current value, avoiding stale closure const shouldReconnect = isActiveRef.current && !hasExitedRef.current && !isManualCloseRef.current // Reset manual close flag after checking (so subsequent disconnects can auto-reconnect) isManualCloseRef.current = false if (shouldReconnect) { // Exponential backoff reconnection const delay = Math.min( RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts.current), RECONNECT_DELAY_MAX ) reconnectAttempts.current++ reconnectTimeoutRef.current = window.setTimeout(() => { connect() }, delay) } } ws.onerror = () => { // Will trigger onclose, which handles reconnection ws.close() } } catch { isConnectingRef.current = false // Failed to connect, attempt reconnection const delay = Math.min( RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttempts.current), RECONNECT_DELAY_MAX ) reconnectAttempts.current++ reconnectTimeoutRef.current = window.setTimeout(() => { connect() }, delay) } }, [projectName, terminalId, sendResize, decodeBase64]) // Keep connect ref up to date useEffect(() => { connectRef.current = connect }, [connect]) /** * Initialize xterm.js terminal */ const initializeTerminal = useCallback(() => { if (!containerRef.current || isInitializedRef.current) return // Create terminal instance const terminal = new XTerm({ theme: TERMINAL_THEME, fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace', fontSize: 14, cursorBlink: true, cursorStyle: 'block', allowProposedApi: true, scrollback: 10000, }) // Create and load FitAddon const fitAddon = new FitAddon() terminal.loadAddon(fitAddon) // Open terminal in container terminal.open(containerRef.current) // Store references terminalRef.current = terminal fitAddonRef.current = fitAddon isInitializedRef.current = true // NOTE: Don't call fitTerminal() here - let the activation effect handle it // after layout is fully calculated. This avoids dimension calculation issues // when the container is first rendered. // Handle keyboard input terminal.onData((data: string) => { // If shell has exited, reconnect on any key // Use ref to avoid re-creating this callback when hasExited changes if (hasExitedRef.current) { setHasExited(false) setExitCode(null) connectRef.current?.() return } // Send input to server const message: TerminalInputMessage = { type: 'input', data: encodeBase64(data), } sendMessage(message) }) // Handle terminal resize terminal.onResize(({ cols, rows }: { cols: number; rows: number }) => { sendResize(cols, rows) }) }, [encodeBase64, sendMessage, sendResize]) /** * Handle window resize */ useEffect(() => { if (!isActive) return const handleResize = () => { fitTerminal() } window.addEventListener('resize', handleResize) return () => { window.removeEventListener('resize', handleResize) } }, [isActive, fitTerminal]) /** * Initialize terminal and WebSocket when becoming active */ useEffect(() => { if (!isActive) { // When becoming inactive, just clear reconnect timeout but keep WebSocket alive // This preserves the terminal buffer and connection for when we switch back if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) reconnectTimeoutRef.current = null } // DO NOT close WebSocket here - keep it alive to preserve buffer return } // Initialize terminal if not already done if (!isInitializedRef.current) { initializeTerminal() } // Connect WebSocket if not already connected // Use double rAF + timeout to ensure terminal is rendered with correct dimensions // before connecting (the fit/refresh effect handles the actual fitting) let rafId1: number let rafId2: number const connectIfNeeded = () => { rafId1 = requestAnimationFrame(() => { rafId2 = requestAnimationFrame(() => { if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) { connectRef.current?.() } }) }) } // Delay connection to ensure terminal dimensions are calculated first const timeoutId = window.setTimeout(connectIfNeeded, 50) return () => { clearTimeout(timeoutId) cancelAnimationFrame(rafId1) cancelAnimationFrame(rafId2) } }, [isActive, initializeTerminal]) /** * Fit and refresh terminal when isActive becomes true */ useEffect(() => { if (isActive && terminalRef.current) { // Use double requestAnimationFrame to ensure: // 1. First rAF: style changes are committed // 2. Second rAF: layout is recalculated // This is more reliable than setTimeout for visibility changes let rafId1: number let rafId2: number const handleActivation = () => { rafId1 = requestAnimationFrame(() => { rafId2 = requestAnimationFrame(() => { if (terminalRef.current && fitAddonRef.current) { // Fit terminal to get correct dimensions fitTerminal() // Refresh the terminal to redraw content after becoming visible // This fixes rendering issues when switching between terminals terminalRef.current.refresh(0, terminalRef.current.rows - 1) // Focus the terminal to receive keyboard input terminalRef.current.focus() } }) }) } // Small initial delay to ensure React has committed the style changes const timeoutId = window.setTimeout(handleActivation, 16) return () => { clearTimeout(timeoutId) cancelAnimationFrame(rafId1) cancelAnimationFrame(rafId2) } } }, [isActive, fitTerminal]) /** * Cleanup on unmount */ useEffect(() => { return () => { if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) } if (wsRef.current) { wsRef.current.close() } if (terminalRef.current) { terminalRef.current.dispose() } isInitializedRef.current = false } }, []) /** * Reconnect when project or terminal changes */ useEffect(() => { if (isActive && isInitializedRef.current) { // Only reconnect if project or terminal actually changed, not on initial activation // This prevents duplicate connect calls when both isActive and projectName/terminalId effects run if (lastProjectRef.current === null && lastTerminalIdRef.current === null) { // Initial activation - just track the project/terminal, don't reconnect (the isActive effect handles initial connect) lastProjectRef.current = projectName lastTerminalIdRef.current = terminalId return } if (lastProjectRef.current === projectName && lastTerminalIdRef.current === terminalId) { // Nothing changed, skip return } // Project or terminal changed - update tracking lastProjectRef.current = projectName lastTerminalIdRef.current = terminalId // Clear terminal and reset cursor position if (terminalRef.current) { terminalRef.current.clear() terminalRef.current.write('\x1b[H') // Move cursor to home position } // Set manual close flag to prevent auto-reconnect race condition isManualCloseRef.current = true // Close existing connection and reset connecting state if (wsRef.current) { wsRef.current.close() wsRef.current = null } isConnectingRef.current = false // Reset state setHasExited(false) setExitCode(null) reconnectAttempts.current = 0 // Connect to new project/terminal using ref to avoid dependency on connect callback connectRef.current?.() } }, [projectName, terminalId, isActive]) return (
{/* Connection status indicator */}
{!isConnected && !hasExited && ( Connecting... )} {hasExited && exitCode !== null && ( Exit: {exitCode} )}
{/* Terminal container */}
{ // Ensure terminal gets focus when container is clicked terminalRef.current?.focus() }} />
) }