import React, { useCallback, useEffect, useRef, useState } from "react"; import { Terminal } from "xterm"; import { FitAddon } from "xterm-addon-fit"; import { SerializeAddon } from "xterm-addon-serialize"; import { WebLinksAddon } from "xterm-addon-web-links"; import "xterm/css/xterm.css"; import { useTerminalConnection } from "../hooks/use-terminal-connection"; import type { ServerControlMessage, TerminalConnectionState, } from "../types/terminal"; import styles from "./features/terminal/TerminalComponent.module.css"; interface TerminalProps { instanceId: string; onClose?: () => void; } const STATUS_DOT_COLORS: Record = { connecting: "var(--warning)", connected: "var(--success)", reconnecting: "var(--warning)", disconnected: "var(--muted)", }; function getStatusText(state: TerminalConnectionState): string { switch (state.status) { case "connecting": return "Connecting..."; case "connected": { if (state.latency !== null && state.latency >= 100) { return `Slow (${state.latency}ms)`; } return "Connected"; } case "reconnecting": return `Reconnecting${state.attempt > 0 ? ` (${state.attempt})` : ""}`; case "disconnected": return state.error || "Disconnected"; } } export const TerminalComponent: React.FC = ({ instanceId, onClose, }) => { const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const serializeAddonRef = useRef(null); const resizeObserverRef = useRef(null); const [sessionEnded, setSessionEnded] = useState<{ reason: string; message: string; } | null>(null); // Determine dark mode from document theme const isDarkMode = document.documentElement.getAttribute("data-theme") === "dark" || (document.documentElement.getAttribute("data-theme") === null && window.matchMedia("(prefers-color-scheme: dark)").matches); const handleData = useCallback((data: Uint8Array) => { // Data is already written by onLocalEcho or deduplication // This callback is mainly for external consumers void data; }, []); const handleLocalEcho = useCallback((data: string) => { xtermRef.current?.write(data); }, []); const serializeFn = useCallback((): string | null => { return serializeAddonRef.current?.serialize() ?? null; }, []); const handleRestoreScrollback = useCallback((content: string) => { xtermRef.current?.write(content); xtermRef.current?.write("\r\n\x1b[90m--- Reconnected ---\x1b[0m\r\n"); }, []); const handleControl = useCallback((msg: ServerControlMessage) => { if (msg.type === "session_ended") { const messages: Record = { process_exit: "The container process has exited.", container_stop: "The container was stopped.", timeout: "The session timed out due to inactivity.", }; setSessionEnded({ reason: msg.reason, message: messages[msg.reason] || "The session has ended.", }); } }, []); const { state, sendInput, sendResize, reconnect } = useTerminalConnection({ instanceId, onData: handleData, onControl: handleControl, onLocalEcho: handleLocalEcho, serializeFn, onRestoreScrollback: handleRestoreScrollback, }); // Initialize xterm useEffect(() => { if (!terminalRef.current) return; const term = new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: 'Menlo, Monaco, "Courier New", monospace', theme: isDarkMode ? { 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", } : { background: "#fafafa", foreground: "#333333", cursor: "#333333", selectionBackground: "#b4d7ff", 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", }, }); const fitAddon = new FitAddon(); const serializeAddon = new SerializeAddon(); term.loadAddon(fitAddon); term.loadAddon(serializeAddon); term.loadAddon(new WebLinksAddon()); term.open(terminalRef.current); fitAddon.fit(); xtermRef.current = term; fitAddonRef.current = fitAddon; serializeAddonRef.current = serializeAddon; // Handle terminal input const disposable = term.onData((data) => { sendInput(data); }); // Resize observer for container-level resize detection let resizeTimeout: ReturnType | null = null; let lastWidth = 0; let lastHeight = 0; const resizeObserver = new ResizeObserver((entries) => { if (resizeTimeout) { clearTimeout(resizeTimeout); } const entry = entries[0]; if (!entry) return; const { width, height } = entry.contentRect; resizeTimeout = setTimeout(() => { resizeTimeout = null; // Guard against internal xterm DOM changes that don't affect container size if ( Math.abs(width - lastWidth) < 1 && Math.abs(height - lastHeight) < 1 ) { return; } lastWidth = width; lastHeight = height; const prevCols = term.cols; const prevRows = term.rows; fitAddon.fit(); if (term.cols !== prevCols || term.rows !== prevRows) { sendResize(term.cols, term.rows); } }, 100); }); resizeObserver.observe(terminalRef.current); resizeObserverRef.current = resizeObserver; return () => { if (resizeTimeout) { clearTimeout(resizeTimeout); } disposable.dispose(); resizeObserver.disconnect(); term.dispose(); xtermRef.current = null; fitAddonRef.current = null; serializeAddonRef.current = null; }; }, [instanceId, isDarkMode, sendInput, sendResize]); // Send initial terminal size once connected (and on reconnect) useEffect(() => { if (state.status === "connected" && xtermRef.current) { const { cols, rows } = xtermRef.current; sendResize(cols, rows); } }, [state.status, sendResize]); return (
{getStatusText(state)}
{state.status === "disconnected" && ( )} {onClose && ( )}
{sessionEnded && (

Session Ended

{sessionEnded.message}

{onClose && ( )}
)} {state.status === "reconnecting" && (
{state.error}
)}
); };