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 = 10; const MAX_FONT_SIZE = 24; 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 ? 16 : 14; const stored = localStorage.getItem(FONT_SIZE_KEY); return stored ? parseInt(stored, 10) : isMobile ? 16 : 14; }); const lastPingRef = useRef(0); const heartbeatCheckRef = useRef(null); 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`; const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { 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; // Send resize immediately on connect 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; if (elapsed > 60000) { // No ping for 60 seconds, connection may be dead console.warn("Terminal heartbeat timeout, reconnecting..."); 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); } else if (msg.status === "resetting") { setStatus("resetting"); } } else if (msg.type === "ping") { // Respond with pong and update last ping time lastPingRef.current = Date.now(); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "pong" })); } } } catch { termRef.current?.write(event.data); } } }; ws.onclose = (event) => { 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); setTimeout(() => { if (document.visibilityState !== "hidden") { connectWebSocket(); } }, delay); } } else if (event.code === 4000) { // Server closed old connection for concurrent connection - don't reconnect // The new connection is already established } }; ws.onerror = () => { 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; let ws: WebSocket; // Open xterm immediately term.open(container); ws = connectWebSocket(); // Fit terminal and notify backend const fitTerminal = () => { if (!fitAddonRef.current || !termRef.current) return; const oldCols = termRef.current.cols; const oldRows = termRef.current.rows; // Force layout recalculation before fit if (container) { void container.offsetWidth; void container.offsetHeight; } fitAddonRef.current.fit(); const { cols, rows } = termRef.current; if (cols !== oldCols || rows !== oldRows) { termRef.current.refresh(0, rows - 1); } if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "resize", cols, rows })); } }; // Initial fit after layout settles requestAnimationFrame(() => { requestAnimationFrame(() => { fitTerminal(); }); }); // Refit after font load (metrics may change) document.fonts.ready.then(() => { requestAnimationFrame(() => fitTerminal()); }); // Handle terminal input term.onData((data) => { if (ws.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) { ws.send(modified); onModifierChange?.(null); return; } } ws.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); // 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) => { if (ws.readyState === WebSocket.OPEN) { ws.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 = () => { if (document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN) { reconnectAttemptsRef.current = 0; connectWebSocket(); } }; document.addEventListener("visibilitychange", handleVisibilityChange); return () => { clearTimeout(resizeTimeout); clearTimeout(headerHideTimeout); resizeObserver.disconnect(); document.removeEventListener("visibilitychange", handleVisibilityChange); if (ws) { ws.close(); } 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 && ( )}
); };