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?: ( sessionId: string | undefined, 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; const BRACKETED_PASTE_ENABLE_SEQUENCE = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x30, 0x34, 0x68]; const BRACKETED_PASTE_DISABLE_SEQUENCE = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x30, 0x34, 0x6c]; const BRACKETED_PASTE_CONTROL_TAIL_LENGTH = BRACKETED_PASTE_ENABLE_SEQUENCE.length - 1; export function getTerminalScrollbackLimit(isMobile: boolean): number { return isMobile ? 10_000 : 0; } export function isCurrentWebSocket( current: WebSocket | null, candidate: WebSocket, ): boolean { return current === candidate; } export function shouldRetryWebSocketClose(code: number, reason: string): boolean { return code !== 1000 && !(code === 4000 && reason === "New connection established"); } export function shouldShowTerminalCopyMenu(selection: string): boolean { return selection.length > 0; } function copyTextWithFallback(text: string): void { const textarea = document.createElement("textarea"); textarea.value = text; textarea.setAttribute("readonly", ""); textarea.style.position = "fixed"; textarea.style.opacity = "0"; document.body.appendChild(textarea); textarea.select(); document.execCommand("copy"); textarea.remove(); } function matchesByteSequence( data: Uint8Array, start: number, sequence: readonly number[], ): boolean { return sequence.every((byte, index) => data[start + index] === byte); } 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 bracketedPasteEnabledRef = useRef(false); const pasteTextRef = useRef<(text: string) => void>(() => {}); const reconnectAttemptsRef = useRef(0); const reconnectTimerRef = useRef(null); 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 [copyMenu, setCopyMenu] = useState<{ x: number; y: number; text: string; } | null>(null); 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 clearReconnectTimer = useCallback(() => { if (reconnectTimerRef.current !== null) { window.clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null; } }, []); const connectWebSocket = useCallback(() => { const currentWs = wsRef.current; if ( currentWs?.readyState === WebSocket.CONNECTING || currentWs?.readyState === WebSocket.OPEN ) { return currentWs; } clearReconnectTimer(); 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); ws.binaryType = "arraybuffer"; wsRef.current = ws; let bracketedPasteControlTail = new Uint8Array(0); const updateBracketedPasteMode = (data: Uint8Array) => { const combined = new Uint8Array( bracketedPasteControlTail.length + data.length, ); combined.set(bracketedPasteControlTail); combined.set(data, bracketedPasteControlTail.length); for (let index = 0; index < combined.length; index++) { if ( index + BRACKETED_PASTE_ENABLE_SEQUENCE.length <= combined.length && matchesByteSequence( combined, index, BRACKETED_PASTE_ENABLE_SEQUENCE, ) ) { bracketedPasteEnabledRef.current = true; } else if ( index + BRACKETED_PASTE_DISABLE_SEQUENCE.length <= combined.length && matchesByteSequence( combined, index, BRACKETED_PASTE_DISABLE_SEQUENCE, ) ) { bracketedPasteEnabledRef.current = false; } } bracketedPasteControlTail = combined.slice( Math.max(0, combined.length - BRACKETED_PASTE_CONTROL_TAIL_LENGTH), ); }; ws.onopen = () => { if (!isCurrentWebSocket(wsRef.current, ws)) { ws.close(1000, "Superseded connection"); return; } 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); }; // Flow control: accumulate processed bytes and send ack let ackAccumulator = 0; const ACK_THRESHOLD = 4096; let ackTimeout: ReturnType | null = null; const flushAck = () => { if (ackAccumulator > 0 && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "ack", chars: ackAccumulator })); ackAccumulator = 0; } }; ws.onmessage = (event) => { if (!isCurrentWebSocket(wsRef.current, ws) || !termRef.current) return; if (event.data instanceof ArrayBuffer) { const data = new Uint8Array(event.data); updateBracketedPasteMode(data); termRef.current.write(data); // Flow control: accumulate processed bytes ackAccumulator += data.length; if (ackAccumulator >= ACK_THRESHOLD) { flushAck(); } else if (!ackTimeout) { ackTimeout = setTimeout(() => { ackTimeout = null; flushAck(); }, 100); } } 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); // Refit after reset/reconnect but do NOT clear the // terminal: the server keeps the session buffer, and // clearing would erase it when the user switches back // to an already-connected session. if (termRef.current) { 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) => { if (!isCurrentWebSocket(wsRef.current, ws)) return; wsRef.current = null; if (ackTimeout) window.clearTimeout(ackTimeout); // 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 (!shouldRetryWebSocketClose(event.code, event.reason)) { setStatus("disconnected"); 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); clearReconnectTimer(); reconnectTimerRef.current = window.setTimeout(() => { reconnectTimerRef.current = null; if ( !isUnmountingRef.current && document.visibilityState !== "hidden" && wsRef.current === null ) { connectWebSocket(); } }, delay); } }; ws.onerror = () => { if (!isCurrentWebSocket(wsRef.current, ws)) return; setStatus("error"); setError("WebSocket error"); }; return ws; }, [clearReconnectTimer, instanceId, sessionId]); useEffect(() => { if (!terminalRef.current) return; isUnmountingRef.current = false; permanentErrorRef.current = null; // 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, // Desktop tools repaint in place, so retaining their normal buffer // creates stale frames that native wheel scrolling can revisit. Mobile // instead uses its custom touch handler to scroll normal-buffer output, // which requires retained history. scrollback: getTerminalScrollbackLimit(isMobile), ignoreBracketedPasteMode: false, fastScrollSensitivity: 0, scrollSensitivity: 0, smoothScrollDuration: 0, 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()); // NOTE: WebGL renderer disabled. // The WebGL addon causes black-on-black rendering artifacts with // tmux/vim reverse-video (inverse color) sequences on desktop. // Mobile already uses the DOM renderer (WebGL fails there), which // handles these color attributes correctly. The DOM renderer is // fast enough for typical terminal workloads. // See: xterm.js WebGL known issues with reverse video / minimumContrastRatio const container = terminalRef.current; // Define fitTerminal before connectWebSocket so it's available in onmessage let lastSentCols = 0; let lastSentRows = 0; const fitTerminal = () => { if (!fitAddonRef.current || !termRef.current) return; try { fitAddonRef.current.fit(); } catch { // Ignore fit errors during initialization return; } const { cols, rows } = termRef.current; // Only send resize when dimensions actually changed if ( cols > 0 && rows > 0 && (cols !== lastSentCols || rows !== lastSentRows) ) { lastSentCols = cols; lastSentRows = rows; const currentWs = wsRef.current; if (currentWs?.readyState === WebSocket.OPEN) { currentWs.send(JSON.stringify({ type: "resize", cols, rows })); } } }; // Open xterm first (must happen before fit) term.open(container); term.focus(); connectWebSocket(); pasteTextRef.current = (text: string) => { const currentWs = wsRef.current; if (currentWs?.readyState !== WebSocket.OPEN) return; if (bracketedPasteEnabledRef.current) { const normalizedText = text.replace(/\r\n|\r|\n/g, "\r"); currentWs.send(`\x1b[200~${normalizedText}\x1b[201~`); return; } term.paste(text); }; const handleBrowserCopy = (event: ClipboardEvent) => { const selection = term.getSelection(); if (!selection) return; event.preventDefault(); event.clipboardData?.setData("text/plain", selection); }; const handleTerminalContextMenu = (event: MouseEvent) => { const selection = term.getSelection(); if (!shouldShowTerminalCopyMenu(selection)) { setCopyMenu(null); return; } event.preventDefault(); setCopyMenu({ x: event.clientX, y: event.clientY, text: selection }); }; const handleBrowserPaste = (event: ClipboardEvent) => { const text = event.clipboardData?.getData("text/plain"); if (text === undefined || wsRef.current?.readyState !== WebSocket.OPEN) { return; } event.preventDefault(); event.stopImmediatePropagation(); pasteTextRef.current(text); }; container.addEventListener("copy", handleBrowserCopy, true); container.addEventListener("contextmenu", handleTerminalContextMenu, true); container.addEventListener("paste", handleBrowserPaste, true); // Mobile touch scroll. // In normal mode xterm.js has a scrollable viewport; in alternate // screen (tmux/vim) there is no scrollback and the only way to // scroll is to send mouse-wheel protocol sequences to the // application. We detect the active buffer by reference equality // (term.buffer.active === term.buffer.alternate) because the // `type` string can be unreliable in some xterm.js versions. let touchCleanup: (() => void) | undefined; if (isMobile) { let startY = 0; let startX = 0; let isScrolling = false; let scrollPending = 0; const WHEEL_DISTANCE = 12; const flushScroll = (force = false) => { if (scrollPending === 0) return; if (!force && Math.abs(scrollPending) < WHEEL_DISTANCE) return; const direction = Math.sign(scrollPending); const steps = Math.max( 1, Math.floor(Math.abs(scrollPending) / WHEEL_DISTANCE), ); const ws = wsRef.current; if (ws?.readyState === WebSocket.OPEN && termRef.current) { const buf = termRef.current.buffer.active; const col = buf.cursorX + 1; const row = buf.cursorY + 1; const btn = direction > 0 ? 64 : 65; for (let i = 0; i < steps; i++) { ws.send(`\x1b[<${btn};${col};${row}M`); } } scrollPending = 0; }; const isAlternateScreen = () => { const term = termRef.current; if (!term) return false; return term.buffer.active === term.buffer.alternate; }; const onTouchStart = (e: TouchEvent) => { if (e.touches.length === 1) { startY = e.touches[0].clientY; startX = e.touches[0].clientX; isScrolling = false; scrollPending = 0; } }; const onTouchMove = (e: TouchEvent) => { if (e.touches.length !== 1) return; const touch = e.touches[0]; const deltaY = startY - touch.clientY; const deltaX = Math.abs(startX - touch.clientX); // Decide early whether this is a vertical scroll gesture. if (!isScrolling) { if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 2) { isScrolling = true; } } if (!isScrolling) return; // Take over the gesture so the page/toolbar never scrolls. e.preventDefault(); if (isAlternateScreen()) { // Alternate screen (tmux/vim): accumulate the swipe and // send SGR 1006 mouse-wheel events in steps. scrollPending += deltaY; flushScroll(); } else if (termRef.current) { // Normal buffer: let xterm.js scroll its own viewport by // the number of lines corresponding to the swipe distance. const lineHeight = termRef.current.options.fontSize != null ? (termRef.current.options.fontSize as number) * 1.2 : 10; const lines = Math.round(deltaY / lineHeight); if (lines !== 0) { termRef.current.scrollLines(lines); } } startY = touch.clientY; }; const onTouchEnd = () => { if (isScrolling) { flushScroll(true); } isScrolling = false; }; container.addEventListener("touchstart", onTouchStart, { passive: false, capture: true, }); container.addEventListener("touchmove", onTouchMove, { passive: false, capture: true, }); container.addEventListener("touchend", onTouchEnd, { capture: true, }); touchCleanup = () => { container.removeEventListener("touchstart", onTouchStart, { capture: true, }); container.removeEventListener("touchmove", onTouchMove, { capture: true, }); container.removeEventListener("touchend", onTouchEnd, { capture: true, }); }; } // 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( sessionId, sendData, status, focusInput, changeFontSize, ); } // Visibility API for reconnection const handleVisibilityChange = () => { const currentWs = wsRef.current; if ( document.visibilityState === "visible" && !permanentErrorRef.current && (currentWs === null || currentWs.readyState === WebSocket.CLOSED) ) { reconnectAttemptsRef.current = 0; connectWebSocket(); } }; document.addEventListener("visibilitychange", handleVisibilityChange); return () => { isUnmountingRef.current = true; clearReconnectTimer(); clearTimeout(resizeTimeout); clearTimeout(windowResizeTimeout); clearTimeout(headerHideTimeout); resizeObserver.disconnect(); window.removeEventListener("resize", handleWindowResize); document.removeEventListener( "visibilitychange", handleVisibilityChange, ); if (touchCleanup) touchCleanup(); container.removeEventListener("copy", handleBrowserCopy, true); container.removeEventListener( "contextmenu", handleTerminalContextMenu, true, ); container.removeEventListener("paste", handleBrowserPaste, true); pasteTextRef.current = () => {}; bracketedPasteEnabledRef.current = false; const currentWs = wsRef.current; wsRef.current = null; if (currentWs) { currentWs.close(1000, "Component unmounting"); } if (heartbeatCheckRef.current) { window.clearInterval(heartbeatCheckRef.current); heartbeatCheckRef.current = null; } try { term.dispose(); } catch { // Ignore disposal errors from partially torn-down terminal } }; }, [instanceId, connectWebSocket, isMobile]); 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( sessionId, sendData, status, focusInput, changeFontSize, ); } }, [sessionId, 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 { pasteTextRef.current(await navigator.clipboard.readText()); } catch { // Clipboard API not available } }; // Focus terminal on mobile to keep keyboard open const handleTerminalClick = () => { setCopyMenu(null); 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.

)} {copyMenu && ( )} {error && (
{error} {status === "error" && ( )}
)}
{isMobile && ( )}
); }, ); TerminalComponent.displayName = "TerminalComponent";