import React from 'react'; import { createPortal } from 'react-dom'; import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web'; import { isMobileDeviceViaCSS } from '@/lib/device'; import type { TerminalTheme } from '@/lib/terminalTheme'; import { getGhosttyTerminalOptions } from '@/lib/terminalTheme'; import type { TerminalChunk } from '@/stores/useTerminalStore'; import { cn } from '@/lib/utils'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; let ghosttyPromise: Promise | null = null; function getGhostty(): Promise { if (!ghosttyPromise) { ghosttyPromise = Ghostty.load(); } return ghosttyPromise; } function findScrollableViewport(container: HTMLElement): HTMLElement | null { if (typeof window === 'undefined') { return null; } const candidates = [container, ...Array.from(container.querySelectorAll('*'))]; let fallback: HTMLElement | null = null; for (const element of candidates) { const style = window.getComputedStyle(element); const overflowY = style.overflowY; if (overflowY !== 'auto' && overflowY !== 'scroll') { continue; } // Prefer an element that is currently scrollable. if (element.scrollHeight - element.clientHeight > 2) { return element; } // Otherwise keep the first overflow container as a fallback so we can // attach touch scroll before scrollback grows. if (!fallback) { fallback = element; } } return fallback; } type TerminalController = { focus: () => void; clear: () => void; fit: () => void; }; interface TerminalViewportProps { sessionKey: string; chunks: TerminalChunk[]; onInput: (data: string) => void; onResize: (cols: number, rows: number) => void; theme: TerminalTheme; fontFamily: string; fontSize: number; className?: string; enableTouchScroll?: boolean; } const TerminalViewport = React.forwardRef( ( { sessionKey, chunks, onInput, onResize, theme, fontFamily, fontSize, className, enableTouchScroll }, ref ) => { const containerRef = React.useRef(null); const viewportRef = React.useRef(null); const terminalRef = React.useRef(null); const fitAddonRef = React.useRef(null); const inputHandlerRef = React.useRef<(data: string) => void>(onInput); const resizeHandlerRef = React.useRef<(cols: number, rows: number) => void>(onResize); const lastReportedSizeRef = React.useRef<{ cols: number; rows: number } | null>(null); const pendingWriteRef = React.useRef(''); const writeScheduledRef = React.useRef(null); const isWritingRef = React.useRef(false); const lastProcessedChunkIdRef = React.useRef(null); const touchScrollCleanupRef = React.useRef<(() => void) | null>(null); const viewportDiscoveryTimeoutRef = React.useRef(null); const viewportDiscoveryAttemptsRef = React.useRef(0); const hiddenInputRef = React.useRef(null); const textInputRef = React.useRef(null); const isComposingRef = React.useRef(false); const ignoreNextInputRef = React.useRef(false); const lastBeforeInputRef = React.useRef<{ type: string; at: number } | null>(null); const lastInputEventAtRef = React.useRef(null); const refocusTimeoutRef = React.useRef(null); const keydownProbeTimeoutRef = React.useRef(null); const lastObservedValueRef = React.useRef(''); const [, forceRender] = React.useReducer((x) => x + 1, 0); const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0); inputHandlerRef.current = onInput; resizeHandlerRef.current = onResize; const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent); const prefersTouchOnlyPointer = typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches && window.matchMedia('(hover: none)').matches; const useHiddenInputOverlay = Boolean(enableTouchScroll) && (isAndroid || isMobileDeviceViaCSS() || prefersTouchOnlyPointer); const disableTerminalTextareas = React.useCallback(() => { const container = containerRef.current; const hiddenInput = hiddenInputRef.current; if (!container) { return; } const editableNodes = Array.from(container.querySelectorAll('[contenteditable="true"]')); editableNodes.forEach((node) => { node.style.setProperty('caret-color', 'transparent'); node.style.outline = 'none'; }); const textareas = Array.from(container.querySelectorAll('textarea')) as HTMLTextAreaElement[]; textareas.forEach((textarea) => { textarea.style.setProperty('caret-color', 'transparent'); textarea.style.color = 'transparent'; textarea.style.setProperty('-webkit-text-fill-color', 'transparent'); textarea.style.background = 'transparent'; textarea.style.border = '0'; textarea.style.outline = 'none'; textarea.style.boxShadow = 'none'; textarea.style.textShadow = 'none'; textarea.style.fontSize = '0'; textarea.style.lineHeight = '0'; if (!useHiddenInputOverlay) { return; } if (textarea === hiddenInput) { return; } if (textarea.getAttribute('data-terminal-disabled-input') === 'true') { return; } textarea.setAttribute('data-terminal-disabled-input', 'true'); textarea.setAttribute('aria-hidden', 'true'); textarea.tabIndex = -1; textarea.disabled = true; textarea.style.position = 'absolute'; textarea.style.opacity = '0'; textarea.style.width = '0px'; textarea.style.height = '0px'; textarea.style.pointerEvents = 'none'; textarea.style.zIndex = '-1'; }); }, [useHiddenInputOverlay]); const useTextInput = useHiddenInputOverlay && isAndroid; const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => { const input = (useTextInput ? textInputRef.current : hiddenInputRef.current) as HTMLElement | null; const container = containerRef.current; if (!input || !container) { return; } const rect = container.getBoundingClientRect(); const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : rect.width; const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : rect.height; const fallbackX = rect.left + rect.width / 2; const fallbackY = rect.top + rect.height - 12; const x = typeof clientX === 'number' ? clientX : fallbackX; const y = typeof clientY === 'number' ? clientY : fallbackY; const padding = 8; const left = Math.max(padding, Math.min(viewportWidth - padding, x)); const top = Math.max(padding, Math.min(viewportHeight - padding, y)); input.style.left = `${left}px`; input.style.top = `${top}px`; input.style.bottom = ''; if (input instanceof HTMLTextAreaElement || input instanceof HTMLInputElement) { input.disabled = false; input.readOnly = false; input.removeAttribute('disabled'); input.removeAttribute('readonly'); } try { input.focus({ preventScroll: true }); } catch { try { input.focus(); } catch { /* ignored */ } } }, [useTextInput]); const readEditableValue = React.useCallback((target: HTMLElement) => { if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) { return target.value; } return target.textContent ?? ''; }, []); const clearEditableValue = React.useCallback((target: HTMLElement) => { if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) { target.value = ''; return; } target.textContent = ''; }, []); const scheduleKeyProbe = React.useCallback((target: HTMLElement) => { if (typeof window === 'undefined') { return; } if (useTextInput) { return; } if (keydownProbeTimeoutRef.current !== null) { window.clearTimeout(keydownProbeTimeoutRef.current); keydownProbeTimeoutRef.current = null; } let attempt = 0; const maxAttempts = 3; const runProbe = () => { keydownProbeTimeoutRef.current = window.setTimeout(() => { keydownProbeTimeoutRef.current = null; const value = readEditableValue(target); if (!value) { attempt += 1; if (attempt < maxAttempts) { runProbe(); return; } return; } const previous = lastObservedValueRef.current; lastObservedValueRef.current = value; const delta = value.startsWith(previous) ? value.slice(previous.length) : value; if (delta) { inputHandlerRef.current(delta.replace(/\r\n|\r|\n/g, '\r')); } clearEditableValue(target); lastObservedValueRef.current = ''; }, attempt === 0 ? 0 : 24); }; runProbe(); }, [clearEditableValue, readEditableValue, useTextInput]); React.useEffect(() => { const container = containerRef.current; if (!useHiddenInputOverlay || !container) { return; } const handleContainerFocusIn = (event: FocusEvent) => { const target = event.target as HTMLElement | null; if (!target) { return; } if (target.getAttribute('data-terminal-hidden-input') === 'true') { return; } if (!container.contains(target)) { return; } try { target.blur(); } catch { /* ignored */ } focusHiddenInput(); }; container.addEventListener('focusin', handleContainerFocusIn, true); return () => { container.removeEventListener('focusin', handleContainerFocusIn, true); }; }, [useHiddenInputOverlay, focusHiddenInput]); const copySelectionToClipboard = React.useCallback(async () => { if (typeof window === 'undefined' || typeof document === 'undefined') { return; } const selection = window.getSelection(); if (!selection) { return; } const text = selection.toString(); if (!text.trim()) { return; } const container = containerRef.current; if (!container) { return; } const anchorNode = selection.anchorNode; const focusNode = selection.focusNode; if (anchorNode && !container.contains(anchorNode)) { return; } if (focusNode && !container.contains(focusNode)) { return; } if (navigator.clipboard?.writeText) { const copied = await navigator.clipboard .writeText(text) .then(() => true) .catch(() => false); if (copied) { return; } } try { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.focus(); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } catch { return; } }, []); const resetWriteState = React.useCallback(() => { pendingWriteRef.current = ''; if (writeScheduledRef.current !== null && typeof window !== 'undefined') { window.cancelAnimationFrame(writeScheduledRef.current); } writeScheduledRef.current = null; isWritingRef.current = false; lastProcessedChunkIdRef.current = null; }, []); const fitTerminal = React.useCallback(() => { const fitAddon = fitAddonRef.current; const terminal = terminalRef.current; const container = containerRef.current; if (!fitAddon || !terminal || !container) { return; } const rect = container.getBoundingClientRect(); if (rect.width < 24 || rect.height < 24) { return; } try { fitAddon.fit(); const next = { cols: terminal.cols, rows: terminal.rows }; const previous = lastReportedSizeRef.current; if (!previous || previous.cols !== next.cols || previous.rows !== next.rows) { lastReportedSizeRef.current = next; resizeHandlerRef.current(next.cols, next.rows); } } catch { /* ignored */ } }, []); const flushWrites = React.useCallback(() => { if (isWritingRef.current) { return; } const term = terminalRef.current; if (!term) { resetWriteState(); return; } if (!pendingWriteRef.current) { return; } const chunk = pendingWriteRef.current; pendingWriteRef.current = ''; isWritingRef.current = true; term.write(chunk, () => { isWritingRef.current = false; if (pendingWriteRef.current) { if (typeof window !== 'undefined') { writeScheduledRef.current = window.requestAnimationFrame(() => { writeScheduledRef.current = null; flushWrites(); }); } else { flushWrites(); } } }); }, [resetWriteState]); const scheduleFlushWrites = React.useCallback(() => { if (writeScheduledRef.current !== null) { return; } if (typeof window !== 'undefined') { writeScheduledRef.current = window.requestAnimationFrame(() => { writeScheduledRef.current = null; flushWrites(); }); } else { flushWrites(); } }, [flushWrites]); const enqueueWrite = React.useCallback( (data: string) => { if (!data) { return; } pendingWriteRef.current += data; scheduleFlushWrites(); }, [scheduleFlushWrites] ); const setupTouchScroll = React.useCallback(() => { touchScrollCleanupRef.current?.(); touchScrollCleanupRef.current = null; if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') { window.clearTimeout(viewportDiscoveryTimeoutRef.current); viewportDiscoveryTimeoutRef.current = null; } if (!enableTouchScroll) { viewportDiscoveryAttemptsRef.current = 0; return; } const container = containerRef.current; if (!container) { return; } // Ghostty scrollback is internal (canvas-based). On touch devices we need // to translate touch deltas into terminal scroll calls. const terminal = terminalRef.current; if (!terminal) { return; } viewportDiscoveryAttemptsRef.current = 0; const baseScrollMultiplier = 2.2; const maxScrollBoost = 2.8; const boostDenominator = 25; const velocityAlpha = 0.25; const maxVelocity = 8; const minVelocity = 0.05; const deceleration = 0.015; const state = { lastY: null as number | null, lastTime: null as number | null, velocity: 0, rafId: null as number | null, startX: null as number | null, startY: null as number | null, didMove: false, }; const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()); const lineHeightPx = Math.max(12, Math.round(fontSize * 1.35)); let remainderPx = 0; const scrollByPixels = (deltaPixels: number) => { if (!deltaPixels) { return false; } const before = terminal.getViewportY(); const total = remainderPx + deltaPixels; const lines = Math.trunc(total / lineHeightPx); remainderPx = total - lines * lineHeightPx; if (lines !== 0) { // Touch delta is in pixels, convert to lines. // Natural mobile scrolling: finger up scrolls down. terminal.scrollLines(lines); } const after = terminal.getViewportY(); return after !== before; }; const stopKinetic = () => { if (state.rafId !== null && typeof window !== 'undefined') { window.cancelAnimationFrame(state.rafId); } state.rafId = null; }; const listenerOptions: AddEventListenerOptions = { passive: false, capture: false }; const supportsPointerEvents = typeof window !== 'undefined' && 'PointerEvent' in window; if (supportsPointerEvents) { const stateWithPointerId = Object.assign(state, { pointerId: null as number | null, startX: null as number | null, startY: null as number | null, moved: false, }); const TAP_MOVE_THRESHOLD_PX = 6; const handlePointerDown = (event: PointerEvent) => { if (event.pointerType !== 'touch') { return; } stopKinetic(); stateWithPointerId.pointerId = event.pointerId; stateWithPointerId.startX = event.clientX; stateWithPointerId.startY = event.clientY; stateWithPointerId.moved = false; stateWithPointerId.lastY = event.clientY; stateWithPointerId.lastTime = nowMs(); stateWithPointerId.velocity = 0; try { container.setPointerCapture(event.pointerId); } catch { /* ignored */ } }; const handlePointerMove = (event: PointerEvent) => { if (event.pointerType !== 'touch' || stateWithPointerId.pointerId !== event.pointerId) { return; } if (stateWithPointerId.startX !== null && stateWithPointerId.startY !== null && !stateWithPointerId.moved) { const dx = event.clientX - stateWithPointerId.startX; const dy = event.clientY - stateWithPointerId.startY; if (Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD_PX) { stateWithPointerId.moved = true; } } if (stateWithPointerId.lastY === null) { stateWithPointerId.lastY = event.clientY; stateWithPointerId.lastTime = nowMs(); return; } const previousY = stateWithPointerId.lastY; const previousTime = stateWithPointerId.lastTime ?? nowMs(); const currentTime = nowMs(); stateWithPointerId.lastY = event.clientY; stateWithPointerId.lastTime = currentTime; const deltaY = previousY - event.clientY; if (Math.abs(deltaY) < 1) { return; } const dt = Math.max(currentTime - previousTime, 8); const scrollMultiplier = baseScrollMultiplier + Math.min(maxScrollBoost, Math.abs(deltaY) / boostDenominator); const deltaPixels = deltaY * scrollMultiplier; const instantVelocity = deltaPixels / dt; stateWithPointerId.velocity = stateWithPointerId.velocity * (1 - velocityAlpha) + instantVelocity * velocityAlpha; if (stateWithPointerId.velocity > maxVelocity) { stateWithPointerId.velocity = maxVelocity; } else if (stateWithPointerId.velocity < -maxVelocity) { stateWithPointerId.velocity = -maxVelocity; } // Only prevent default once we're actually scrolling. if (stateWithPointerId.moved) { if (event.cancelable) { event.preventDefault(); } event.stopPropagation(); } scrollByPixels(deltaPixels); }; const handlePointerUp = (event: PointerEvent) => { if (event.pointerType !== 'touch' || stateWithPointerId.pointerId !== event.pointerId) { return; } const wasTap = !stateWithPointerId.moved; stateWithPointerId.pointerId = null; stateWithPointerId.startX = null; stateWithPointerId.startY = null; stateWithPointerId.moved = false; stateWithPointerId.lastY = null; stateWithPointerId.lastTime = null; try { container.releasePointerCapture(event.pointerId); } catch { /* ignored */ } if (wasTap) { if (useHiddenInputOverlay) { focusHiddenInput(event.clientX, event.clientY); } else { terminalRef.current?.focus(); } return; } if (typeof window === 'undefined') { return; } if (Math.abs(stateWithPointerId.velocity) < minVelocity) { stateWithPointerId.velocity = 0; return; } let lastFrame = nowMs(); const step = () => { const frameTime = nowMs(); const dt = Math.max(frameTime - lastFrame, 8); lastFrame = frameTime; const moved = scrollByPixels(stateWithPointerId.velocity * dt) ?? false; const sign = Math.sign(stateWithPointerId.velocity); const nextMagnitude = Math.max(0, Math.abs(stateWithPointerId.velocity) - deceleration * dt); stateWithPointerId.velocity = nextMagnitude * sign; if (!moved || nextMagnitude <= minVelocity) { stopKinetic(); stateWithPointerId.velocity = 0; return; } stateWithPointerId.rafId = window.requestAnimationFrame(step); }; stateWithPointerId.rafId = window.requestAnimationFrame(step); }; container.addEventListener('pointerdown', handlePointerDown, listenerOptions); container.addEventListener('pointermove', handlePointerMove, listenerOptions); container.addEventListener('pointerup', handlePointerUp, listenerOptions); container.addEventListener('pointercancel', handlePointerUp, listenerOptions); const previousTouchAction = container.style.touchAction; container.style.touchAction = 'manipulation'; touchScrollCleanupRef.current = () => { stopKinetic(); if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') { window.clearTimeout(viewportDiscoveryTimeoutRef.current); viewportDiscoveryTimeoutRef.current = null; } viewportDiscoveryAttemptsRef.current = 0; container.removeEventListener('pointerdown', handlePointerDown, listenerOptions); container.removeEventListener('pointermove', handlePointerMove, listenerOptions); container.removeEventListener('pointerup', handlePointerUp, listenerOptions); container.removeEventListener('pointercancel', handlePointerUp, listenerOptions); container.style.touchAction = previousTouchAction; }; return; } const TAP_MOVE_THRESHOLD_PX = 6; const handleTouchStart = (event: TouchEvent) => { if (event.touches.length !== 1) { return; } stopKinetic(); state.lastY = event.touches[0].clientY; state.lastTime = nowMs(); state.velocity = 0; state.startX = event.touches[0].clientX; state.startY = event.touches[0].clientY; state.didMove = false; }; const handleTouchMove = (event: TouchEvent) => { if (event.touches.length !== 1) { state.lastY = null; state.lastTime = null; state.velocity = 0; state.startX = null; state.startY = null; state.didMove = false; stopKinetic(); return; } const currentX = event.touches[0].clientX; const currentY = event.touches[0].clientY; if (state.startX !== null && state.startY !== null && !state.didMove) { const dx = currentX - state.startX; const dy = currentY - state.startY; if (Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD_PX) { state.didMove = true; } } if (state.lastY === null) { state.lastY = currentY; state.lastTime = nowMs(); return; } const previousY = state.lastY; const previousTime = state.lastTime ?? nowMs(); const currentTime = nowMs(); state.lastY = currentY; state.lastTime = currentTime; const deltaY = previousY - currentY; if (Math.abs(deltaY) < 1) { return; } const dt = Math.max(currentTime - previousTime, 8); const scrollMultiplier = baseScrollMultiplier + Math.min(maxScrollBoost, Math.abs(deltaY) / boostDenominator); const deltaPixels = deltaY * scrollMultiplier; const instantVelocity = deltaPixels / dt; state.velocity = state.velocity * (1 - velocityAlpha) + instantVelocity * velocityAlpha; if (state.velocity > maxVelocity) { state.velocity = maxVelocity; } else if (state.velocity < -maxVelocity) { state.velocity = -maxVelocity; } if (state.didMove) { event.preventDefault(); event.stopPropagation(); } scrollByPixels(deltaPixels); }; const handleTouchEnd = (event: TouchEvent) => { const wasTap = !state.didMove; state.lastY = null; state.lastTime = null; const velocity = state.velocity; state.startX = null; state.startY = null; state.didMove = false; if (wasTap) { const point = event.changedTouches?.[0]; if (useHiddenInputOverlay) { focusHiddenInput(point?.clientX, point?.clientY); } else { terminalRef.current?.focus(); } return; } if (typeof window === 'undefined') { return; } if (Math.abs(velocity) < minVelocity) { state.velocity = 0; return; } let lastFrame = nowMs(); const step = () => { const frameTime = nowMs(); const dt = Math.max(frameTime - lastFrame, 8); lastFrame = frameTime; const moved = scrollByPixels(state.velocity * dt) ?? false; const sign = Math.sign(state.velocity); const nextMagnitude = Math.max(0, Math.abs(state.velocity) - deceleration * dt); state.velocity = nextMagnitude * sign; if (!moved || nextMagnitude <= minVelocity) { stopKinetic(); state.velocity = 0; return; } state.rafId = window.requestAnimationFrame(step); }; state.rafId = window.requestAnimationFrame(step); }; container.addEventListener('touchstart', handleTouchStart, listenerOptions); container.addEventListener('touchmove', handleTouchMove, listenerOptions); container.addEventListener('touchend', handleTouchEnd as unknown as EventListener, listenerOptions); container.addEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions); const previousTouchAction = container.style.touchAction; container.style.touchAction = 'manipulation'; touchScrollCleanupRef.current = () => { stopKinetic(); if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') { window.clearTimeout(viewportDiscoveryTimeoutRef.current); viewportDiscoveryTimeoutRef.current = null; } viewportDiscoveryAttemptsRef.current = 0; container.removeEventListener('touchstart', handleTouchStart, listenerOptions); container.removeEventListener('touchmove', handleTouchMove, listenerOptions); container.removeEventListener('touchend', handleTouchEnd as unknown as EventListener, listenerOptions); container.removeEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions); container.style.touchAction = previousTouchAction; }; }, [enableTouchScroll, useHiddenInputOverlay, focusHiddenInput, fontSize]); React.useEffect(() => { let disposed = false; let localTerminal: GhosttyTerminal | null = null; let localResizeObserver: ResizeObserver | null = null; let localTextareaObserver: MutationObserver | null = null; let localDisposables: Array<{ dispose: () => void }> = []; const container = containerRef.current; if (!container) { return; } container.tabIndex = useHiddenInputOverlay ? -1 : 0; const initialize = async () => { try { const ghostty = await getGhostty(); if (disposed) { return; } const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, useHiddenInputOverlay); const terminal = new GhosttyTerminal(options); const fitAddon = new FitAddon(); localTerminal = terminal; terminalRef.current = terminal; fitAddonRef.current = fitAddon; terminal.loadAddon(fitAddon); terminal.open(container); bumpTerminalReady(); disableTerminalTextareas(); if (typeof MutationObserver !== 'undefined') { localTextareaObserver = new MutationObserver(() => { disableTerminalTextareas(); }); localTextareaObserver.observe(container, { childList: true, subtree: true }); } const viewport = findScrollableViewport(container); if (viewport) { viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container'); viewportRef.current = viewport; forceRender(); } else { viewportRef.current = null; } fitTerminal(); setupTouchScroll(); if (!useHiddenInputOverlay) { terminal.focus(); } localDisposables = [ terminal.onData((data: string) => { inputHandlerRef.current(data); }), ]; localResizeObserver = new ResizeObserver(() => { fitTerminal(); }); localResizeObserver.observe(container); if (typeof window !== 'undefined') { window.setTimeout(() => { fitTerminal(); }, 0); } } catch { // ignored } }; void initialize(); return () => { disposed = true; touchScrollCleanupRef.current?.(); touchScrollCleanupRef.current = null; localDisposables.forEach((disposable) => disposable.dispose()); localResizeObserver?.disconnect(); localTextareaObserver?.disconnect(); localTerminal?.dispose(); terminalRef.current = null; fitAddonRef.current = null; viewportRef.current = null; lastReportedSizeRef.current = null; resetWriteState(); }; }, [disableTerminalTextareas, useHiddenInputOverlay, fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]); React.useEffect(() => { const terminal = terminalRef.current; if (!terminal) { return; } terminal.reset(); resetWriteState(); lastReportedSizeRef.current = null; fitTerminal(); if (!useHiddenInputOverlay) { terminal.focus(); } }, [useHiddenInputOverlay, sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]); React.useEffect(() => { setupTouchScroll(); return () => { touchScrollCleanupRef.current?.(); touchScrollCleanupRef.current = null; }; }, [setupTouchScroll, sessionKey]); React.useEffect(() => { const terminal = terminalRef.current; if (!terminal) { return; } if (chunks.length === 0) { if (lastProcessedChunkIdRef.current !== null) { terminal.reset(); resetWriteState(); fitTerminal(); } return; } const lastProcessedId = lastProcessedChunkIdRef.current; let pending: TerminalChunk[]; if (lastProcessedId === null) { pending = chunks; } else { const lastProcessedIndex = chunks.findIndex((chunk) => chunk.id === lastProcessedId); pending = lastProcessedIndex >= 0 ? chunks.slice(lastProcessedIndex + 1) : chunks; } if (pending.length > 0) { enqueueWrite(pending.map((chunk) => chunk.data).join('')); } lastProcessedChunkIdRef.current = chunks[chunks.length - 1].id; }, [chunks, terminalReadyVersion, enqueueWrite, fitTerminal, resetWriteState]); React.useImperativeHandle( ref, (): TerminalController => ({ focus: () => { if (useHiddenInputOverlay) { focusHiddenInput(); return; } terminalRef.current?.focus(); }, clear: () => { const terminal = terminalRef.current; if (!terminal) { return; } terminal.reset(); resetWriteState(); fitTerminal(); }, fit: () => { fitTerminal(); }, }), [useHiddenInputOverlay, focusHiddenInput, fitTerminal, resetWriteState] ); const handleHiddenInputBlur = React.useCallback( (event: React.FocusEvent) => { if (!useHiddenInputOverlay) { return; } const related = event.relatedTarget as HTMLElement | null; const relatedTag = related?.tagName; const isInput = relatedTag === 'INPUT' || relatedTag === 'TEXTAREA' || related?.isContentEditable; const isHiddenInput = related?.getAttribute('data-terminal-hidden-input') === 'true'; if (isInput && !isHiddenInput) { if (refocusTimeoutRef.current !== null && typeof window !== 'undefined') { window.clearTimeout(refocusTimeoutRef.current); } if (typeof window !== 'undefined') { refocusTimeoutRef.current = window.setTimeout(() => { refocusTimeoutRef.current = null; focusHiddenInput(); }, 0); } } }, [useHiddenInputOverlay, focusHiddenInput] ); const handleHiddenBeforeInput = React.useCallback( (event: React.FormEvent) => { const nativeEvent = event.nativeEvent as InputEvent | undefined; const inputType = nativeEvent?.inputType ?? ''; const data = typeof nativeEvent?.data === 'string' ? nativeEvent.data : ''; lastInputEventAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now(); if (inputType === 'insertCompositionText') { isComposingRef.current = true; return; } if (!inputType && data) { if (isComposingRef.current) { return; } event.preventDefault(); inputHandlerRef.current(data); lastBeforeInputRef.current = { type: 'insertText', at: typeof performance !== 'undefined' ? performance.now() : Date.now(), }; ignoreNextInputRef.current = true; return; } if (inputType === 'insertText' && data) { if (isComposingRef.current) { return; } event.preventDefault(); inputHandlerRef.current(data); lastBeforeInputRef.current = { type: inputType, at: typeof performance !== 'undefined' ? performance.now() : Date.now(), }; ignoreNextInputRef.current = true; return; } if (inputType === 'insertLineBreak') { if (isComposingRef.current) { return; } event.preventDefault(); inputHandlerRef.current('\r'); lastBeforeInputRef.current = { type: inputType, at: typeof performance !== 'undefined' ? performance.now() : Date.now(), }; ignoreNextInputRef.current = true; return; } if (inputType === 'deleteContentBackward') { if (isComposingRef.current) { return; } event.preventDefault(); inputHandlerRef.current('\x7f'); lastBeforeInputRef.current = { type: inputType, at: typeof performance !== 'undefined' ? performance.now() : Date.now(), }; ignoreNextInputRef.current = true; } }, [] ); const handleHiddenInput = React.useCallback( (event: React.FormEvent) => { const target = event.currentTarget as HTMLElement; lastInputEventAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now(); if (isComposingRef.current) { return; } if (ignoreNextInputRef.current) { const lastBeforeInput = lastBeforeInputRef.current; const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); if (lastBeforeInput && now - lastBeforeInput.at < 50) { ignoreNextInputRef.current = false; clearEditableValue(target); return; } ignoreNextInputRef.current = false; } const raw = readEditableValue(target); if (!raw) { return; } lastObservedValueRef.current = raw; const value = raw.replace(/\r\n|\r|\n/g, '\r'); inputHandlerRef.current(value); clearEditableValue(target); lastObservedValueRef.current = ''; }, [clearEditableValue, readEditableValue] ); const handleHiddenKeyDown = React.useCallback( (event: React.KeyboardEvent) => { event.stopPropagation(); const target = event.currentTarget as HTMLElement; const nativeEvent = event.nativeEvent as KeyboardEvent | undefined; if (nativeEvent?.isComposing) { return; } const lastBeforeInput = lastBeforeInputRef.current; const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); const recent = Boolean(lastBeforeInput && now - lastBeforeInput.at < 50); if (event.key === 'Enter') { if (recent && lastBeforeInput?.type === 'insertLineBreak') { return; } event.preventDefault(); inputHandlerRef.current('\r'); clearEditableValue(target); return; } if (event.key === 'Backspace') { event.preventDefault(); if (recent && lastBeforeInput?.type === 'deleteContentBackward') { return; } if (!readEditableValue(target)) { inputHandlerRef.current('\x7f'); } } if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { const lastInputAt = lastInputEventAtRef.current; const sawInputRecently = Boolean(lastInputAt && now - lastInputAt < 50); if (!sawInputRecently) { event.preventDefault(); inputHandlerRef.current(event.key); ignoreNextInputRef.current = true; lastBeforeInputRef.current = { type: 'keydown-text', at: now, }; } } scheduleKeyProbe(target); }, [clearEditableValue, readEditableValue, scheduleKeyProbe] ); const handleHiddenKeyUp = React.useCallback( (event: React.KeyboardEvent) => { event.stopPropagation(); const target = event.currentTarget as HTMLElement; const nativeEvent = event.nativeEvent as KeyboardEvent | undefined; if (nativeEvent?.isComposing) { return; } scheduleKeyProbe(target); }, [scheduleKeyProbe] ); const handleHiddenCompositionEnd = React.useCallback( (event: React.CompositionEvent) => { const target = event.currentTarget as HTMLElement; isComposingRef.current = false; const data = event.data || readEditableValue(target); lastInputEventAtRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now(); if (!data) { return; } const value = data.replace(/\r\n|\r|\n/g, '\r'); inputHandlerRef.current(value); clearEditableValue(target); lastBeforeInputRef.current = { type: 'compositionend', at: typeof performance !== 'undefined' ? performance.now() : Date.now(), }; ignoreNextInputRef.current = true; }, [clearEditableValue, readEditableValue] ); const handleHiddenPaste = React.useCallback( (event: React.ClipboardEvent) => { event.stopPropagation(); const text = event.clipboardData?.getData('text') ?? ''; if (!text) { return; } event.preventDefault(); const terminal = terminalRef.current; const payload = terminal?.hasBracketedPaste?.() ? `\x1b[200~${text}\x1b[201~` : text; inputHandlerRef.current(payload); }, [] ); const hiddenInputStyle: React.CSSProperties = { position: 'fixed', left: 0, top: 0, width: 1, height: 1, opacity: 0, zIndex: -1, background: 'transparent', color: 'transparent', WebkitTextFillColor: 'transparent', caretColor: 'transparent', textShadow: 'none', WebkitAppearance: 'none', appearance: 'none', resize: 'none', overflow: 'hidden', whiteSpace: 'nowrap', border: '0', padding: 0, margin: 0, outline: 'none', outlineOffset: 0, fontSize: 16, fontWeight: 400, pointerEvents: 'none', WebkitUserSelect: 'none', userSelect: 'none', }; return (
{ if (useHiddenInputOverlay) { const touch = event.touches?.[0]; focusHiddenInput(touch?.clientX, touch?.clientY); } }} onClick={(event) => { if (useHiddenInputOverlay) { focusHiddenInput(event.clientX, event.clientY); } else { terminalRef.current?.focus(); } }} onMouseUp={() => { if (!enableTouchScroll) { void copySelectionToClipboard(); } }} onTouchEnd={() => { if (!enableTouchScroll) { void copySelectionToClipboard(); } }} > {useHiddenInputOverlay && typeof document !== 'undefined' ? createPortal( <> { isComposingRef.current = true; }} onInput={handleHiddenInput} onKeyDown={handleHiddenKeyDown} onKeyUp={handleHiddenKeyUp} onCompositionEnd={handleHiddenCompositionEnd} onPaste={handleHiddenPaste} />