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", 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" >("connecting"); const [error, setError] = useState(null); 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 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; }; 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" && msg.status === "connected") { setStatus("connected"); } } catch { termRef.current?.write(event.data); } } }; ws.onclose = (event) => { setStatus("disconnected"); if (event.code !== 1000) { 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); } } }; 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()); term.open(terminalRef.current); // Connect WebSocket const ws = connectWebSocket(); // Fit after layout settles - use rAF + timeout to ensure DOM is ready requestAnimationFrame(() => { requestAnimationFrame(() => { fitAddon.fit(); // Force a second fit after layout fully settles setTimeout(() => { fitAddon.fit(); const { cols, rows } = term; if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: "resize", cols, rows, }) ); } }, 500); // Fit after mobile header auto-hides (3s delay + 0.3s transition) setTimeout(() => { fitAddon.fit(); const { cols, rows } = term; if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: "resize", cols, rows, }) ); } }, 4000); }); }); // 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 resize with debounce let resizeTimeout: ReturnType; const handleResize = () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { fitAddon.fit(); const { cols, rows } = term; if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: "resize", cols, rows, }) ); } }, 250); }; window.addEventListener("resize", handleResize); // 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.readyState !== WebSocket.OPEN) { reconnectAttemptsRef.current = 0; connectWebSocket(); } }; document.addEventListener("visibilitychange", handleVisibilityChange); return () => { clearTimeout(resizeTimeout); window.removeEventListener("resize", handleResize); document.removeEventListener("visibilitychange", handleVisibilityChange); 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 (
{reconnectAttemptsRef.current > 0 && status !== "connected" ? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...` : status}
{isMobile && ( <> )}
{onClose && ( )}
{error && (
{error} {status === "error" && ( )}
)}
{isMobile && ( )}
); };