import { useCallback, useEffect, useMemo, useRef, useState, type RefObject, } from "react"; import { ClipboardAddon } from "@xterm/addon-clipboard"; import { FitAddon } from "@xterm/addon-fit"; import { ImageAddon } from "@xterm/addon-image"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { Terminal as XTerm } from "@xterm/xterm"; import { toast } from "sonner"; import { MAX_TERMINAL_FONT_SIZE, MIN_TERMINAL_FONT_SIZE, } from "@/pages/terminal/TerminalAccessoryBar"; import { visibleTerminalText } from "@/pages/terminal/sessionUtils"; import { loadTerminalFontFamily } from "@/terminal/terminalFonts"; const DEFAULT_TERMINAL_FONT_SIZE = 14; // Stable behavioral surface shared with session transport and page controls. export type TerminalPort = { clear: () => void; copy: () => Promise; dimensions: () => { rows: number; cols: number } | null; forgetTitle: (terminalId: string) => void; fit: () => void; focus: () => void; blurInput: () => void; onInput: (handler: (data: string) => void) => { dispose: () => void }; onResize: (handler: () => void) => { dispose: () => void }; paste: (write: (data: string) => void) => Promise; reset: () => void; scrollPages: (pages: number) => void; sendResize: (send: (message: string) => void, force?: boolean) => void; changeFontSize: (delta: number) => void; setActiveTerminalId: (terminalId: string | undefined) => void; write: (data: Uint8Array) => void; writeLine: (line: string) => void; ready: boolean; }; type Options = { pageRef: RefObject; hostRef: RefObject; }; export function useXterm({ pageRef, hostRef }: Options) { const terminalRef = useRef(null); const fitRef = useRef(null); const activeTerminalIdRef = useRef(undefined); const lastSizeRef = useRef({ rows: 0, cols: 0 }); const resizeListenersRef = useRef(new Set<() => void>()); const [fontFamily, setFontFamily] = useState(); const [ready, setReady] = useState(false); const [fontSize, setFontSize] = useState(DEFAULT_TERMINAL_FONT_SIZE); const [sessionTitles, setSessionTitles] = useState>( {}, ); const setActiveTerminalId = useCallback((terminalId: string | undefined) => { activeTerminalIdRef.current = terminalId; }, []); useEffect(() => { let cancelled = false; void loadTerminalFontFamily().then((loadedFontFamily) => { if (!cancelled) setFontFamily(loadedFontFamily); }); return () => { cancelled = true; }; }, []); useEffect(() => { const viewport = window.visualViewport; const updateViewportHeight = () => { const height = Math.round(viewport?.height ?? window.innerHeight); pageRef.current?.style.setProperty( "--terminal-visual-viewport-height", `${height}px`, ); }; updateViewportHeight(); viewport?.addEventListener("resize", updateViewportHeight); viewport?.addEventListener("scroll", updateViewportHeight); window.addEventListener("resize", updateViewportHeight); return () => { viewport?.removeEventListener("resize", updateViewportHeight); viewport?.removeEventListener("scroll", updateViewportHeight); window.removeEventListener("resize", updateViewportHeight); }; }, [pageRef]); const sendResize = useCallback( (send: (message: string) => void, force = false) => { const terminal = terminalRef.current; if (!terminal) return; if ( !force && lastSizeRef.current.rows === terminal.rows && lastSizeRef.current.cols === terminal.cols ) { return; } lastSizeRef.current = { rows: terminal.rows, cols: terminal.cols }; send( JSON.stringify({ type: "resize", rows: terminal.rows, cols: terminal.cols, }), ); }, [], ); const fit = useCallback(() => fitRef.current?.fit(), []); const focus = useCallback(() => terminalRef.current?.focus(), []); const blurInput = useCallback( () => terminalRef.current?.textarea?.blur(), [], ); const reset = useCallback(() => terminalRef.current?.reset(), []); const clear = useCallback(() => terminalRef.current?.clear(), []); const write = useCallback( (data: Uint8Array) => terminalRef.current?.write(data), [], ); const writeLine = useCallback( (line: string) => terminalRef.current?.writeln(line), [], ); const dimensions = useCallback(() => { const terminal = terminalRef.current; return terminal ? { rows: terminal.rows, cols: terminal.cols } : null; }, []); const forgetTitle = useCallback((terminalId: string) => { setSessionTitles((current) => { if (!(terminalId in current)) return current; const next = { ...current }; delete next[terminalId]; return next; }); }, []); const onInput = useCallback( (handler: (data: string) => void) => terminalRef.current?.onData(handler) ?? { dispose: () => {} }, [], ); const onResize = useCallback((handler: () => void) => { resizeListenersRef.current.add(handler); return { dispose: () => resizeListenersRef.current.delete(handler) }; }, []); const scrollPages = useCallback( (pages: number) => terminalRef.current?.scrollPages(pages), [], ); const copy = useCallback(async () => { const terminal = terminalRef.current; if (!terminal) return; const text = terminal.hasSelection() ? terminal.getSelection() : visibleTerminalText(terminal); if (!text) { toast.info("Nothing to copy"); return; } try { await navigator.clipboard.writeText(text); toast.success( terminal.hasSelection() ? "Selection copied" : "Visible screen copied", ); } catch (error) { toast.error("Clipboard access was denied", { description: String(error), }); } }, []); const paste = useCallback(async (writeData: (data: string) => void) => { try { const text = await navigator.clipboard.readText(); if (text) writeData(text); terminalRef.current?.focus(); } catch (error) { toast.error("Clipboard access was denied", { description: String(error), }); } }, []); const changeFontSize = useCallback((delta: number) => { const terminal = terminalRef.current; if (!terminal) return; const current = terminal.options.fontSize ?? DEFAULT_TERMINAL_FONT_SIZE; const next = Math.min( MAX_TERMINAL_FONT_SIZE, Math.max(MIN_TERMINAL_FONT_SIZE, current + delta), ); if (next === current) return; terminal.options.fontSize = next; setFontSize(next); window.requestAnimationFrame(() => { fitRef.current?.fit(); resizeListenersRef.current.forEach((handler) => handler()); }); }, []); // Wait for the font before creating xterm; this effect owns the instance and its observers. useEffect(() => { if (!hostRef.current || !fontFamily) return; const terminal = new XTerm({ cursorBlink: true, convertEol: false, fontFamily, fontSize: DEFAULT_TERMINAL_FONT_SIZE, lineHeight: 1.1, scrollbar: { width: window.matchMedia("(pointer: coarse)").matches ? 22 : 16, }, scrollback: 10_000, theme: { background: "#0b1117", foreground: "#e5e7eb", cursor: "#6ee7a8", scrollbarSliderBackground: "#52617099", scrollbarSliderHoverBackground: "#6b7c8dcc", scrollbarSliderActiveBackground: "#8294a6", }, allowProposedApi: true, }); terminal.loadAddon(new Unicode11Addon()); terminal.unicode.activeVersion = "11"; terminal.loadAddon(new UnicodeGraphemesAddon()); terminal.loadAddon(new WebLinksAddon()); terminal.loadAddon(new ImageAddon({})); const fitAddon = new FitAddon(); terminal.loadAddon(new ClipboardAddon()); terminal.loadAddon(fitAddon); terminal.open(hostRef.current); terminal.attachCustomKeyEventHandler((event) => { const copiesTerminalSelection = event.type === "keydown" && event.ctrlKey && event.shiftKey && event.code === "KeyC"; if (!copiesTerminalSelection) return true; event.preventDefault(); event.stopPropagation(); if (terminal.hasSelection()) { if (!navigator.clipboard) { toast.error("Clipboard access requires HTTPS or localhost"); } else { void navigator.clipboard .writeText(terminal.getSelection()) .catch((error) => toast.error("Clipboard access was denied", { description: String(error), }), ); } } return false; }); const titleChange = terminal.onTitleChange((title) => { const terminalId = activeTerminalIdRef.current; if (!terminalId) return; const normalized = title.trim(); setSessionTitles((current) => { if (current[terminalId] === normalized) return current; if (!normalized) { const next = { ...current }; delete next[terminalId]; return next; } return { ...current, [terminalId]: normalized }; }); }); fitAddon.fit(); // Consumers subscribe only after this transition; the port methods stay stable. terminalRef.current = terminal; fitRef.current = fitAddon; setReady(true); let resizeFrame = 0; let lastHostWidth = 0; let lastHostHeight = 0; const resizeObserver = new ResizeObserver(([entry]) => { const width = Math.round(entry.contentRect.width); const height = Math.round(entry.contentRect.height); if (width === lastHostWidth && height === lastHostHeight) return; lastHostWidth = width; lastHostHeight = height; window.cancelAnimationFrame(resizeFrame); resizeFrame = window.requestAnimationFrame(() => { fitAddon.fit(); resizeListenersRef.current.forEach((handler) => handler()); }); }); resizeObserver.observe(hostRef.current); return () => { titleChange.dispose(); resizeObserver.disconnect(); resizeListenersRef.current.clear(); window.cancelAnimationFrame(resizeFrame); terminal.dispose(); terminalRef.current = null; fitRef.current = null; setReady(false); }; }, [fontFamily, hostRef]); const terminal = useMemo( () => ({ clear, copy, dimensions, forgetTitle, fit, focus, blurInput, onInput, onResize, paste, reset, scrollPages, sendResize, changeFontSize, setActiveTerminalId, write, writeLine, ready, }), [ clear, copy, dimensions, forgetTitle, fit, focus, blurInput, onInput, onResize, paste, reset, scrollPages, sendResize, changeFontSize, setActiveTerminalId, write, writeLine, ready, ], ); return { terminal, fontSize, sessionTitles }; }