import React, { useEffect, useImperativeHandle, 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"; export interface TerminalProps { instanceId: string; sessionId?: string; onClose?: () => void; isMobile?: boolean; showControls?: 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; } export interface TerminalRef { fit: () => void; focus: () => void; reset: () => void; } const FONT_SIZE_KEY = "terminal-font-size"; const MIN_FONT_SIZE = 4; const MAX_FONT_SIZE = 24; const RECONNECT_ATTEMPTS = 3; const RECONNECT_DELAY_BASE = 1000; export const TerminalComponent = React.forwardRef( ( { instanceId, sessionId, onClose, isMobile = false, showControls = true, activeModifier, onModifierChange, onTerminalReady, }, ref, ) => { 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 handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {}); 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 ? 8 : 8; const stored = localStorage.getItem(FONT_SIZE_KEY); if (stored) { const parsed = parseInt(stored, 10); return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, parsed)); } return isMobile ? 8 : 8; }); const lastPingRef = useRef(0); const heartbeatCheckRef = useRef(null); const isUnmountingRef = useRef(false); const permanentErrorRef = useRef(null); const calculateFontSize = useCallback(() => { return fontSize; }, [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 wsPath = sessionId ? `/ws/tool-instances/${instanceId}/terminal/${sessionId}` : `/ws/tool-instances/${instanceId}/terminal`; const wsUrl = `${wsProtocol}//${wsHost}${wsPath}`; // WebSocket connection established 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; // 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; if (elapsed > 60000) { // No ping for 60 seconds, connection may be dead 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(); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "pong" })); } } } catch { termRef.current?.write(event.data); } } }; ws.onclose = (event) => { // Clean up heartbeat check if (heartbeatCheckRef.current) { window.clearInterval(heartbeatCheckRef.current); heartbeatCheckRef.current = null; } // Permanent errors: do not retry if (event.code === 4001 || event.code === 4003 || event.code === 4004) { const reason = event.reason || `Instance error (code: ${event.code})`; setStatus("error"); setError(reason); permanentErrorRef.current = reason; return; } if (event.code === 1000) { setStatus("disconnected"); return; } if (event.code === 4000) { // Server closed old connection for concurrent connection - don't reconnect // The new connection is already established return; } // Transient errors: attempt reconnection setStatus("disconnected"); setError(`Connection closed (code: ${event.code})`); if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) { reconnectAttemptsRef.current++; const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1); setTimeout(() => { if (isUnmountingRef.current) { return; } if (document.visibilityState !== "hidden") { connectWebSocket(); } }, delay); } }; ws.onerror = () => { setStatus("error"); setError("WebSocket error"); }; return ws; }, [instanceId, sessionId]); useEffect(() => { if (!terminalRef.current) return; // Initialize terminal const currentFontSize = calculateFontSize(); const term = new Terminal({ cursorBlink: true, fontSize: currentFontSize, fontFamily: 'Menlo, Monaco, "Courier New", monospace', lineHeight: 1.2, letterSpacing: 0, allowTransparency: false, 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; try { fitAddonRef.current.fit(); } catch { // Ignore fit errors during initialization return; } const { cols, rows } = termRef.current; // 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); term.focus(); 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) { fitTerminal(); } else if (fitAttempts < 50) { // Container not ready yet, try again (max 50 attempts ~ 1s) requestAnimationFrame(doInitialFit); } }; 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) => { handleFontSizeChangeRef.current(delta); }; onTerminalReadyRef.current( sendData, status, focusInput, changeFontSize, ); } // Visibility API for reconnection const handleVisibilityChange = () => { if ( document.visibilityState === "visible" && ws && ws.readyState !== WebSocket.OPEN ) { if (permanentErrorRef.current) { return; } reconnectAttemptsRef.current = 0; connectWebSocket(); } }; document.addEventListener("visibilitychange", handleVisibilityChange); // Mobile touch scroll forwarding. // xterm.js intercepts touch events for selection, which blocks the // browser from scrolling the viewport. We listen on the container in // capture phase, decide early if the gesture is vertical, and then // preventDefault + stopPropagation so xterm.js never sees the event. // The scroll is forwarded to term.scrollLines(). let touchStartY = 0; let touchStartX = 0; let isVerticalScroll = false; let accumulatedDeltaY = 0; const handleTouchStart = (e: TouchEvent) => { if (e.touches.length === 1) { touchStartY = e.touches[0].clientY; touchStartX = e.touches[0].clientX; isVerticalScroll = false; accumulatedDeltaY = 0; } }; const handleTouchMove = (e: TouchEvent) => { if (e.touches.length !== 1 || !termRef.current) return; const touch = e.touches[0]; const deltaY = touchStartY - touch.clientY; const deltaX = Math.abs(touchStartX - touch.clientX); // Decide scroll direction on the first meaningful movement (3px) if (!isVerticalScroll) { if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 3) { isVerticalScroll = true; } } if (isVerticalScroll) { // preventDefault MUST be called on the first matching touchmove // or the browser compositor will already have committed to page // scroll before our JS runs. e.preventDefault(); e.stopPropagation(); accumulatedDeltaY += deltaY; touchStartY = touch.clientY; const lines = Math.round(accumulatedDeltaY / 24); if (lines !== 0) { // Negative = scroll up (show older buffer content) termRef.current.scrollLines(-lines); accumulatedDeltaY = 0; } } }; const handleTouchEnd = () => { isVerticalScroll = false; accumulatedDeltaY = 0; }; if (isMobile) { container.addEventListener("touchstart", handleTouchStart, { passive: true, capture: true, }); container.addEventListener("touchmove", handleTouchMove, { passive: false, capture: true, }); container.addEventListener("touchend", handleTouchEnd, { capture: true, }); } return () => { isUnmountingRef.current = true; clearTimeout(resizeTimeout); clearTimeout(windowResizeTimeout); clearTimeout(headerHideTimeout); resizeObserver.disconnect(); window.removeEventListener("resize", handleWindowResize); document.removeEventListener( "visibilitychange", handleVisibilityChange, ); if (isMobile) { container.removeEventListener("touchstart", handleTouchStart, { capture: true, }); container.removeEventListener("touchmove", handleTouchMove, { capture: true, }); container.removeEventListener("touchend", handleTouchEnd, { capture: true, }); } if (ws) { ws.close(1000, "Component unmounting"); } if (heartbeatCheckRef.current) { window.clearInterval(heartbeatCheckRef.current); heartbeatCheckRef.current = null; } term.dispose(); }; }, [instanceId, connectWebSocket]); useImperativeHandle(ref, () => ({ fit: () => { if (fitAddonRef.current && termRef.current) { try { fitAddonRef.current.fit(); const { cols, rows } = termRef.current; if ( wsRef.current?.readyState === WebSocket.OPEN && cols > 0 && rows > 0 ) { wsRef.current.send( JSON.stringify({ type: "resize", cols, rows }), ); } } catch { // Ignore fit errors } } }, focus: () => { termRef.current?.focus(); }, reset: () => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: "reset" })); } }, })); // 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) => { handleFontSizeChangeRef.current(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 } } }); } }; handleFontSizeChangeRef.current = handleFontSizeChange; 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 (
{showControls && (
{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 && ( )}
); }, ); TerminalComponent.displayName = "TerminalComponent";