import React, { useEffect, useRef, useState, useCallback } from "react"; import { Terminal } from "xterm"; import { FitAddon } from "xterm-addon-fit"; import { WebLinksAddon } from "xterm-addon-web-links"; import "xterm/css/xterm.css"; import { applyModifierToChar, type ModifierKey } from "../hooks/use-special-keys"; interface TerminalProps { instanceId: string; onClose?: () => void; isMobile?: boolean; activeModifier?: ModifierKey | null; onModifierChange?: (modifier: ModifierKey | null) => void; onTerminalReady?: ( sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting", focusInput: () => void, changeFontSize: (delta: number) => void ) => void; } const FONT_SIZE_KEY = "terminal-font-size"; const MIN_FONT_SIZE = 6; const MAX_FONT_SIZE = 20; const RECONNECT_ATTEMPTS = 3; const RECONNECT_DELAY_BASE = 1000; export const TerminalComponent: React.FC = ({ instanceId, onClose, isMobile = false, activeModifier, onModifierChange, onTerminalReady, }) => { const terminalRef = useRef(null); const hiddenInputRef = useRef(null); const wsRef = useRef(null); const termRef = useRef(null); const fitAddonRef = useRef(null); const reconnectAttemptsRef = useRef(0); const onTerminalReadyRef = useRef(onTerminalReady); onTerminalReadyRef.current = onTerminalReady; const [status, setStatus] = useState< "connecting" | "connected" | "disconnected" | "error" | "resetting" >("connecting"); const [error, setError] = useState(null); const [showResetConfirm, setShowResetConfirm] = useState(false); const activeModifierRef = useRef(activeModifier); activeModifierRef.current = activeModifier; const [fontSize, setFontSize] = useState(() => { if (typeof window === "undefined") return isMobile ? 12 : 10; const stored = localStorage.getItem(FONT_SIZE_KEY); return stored ? parseInt(stored, 10) : isMobile ? 12 : 10; }); const lastPingRef = useRef(0); const heartbeatCheckRef = useRef(null); const isUnmountingRef = useRef(false); const calculateFontSize = useCallback(() => { if (!isMobile) return fontSize; const vw = window.innerWidth; const calculated = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, vw / 25)); return Math.round(calculated); }, [isMobile, fontSize]); const connectWebSocket = useCallback(() => { const apiUrl = import.meta.env.VITE_API_BASE_URL || ""; const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, ""); const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`; console.log(`[Terminal WS] Connecting to ${wsUrl} (attempt ${reconnectAttemptsRef.current + 1}/${RECONNECT_ATTEMPTS + 1})`); const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { console.log(`[Terminal WS] Connected successfully`); setStatus("connected"); setError(null); reconnectAttemptsRef.current = 0; lastPingRef.current = Date.now(); // Send current terminal size immediately on connect if (termRef.current) { const { cols, rows } = termRef.current; // Only send if we have valid dimensions if (cols > 0 && rows > 0) { ws.send(JSON.stringify({ type: "resize", cols, rows })); } } // Start heartbeat check if (heartbeatCheckRef.current) { window.clearInterval(heartbeatCheckRef.current); } heartbeatCheckRef.current = window.setInterval(() => { const elapsed = Date.now() - lastPingRef.current; console.log(`[Terminal WS] Heartbeat check: lastPing=${elapsed}ms ago`); if (elapsed > 60000) { // No ping for 60 seconds, connection may be dead console.warn("[Terminal WS] Heartbeat timeout (>60s), closing connection"); ws.close(4000, "Heartbeat timeout"); } }, 30000); }; ws.onmessage = (event) => { if (!termRef.current) return; if (event.data instanceof Blob) { event.data.arrayBuffer().then((buffer) => { const data = new Uint8Array(buffer); termRef.current?.write(data); }); } else if (typeof event.data === "string") { try { const msg = JSON.parse(event.data); if (msg.type === "status") { if (msg.status === "connected") { setStatus("connected"); setError(null); // Clear terminal and refit after reset/reconnect if (termRef.current) { termRef.current.clear(); requestAnimationFrame(() => { if (fitAddonRef.current && termRef.current) { fitAddonRef.current.fit(); const { cols, rows } = termRef.current; const currentWs = wsRef.current; if (currentWs?.readyState === WebSocket.OPEN) { currentWs.send(JSON.stringify({ type: "resize", cols, rows })); } } }); } } else if (msg.status === "resetting") { setStatus("resetting"); } } else if (msg.type === "ping") { // Respond with pong and update last ping time lastPingRef.current = Date.now(); console.log(`[Terminal WS] Received ping, sending pong`); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "pong" })); } } } catch { termRef.current?.write(event.data); } } }; ws.onclose = (event) => { console.log(`[Terminal WS] Connection closed: code=${event.code}, reason="${event.reason}", wasClean=${event.wasClean}, attempts=${reconnectAttemptsRef.current}`); setStatus("disconnected"); // Clean up heartbeat check if (heartbeatCheckRef.current) { window.clearInterval(heartbeatCheckRef.current); heartbeatCheckRef.current = null; } if (event.code !== 1000 && event.code !== 4000) { setError(`Connection closed (code: ${event.code})`); // Attempt reconnection if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) { reconnectAttemptsRef.current++; const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1); console.log(`[Terminal WS] Will retry in ${delay}ms (attempt ${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})`); setTimeout(() => { if (isUnmountingRef.current) { console.log(`[Terminal WS] Component unmounting, skipping reconnect`); return; } if (document.visibilityState !== "hidden") { connectWebSocket(); } else { console.log(`[Terminal WS] Tab hidden, skipping reconnect`); } }, delay); } else { console.log(`[Terminal WS] Max reconnection attempts (${RECONNECT_ATTEMPTS}) reached`); } } else if (event.code === 4000) { // Server closed old connection for concurrent connection - don't reconnect // The new connection is already established console.log(`[Terminal WS] Server closed old connection (concurrent/heartbeat)`); } }; ws.onerror = (error) => { console.error(`[Terminal WS] Error event fired`, error); setStatus("error"); setError("WebSocket error"); }; return ws; }, [instanceId]); useEffect(() => { if (!terminalRef.current) return; // Initialize terminal const currentFontSize = calculateFontSize(); const term = new Terminal({ cursorBlink: true, fontSize: currentFontSize, fontFamily: 'Menlo, Monaco, "Courier New", monospace', theme: { background: "#1e1e1e", foreground: "#d4d4d4", cursor: "#d4d4d4", selectionBackground: "#264f78", black: "#000000", red: "#cd3131", green: "#0dbc79", yellow: "#e5e510", blue: "#2472c8", magenta: "#bc3fbc", cyan: "#11a8cd", white: "#e5e5e5", brightBlack: "#666666", brightRed: "#f14c4c", brightGreen: "#23d18b", brightYellow: "#f5f543", brightBlue: "#3b8eea", brightMagenta: "#d670d6", brightCyan: "#29b8db", brightWhite: "#e5e5e5", }, }); termRef.current = term; const fitAddon = new FitAddon(); fitAddonRef.current = fitAddon; term.loadAddon(fitAddon); term.loadAddon(new WebLinksAddon()); const container = terminalRef.current; // Define fitTerminal before connectWebSocket so it's available in onmessage const fitTerminal = () => { if (!fitAddonRef.current || !termRef.current) return; const oldCols = termRef.current.cols; const oldRows = termRef.current.rows; try { fitAddonRef.current.fit(); } catch { // Ignore fit errors during initialization return; } const { cols, rows } = termRef.current; console.log(`[Terminal] fit() result: ${cols}x${rows} (was ${oldCols}x${oldRows})`); // Force refresh if dimensions are valid if (cols > 0 && rows > 0) { try { termRef.current.refresh(0, rows - 1); } catch { // Ignore refresh errors } } const currentWs = wsRef.current; if (currentWs?.readyState === WebSocket.OPEN && cols > 0 && rows > 0) { currentWs.send(JSON.stringify({ type: "resize", cols, rows })); } }; // Open xterm first (must happen before fit) term.open(container); const ws = connectWebSocket(); // Initial fit after layout settles (terminal must be opened first) let fitAttempts = 0; const doInitialFit = () => { if (!container.isConnected) return; fitAttempts++; // Ensure container has dimensions before fitting if (container.clientWidth > 0 && container.clientHeight > 0) { console.log(`[Terminal] Container ready: ${container.clientWidth}x${container.clientHeight} (attempt ${fitAttempts})`); fitTerminal(); } else if (fitAttempts < 50) { // Container not ready yet, try again (max 50 attempts ~ 1s) console.log(`[Terminal] Container not ready: ${container.clientWidth}x${container.clientHeight} (attempt ${fitAttempts})`); requestAnimationFrame(doInitialFit); } else { console.warn(`[Terminal] Container never got dimensions after ${fitAttempts} attempts`); } }; requestAnimationFrame(doInitialFit); // Refit after font load (metrics may change) document.fonts.ready.then(() => { requestAnimationFrame(() => fitTerminal()); }); // Handle terminal input term.onData((data) => { const currentWs = wsRef.current; if (currentWs?.readyState !== WebSocket.OPEN) return; // Apply active modifier to single-character input const modifier = activeModifierRef.current; if (modifier && data.length === 1) { const modified = applyModifierToChar(data, modifier); if (modified) { currentWs.send(modified); onModifierChange?.(null); return; } } currentWs.send(data); }); // Handle container resize with ResizeObserver for accurate dimension tracking let resizeTimeout: ReturnType; let lastWidth = 0; let lastHeight = 0; const resizeObserver = new ResizeObserver((entries) => { const entry = entries[0]; if (!entry) return; const { width, height } = entry.contentRect; // Only trigger if dimensions actually changed if (width === lastWidth && height === lastHeight) return; lastWidth = width; lastHeight = height; clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { requestAnimationFrame(() => { if (!container.isConnected) return; fitTerminal(); }); }, 50); }); resizeObserver.observe(container); // Window resize fallback (for viewport changes that don't affect container dimensions) let windowResizeTimeout: ReturnType; const handleWindowResize = () => { clearTimeout(windowResizeTimeout); windowResizeTimeout = setTimeout(() => { requestAnimationFrame(() => fitTerminal()); }, 250); }; window.addEventListener("resize", handleWindowResize); // Refit after mobile header auto-hides (3s delay + 0.3s transition) const headerHideTimeout = setTimeout(() => { fitTerminal(); }, 4000); // Notify parent about terminal readiness if (onTerminalReadyRef.current) { const sendData = (data: string) => { const currentWs = wsRef.current; if (currentWs?.readyState === WebSocket.OPEN) { currentWs.send(data); } }; const focusInput = () => { termRef.current?.focus(); }; const changeFontSize = (delta: number) => { handleFontSizeChange(delta); }; onTerminalReadyRef.current(sendData, status, focusInput, changeFontSize); } // Visibility API for reconnection const handleVisibilityChange = () => { console.log(`[Terminal WS] Visibility changed to: ${document.visibilityState}, wsState=${ws?.readyState}`); if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN) { console.log(`[Terminal WS] Tab visible, resetting reconnect attempts and reconnecting`); reconnectAttemptsRef.current = 0; connectWebSocket(); } }; document.addEventListener("visibilitychange", handleVisibilityChange); return () => { isUnmountingRef.current = true; clearTimeout(resizeTimeout); clearTimeout(windowResizeTimeout); clearTimeout(headerHideTimeout); resizeObserver.disconnect(); window.removeEventListener("resize", handleWindowResize); document.removeEventListener("visibilitychange", handleVisibilityChange); if (ws) { ws.close(1000, "Component unmounting"); } if (heartbeatCheckRef.current) { window.clearInterval(heartbeatCheckRef.current); heartbeatCheckRef.current = null; } term.dispose(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [instanceId, connectWebSocket]); // Update parent about status changes useEffect(() => { if (onTerminalReady && termRef.current) { const sendData = (data: string) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(data); } }; const focusInput = () => { termRef.current?.focus(); }; const changeFontSize = (delta: number) => { handleFontSizeChange(delta); }; onTerminalReady(sendData, status, focusInput, changeFontSize); } }, [status, onTerminalReady]); const handleFontSizeChange = (delta: number) => { const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize + delta)); setFontSize(newSize); localStorage.setItem(FONT_SIZE_KEY, newSize.toString()); if (termRef.current && fitAddonRef.current) { termRef.current.options.fontSize = newSize; requestAnimationFrame(() => { if (termRef.current && fitAddonRef.current) { try { fitAddonRef.current.fit(); const { cols, rows } = termRef.current; if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send( JSON.stringify({ type: "resize", cols, rows, }) ); } } catch { // Ignore fit errors during re-initialization } } }); } }; const handleCopy = async () => { if (!termRef.current) return; const selection = termRef.current.getSelection(); if (selection) { try { await navigator.clipboard.writeText(selection); } catch { // Fallback for older browsers const textarea = document.createElement("textarea"); textarea.value = selection; document.body.appendChild(textarea); textarea.select(); document.execCommand("copy"); document.body.removeChild(textarea); } } }; const handlePaste = async () => { try { const text = await navigator.clipboard.readText(); if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(text); } } catch { // Clipboard API not available } }; // Focus terminal on mobile to keep keyboard open const handleTerminalClick = () => { if (isMobile && termRef.current) { termRef.current.focus(); } }; return (
{status === "resetting" ? "Resetting..." : reconnectAttemptsRef.current > 0 && status !== "connected" ? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...` : status}
{isMobile && ( <> )}
{onClose && ( )}
{showResetConfirm && (

Reset terminal? This will kill the current shell session and start fresh.

)} {error && (
{error} {status === "error" && ( )}
)}
{isMobile && ( )}
); };