"use dom"; import { useEffect, useMemo, useRef } from "react"; import type { DOMProps } from "expo/dom"; import "@xterm/xterm/css/xterm.css"; import type { ITheme } from "@xterm/xterm"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime"; import { focusWithRetries } from "../utils/web-focus"; function buildXtermThemeKey(theme: ITheme): string { const values: Array = [ theme.background, theme.foreground, theme.cursor, theme.cursorAccent, theme.selectionBackground, theme.selectionForeground, theme.black, theme.red, theme.green, theme.yellow, theme.blue, theme.magenta, theme.cyan, theme.white, theme.brightBlack, theme.brightRed, theme.brightGreen, theme.brightYellow, theme.brightBlue, theme.brightMagenta, theme.brightCyan, theme.brightWhite, ].map((value) => (typeof value === "string" ? value : "")); return values.join("|"); } interface TerminalEmulatorProps { dom?: DOMProps; streamKey: string; initialOutputText: string; initialOutputChunkSequence?: number; outputChunkText: string; outputChunkSequence: number; outputChunkReplay?: boolean; testId?: string; xtermTheme?: ITheme; swipeGesturesEnabled?: boolean; onSwipeLeft?: () => void; onSwipeRight?: () => void; onInput?: (data: string) => Promise | void; onResize?: (input: { rows: number; cols: number }) => Promise | void; onTerminalKey?: (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean; }) => Promise | void; onPendingModifiersConsumed?: () => Promise | void; onOutputChunkConsumed?: (sequence: number) => Promise | void; pendingModifiers?: PendingTerminalModifiers; focusRequestToken?: number; resizeRequestToken?: number; } declare global { interface Window {} } export default function TerminalEmulator({ streamKey, initialOutputText, initialOutputChunkSequence = 0, outputChunkText, outputChunkSequence, outputChunkReplay = false, testId = "terminal-surface", xtermTheme = { background: "#0b0b0b", foreground: "#e6e6e6", cursor: "#e6e6e6", }, swipeGesturesEnabled = false, onSwipeLeft, onSwipeRight, onInput, onResize, onTerminalKey, onPendingModifiersConsumed, onOutputChunkConsumed, pendingModifiers = { ctrl: false, shift: false, alt: false }, focusRequestToken = 0, resizeRequestToken = 0, }: TerminalEmulatorProps) { const rootRef = useRef(null); const hostRef = useRef(null); const runtimeRef = useRef(null); const appliedChunkSequenceRef = useRef(0); const mountedThemeRef = useRef(xtermTheme); const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]); useEffect(() => { mountedThemeRef.current = xtermTheme; runtimeRef.current?.setTheme({ theme: xtermTheme }); }, [themeKey]); useEffect(() => { const root = rootRef.current; if (!root || !swipeGesturesEnabled) { return; } const SWIPE_MIN_PX = 22; const VERTICAL_CANCEL_PX = 12; const HORIZONTAL_DOMINANCE_RATIO = 1.2; let tracking = false; let activePointerId: number | null = null; let startX = 0; let startY = 0; let fired = false; const reset = () => { tracking = false; activePointerId = null; startX = 0; startY = 0; fired = false; }; const shouldTreatAsVertical = (dx: number, dy: number) => { const absDx = Math.abs(dx); const absDy = Math.abs(dy); if (absDy < VERTICAL_CANCEL_PX) { return false; } return absDy > absDx; }; const shouldTreatAsHorizontal = (dx: number, dy: number) => { const absDx = Math.abs(dx); const absDy = Math.abs(dy); if (absDx < SWIPE_MIN_PX) { return false; } if (absDy === 0) { return true; } return absDx / absDy >= HORIZONTAL_DOMINANCE_RATIO; }; const onPointerDown = (event: PointerEvent) => { if (!event.isPrimary) { return; } tracking = true; fired = false; activePointerId = event.pointerId; startX = event.clientX; startY = event.clientY; }; const onPointerMove = (event: PointerEvent) => { if (!tracking || fired) { return; } if (activePointerId !== null && event.pointerId !== activePointerId) { return; } const dx = event.clientX - startX; const dy = event.clientY - startY; if (shouldTreatAsVertical(dx, dy)) { reset(); return; } if (!shouldTreatAsHorizontal(dx, dy)) { return; } fired = true; if (dx > 0) { onSwipeRight?.(); } else { onSwipeLeft?.(); } if (event.cancelable) { event.preventDefault(); } }; const onPointerUp = (event: PointerEvent) => { if (activePointerId !== null && event.pointerId !== activePointerId) { return; } reset(); }; const onPointerCancel = (event: PointerEvent) => { if (activePointerId !== null && event.pointerId !== activePointerId) { return; } reset(); }; root.addEventListener("pointerdown", onPointerDown, { passive: true }); root.addEventListener("pointermove", onPointerMove, { passive: false }); root.addEventListener("pointerup", onPointerUp, { passive: true }); root.addEventListener("pointercancel", onPointerCancel, { passive: true }); return () => { root.removeEventListener("pointerdown", onPointerDown); root.removeEventListener("pointermove", onPointerMove); root.removeEventListener("pointerup", onPointerUp); root.removeEventListener("pointercancel", onPointerCancel); }; }, [onSwipeLeft, onSwipeRight, swipeGesturesEnabled]); useEffect(() => { const host = hostRef.current; const root = rootRef.current; if (!host || !root) { return; } const runtime = new TerminalEmulatorRuntime(); runtimeRef.current = runtime; runtime.setCallbacks({ callbacks: { onInput, onResize, onTerminalKey, onPendingModifiersConsumed, }, }); runtime.setPendingModifiers({ pendingModifiers }); runtime.mount({ root, host, initialOutputText, theme: mountedThemeRef.current, }); appliedChunkSequenceRef.current = Math.max( 0, Math.floor(initialOutputChunkSequence) ); return () => { runtime.unmount(); if (runtimeRef.current === runtime) { runtimeRef.current = null; } appliedChunkSequenceRef.current = 0; }; }, [streamKey]); useEffect(() => { runtimeRef.current?.setCallbacks({ callbacks: { onInput, onResize, onTerminalKey, onPendingModifiersConsumed, }, }); }, [onInput, onPendingModifiersConsumed, onResize, onTerminalKey]); useEffect(() => { runtimeRef.current?.setPendingModifiers({ pendingModifiers }); }, [pendingModifiers]); useEffect(() => { const runtime = runtimeRef.current; if (outputChunkSequence <= 0) { return; } if (outputChunkSequence <= appliedChunkSequenceRef.current) { onOutputChunkConsumed?.(outputChunkSequence); return; } if (!runtime) { onOutputChunkConsumed?.(outputChunkSequence); return; } appliedChunkSequenceRef.current = outputChunkSequence; if (outputChunkText.length === 0) { runtime.clear({ onCommitted: () => { onOutputChunkConsumed?.(outputChunkSequence); }, }); return; } runtime.write({ text: outputChunkText, suppressInput: outputChunkReplay, onCommitted: () => { onOutputChunkConsumed?.(outputChunkSequence); }, }); }, [onOutputChunkConsumed, outputChunkReplay, outputChunkSequence, outputChunkText]); useEffect(() => { if (focusRequestToken <= 0) { return; } return focusWithRetries({ focus: () => { runtimeRef.current?.focus(); }, isFocused: () => { const root = rootRef.current; if (!root) { return false; } const active = typeof document !== "undefined" ? document.activeElement : null; return active instanceof HTMLElement && root.contains(active); }, }); }, [focusRequestToken]); useEffect(() => { if (resizeRequestToken <= 0) { return; } runtimeRef.current?.resize({ force: true }); }, [resizeRequestToken]); return (
{ runtimeRef.current?.focus(); }} >
); }