"use dom"; import { useEffect, useRef } from "react"; import type { DOMProps } from "expo/dom"; import "@xterm/xterm/css/xterm.css"; import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime"; interface TerminalEmulatorProps { dom?: DOMProps; streamKey: string; initialOutputText: string; outputChunkText: string; outputChunkSequence: number; testId?: string; backgroundColor?: string; foregroundColor?: string; cursorColor?: string; 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, outputChunkText, outputChunkSequence, testId = "terminal-surface", backgroundColor = "#0b0b0b", foregroundColor = "#e6e6e6", cursorColor = "#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 appliedInitialOutputRef = useRef(null); 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: { backgroundColor, foregroundColor, cursorColor, }, }); appliedInitialOutputRef.current = initialOutputText; return () => { runtime.unmount(); if (runtimeRef.current === runtime) { runtimeRef.current = null; } appliedInitialOutputRef.current = null; }; }, [backgroundColor, cursorColor, foregroundColor, streamKey]); useEffect(() => { const runtime = runtimeRef.current; if (!runtime) { return; } if (appliedInitialOutputRef.current === initialOutputText) { return; } appliedInitialOutputRef.current = initialOutputText; runtime.clear(); if (initialOutputText.length > 0) { runtime.write({ text: initialOutputText }); } }, [initialOutputText]); 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 (!runtime) { onOutputChunkConsumed?.(outputChunkSequence); return; } if (outputChunkText.length === 0) { runtime.clear({ onCommitted: () => { onOutputChunkConsumed?.(outputChunkSequence); }, }); return; } runtime.write({ text: outputChunkText, onCommitted: () => { onOutputChunkConsumed?.(outputChunkSequence); }, }); }, [onOutputChunkConsumed, outputChunkSequence, outputChunkText]); useEffect(() => { if (focusRequestToken <= 0) { return; } runtimeRef.current?.focus(); }, [focusRequestToken]); useEffect(() => { if (resizeRequestToken <= 0) { return; } runtimeRef.current?.resize({ force: true }); }, [resizeRequestToken]); return (
{ runtimeRef.current?.focus(); }} >
); }