import React from 'react'; import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web'; import { cn } from '@/lib/utils'; import { loadMonoFont } from '@/lib/fontLoader'; import type { MonoFontOption } from '@/lib/fontOptions'; import type { TerminalTheme } from '@/lib/terminalTheme'; import { getGhosttyTerminalOptions } from '@/lib/terminalTheme'; import { getGhosttySafeResetSequence, rewriteGhosttyDefaultBackgroundResets, } from '@/lib/terminalOutput'; import { getTerminalCellFromPoint, getTerminalWordRange, type TerminalCellPosition, } from '@/lib/terminalTouchSelection'; import type { TerminalChunk } from '@/stores/useTerminalStore'; import { selectTerminalChunkReplay } from './terminalChunkReplay'; // ghostty-web (638 KB raw of JS + the WASM VT) loads on demand: TerminalView // stays eagerly importable for the bottom dock without pulling the emulator // into the startup graph before a terminal is actually mounted. type GhosttyModule = typeof import('ghostty-web'); type GhosttyRuntime = { module: GhosttyModule; ghostty: Ghostty }; let ghosttyRuntimePromise: Promise | null = null; const loadGhostty = (): Promise => ghosttyRuntimePromise ??= import('ghostty-web').then(async (module) => ({ module, ghostty: await module.Ghostty.load(), })); // Wait briefly for both the selected mono font and the web entry's deferred // Nerd Fonts before Ghostty measures glyphs. A cold CDN fetch must not block // opening the terminal, so the renderer starts after the bound and is rebuilt // once the fonts arrive. Runtimes without the Nerd Font hook resolve it at once. const TERMINAL_FONT_WAIT_MS = 2000; const loadNerdFonts = (): Promise => Promise.resolve(window.__openchamberEnsureNerdFonts?.()).catch(() => undefined); const waitForTerminalFonts = (font: MonoFontOption) => { const loaded = Promise.all([loadMonoFont(font), loadNerdFonts()]).then(() => undefined); const loadedBeforeTimeout = new Promise((resolve) => { const timeout = setTimeout(() => resolve(false), TERMINAL_FONT_WAIT_MS); void loaded.then(() => { clearTimeout(timeout); resolve(true); }); }); return { loaded, loadedBeforeTimeout }; }; type TerminalSize = { cols: number; rows: number }; const getProvisionalTerminalSize = ( container: HTMLDivElement, fontFamily: string, fontSize: number, ): TerminalSize | null => { if (typeof window === 'undefined' || typeof document === 'undefined') return null; const context = document.createElement('canvas').getContext('2d'); if (!context || container.clientWidth < 24 || container.clientHeight < 24) return null; context.font = `${fontSize}px ${fontFamily}`; const metrics = context.measureText('M'); const cellWidth = Math.ceil(metrics.width); const cellHeight = Math.ceil( (metrics.actualBoundingBoxAscent || fontSize * 0.8) + (metrics.actualBoundingBoxDescent || fontSize * 0.2), ) + 2; if (cellWidth < 1 || cellHeight < 1) return null; const style = window.getComputedStyle(container); const horizontalPadding = (Number.parseInt(style.paddingLeft, 10) || 0) + (Number.parseInt(style.paddingRight, 10) || 0); const verticalPadding = (Number.parseInt(style.paddingTop, 10) || 0) + (Number.parseInt(style.paddingBottom, 10) || 0); // Match Ghostty FitAddon's 15px scrollbar reservation and minimum dimensions. return { cols: Math.max(2, Math.floor((container.clientWidth - horizontalPadding - 15) / cellWidth)), rows: Math.max(1, Math.floor((container.clientHeight - verticalPadding) / cellHeight)), }; }; export type TerminalController = { focus: () => void; fit: () => void; getSelection: () => { text: string; startLine: number; endLine: number } | null; }; type Props = { sessionKey: string; chunks: TerminalChunk[]; onInput: (data: string) => void; /** Fitted size: the emulator has this size, so the PTY should follow. */ onResize: (cols: number, rows: number) => void; /** * Size estimated from the container before Ghostty has measured anything. * Good enough to spawn a shell early, not authoritative: an existing PTY * must not be resized to it. Falls back to `onResize` when omitted. */ onProvisionalSize?: (cols: number, rows: number) => void; theme: TerminalTheme; monoFont: MonoFontOption; fontFamily: string; fontSize: number; className?: string; enableTouchScroll?: boolean; autoFocus?: boolean; isVisible?: boolean; }; const TerminalViewport = React.forwardRef(({ sessionKey, chunks, onInput, onResize, onProvisionalSize, theme, monoFont, fontFamily, fontSize, className, enableTouchScroll = false, autoFocus = true, isVisible = true, }, ref) => { const containerRef = React.useRef(null); const terminalRef = React.useRef(null); const fitRef = React.useRef(null); const inputRef = React.useRef(onInput); const resizeRef = React.useRef(onResize); const provisionalSizeCallbackRef = React.useRef(onProvisionalSize); const lastSizeRef = React.useRef(null); const provisionalSizeRef = React.useRef(null); const lastChunkRef = React.useRef(null); const writeQueueRef = React.useRef(''); const outputRewriteCarryRef = React.useRef(''); const safeResetRef = React.useRef(getGhosttySafeResetSequence(theme.background)); const writingRef = React.useRef(false); // Incremented whenever the replay stream restarts, so a write completing from // before the restart cannot clear the in-flight flag of a newer write. const writeEpochRef = React.useRef(0); const visibleRef = React.useRef(isVisible); const rendererReadyRef = React.useRef(false); const [ready, setReady] = React.useState(0); const [rendererGeneration, setRendererGeneration] = React.useState(0); inputRef.current = onInput; resizeRef.current = onResize; provisionalSizeCallbackRef.current = onProvisionalSize; visibleRef.current = isVisible; safeResetRef.current = getGhosttySafeResetSequence(theme.background); React.useLayoutEffect(() => { const container = containerRef.current; if (!container) return; const size = getProvisionalTerminalSize(container, fontFamily, fontSize); provisionalSizeRef.current = size; if (size) (provisionalSizeCallbackRef.current ?? resizeRef.current)(size.cols, size.rows); }, [fontFamily, fontSize]); const fit = React.useCallback(() => { const container = containerRef.current; const terminal = terminalRef.current; if (!container || !terminal || !fitRef.current || !visibleRef.current) return; const bounds = container.getBoundingClientRect(); if (bounds.width < 24 || bounds.height < 24) return; try { fitRef.current.fit(); const next = { cols: terminal.cols, rows: terminal.rows }; if (!lastSizeRef.current || lastSizeRef.current.cols !== next.cols || lastSizeRef.current.rows !== next.rows) { lastSizeRef.current = next; resizeRef.current(next.cols, next.rows); } if (!rendererReadyRef.current) { rendererReadyRef.current = true; setReady((value) => value + 1); } } catch { /* hidden or detached */ } }, []); const flush = React.useCallback(() => { if (writingRef.current || !writeQueueRef.current || !terminalRef.current) return; const terminal = terminalRef.current; const pending = writeQueueRef.current; writeQueueRef.current = ''; const rewritten = rewriteGhosttyDefaultBackgroundResets( pending, outputRewriteCarryRef.current, safeResetRef.current, ); outputRewriteCarryRef.current = rewritten.carry; if (!rewritten.data) { if (writeQueueRef.current) flush(); return; } writingRef.current = true; const epoch = writeEpochRef.current; terminal.write(rewritten.data, () => { if (terminalRef.current !== terminal || writeEpochRef.current !== epoch) return; writingRef.current = false; if (writeQueueRef.current) flush(); }); }, []); /** * Replay discontinuities (restart, reconnect, buffer reset) only need the VT * state cleared. `Terminal.reset()` frees and rebuilds the WASM terminal while * keeping the canvas, renderer and font atlas, so prefer it over remounting the * whole terminal; the generation bump remains the fallback before the terminal * exists. */ const recreateRenderer = React.useCallback(() => { lastChunkRef.current = null; writeQueueRef.current = ''; outputRewriteCarryRef.current = ''; writingRef.current = false; writeEpochRef.current += 1; const terminal = terminalRef.current; if (!terminal) { setRendererGeneration((value) => value + 1); return; } try { terminal.reset(); const safeReset = safeResetRef.current; if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`); } catch { setRendererGeneration((value) => value + 1); } }, []); React.useEffect(() => { const container = containerRef.current; if (!container) return; let disposed = false; let terminal: GhosttyTerminal | null = null; let observer: ResizeObserver | null = null; let resizeTimeout: ReturnType | null = null; let fitFrame: number | null = null; let subscriptions: Array<{ dispose: () => void }> = []; const handleFocusIn = () => { if (terminal && visibleRef.current) terminal.options.cursorBlink = true; }; const handleFocusOut = (event: FocusEvent) => { if (event.relatedTarget instanceof Node && container.contains(event.relatedTarget)) return; if (terminal) terminal.options.cursorBlink = false; }; const handleWindowFocus = () => { if (terminal && visibleRef.current && container.contains(document.activeElement)) { terminal.options.cursorBlink = true; } }; const handleWindowBlur = () => { if (terminal) terminal.options.cursorBlink = false; }; container.addEventListener('focusin', handleFocusIn); container.addEventListener('focusout', handleFocusOut); window.addEventListener('focus', handleWindowFocus); window.addEventListener('blur', handleWindowBlur); const fonts = waitForTerminalFonts(monoFont); Promise.all([loadGhostty(), fonts.loadedBeforeTimeout]).then(([{ module, ghostty }, fontsLoaded]) => { if (disposed) return; terminal = new module.Terminal({ ...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false), ...(provisionalSizeRef.current ?? {}), }); const fitAddon = new module.FitAddon(); terminal.loadAddon(fitAddon); terminal.open(container); // ghostty-web marks the container contenteditable for touch IME input but // sets autocapitalize/autocorrect only on its hidden textarea. Mobile // keyboards (iOS and Android) therefore auto-capitalize the first letter // of every terminal command; disable IME text mangling on the container. container.setAttribute('autocapitalize', 'off'); container.setAttribute('autocorrect', 'off'); container.setAttribute('spellcheck', 'false'); terminalRef.current = terminal; fitRef.current = fitAddon; subscriptions = [terminal.onData((data) => inputRef.current(data))]; observer = new ResizeObserver(() => { if (resizeTimeout) clearTimeout(resizeTimeout); resizeTimeout = setTimeout(fit, 80); }); observer.observe(container); fit(); const safeReset = safeResetRef.current; if (safeReset) terminal.write(`${safeReset}\u001b[2J\u001b[H`); fitFrame = requestAnimationFrame(fit); if (!fontsLoaded) { void fonts.loaded.then(() => { if (!disposed && terminalRef.current === terminal) { setRendererGeneration((value) => value + 1); } }); } }); return () => { disposed = true; // Removing a focused editable mid-IME-composition wedges Android // WebView's input dispatch (the whole app stops responding to touch). // Blur first so the IME detaches cleanly, and hide the soft keyboard // explicitly on Android before the terminal DOM is torn down. const active = document.activeElement; if (active instanceof HTMLElement && container.contains(active)) { active.blur(); const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor; if (capacitor?.getPlatform?.() === 'android') { void import('@capacitor/keyboard') .then(({ Keyboard }) => Keyboard.hide()) .catch(() => undefined); } } observer?.disconnect(); if (resizeTimeout) clearTimeout(resizeTimeout); if (fitFrame !== null) cancelAnimationFrame(fitFrame); container.removeEventListener('focusin', handleFocusIn); container.removeEventListener('focusout', handleFocusOut); window.removeEventListener('focus', handleWindowFocus); window.removeEventListener('blur', handleWindowBlur); subscriptions.forEach((subscription) => subscription.dispose()); terminal?.dispose(); terminalRef.current = null; fitRef.current = null; lastSizeRef.current = null; lastChunkRef.current = null; writeQueueRef.current = ''; outputRewriteCarryRef.current = ''; writingRef.current = false; writeEpochRef.current += 1; rendererReadyRef.current = false; }; }, [fit, fontFamily, fontSize, monoFont, rendererGeneration, theme]); React.useEffect(() => { const terminal = terminalRef.current; const container = containerRef.current; if (!terminal || !container) return; terminal.options.cursorBlink = isVisible && document.hasFocus() && container.contains(document.activeElement); }, [isVisible, ready]); /** * Snapshot history was laid out by the shell for the PTY size recorded on the * chunk. Writing it into an emulator of another width wraps or joins lines the * shell never wrapped, and the shell's later SIGWINCH redraw only repaints * from its own cursor row down, so the stray fragments stay on screen. Replay * such a chunk at its own size and let the emulator reflow back to the fitted * size; a subsequent PTY resize (when the sizes differ) makes the shell redraw * on top of a consistent screen. * * Only valid while nothing is queued: the write must not overtake bytes that * are still waiting for the emulator. */ const writeReplayAtDrawnSize = React.useCallback((terminal: GhosttyTerminal, chunk: TerminalChunk): boolean => { if (!chunk.size || writingRef.current || writeQueueRef.current) return false; const rewritten = rewriteGhosttyDefaultBackgroundResets( chunk.replayData ?? chunk.data, outputRewriteCarryRef.current, safeResetRef.current, ); outputRewriteCarryRef.current = rewritten.carry; if (!rewritten.data) return true; const fitted = { cols: terminal.cols, rows: terminal.rows }; const resizeForReplay = chunk.size.cols !== fitted.cols || chunk.size.rows !== fitted.rows; if (resizeForReplay) terminal.resize(chunk.size.cols, chunk.size.rows); try { terminal.write(rewritten.data); } finally { if (resizeForReplay) terminal.resize(fitted.cols, fitted.rows); } return true; }, []); React.useEffect(() => { const terminal = terminalRef.current; if (!terminal) return; const { reset, replay, pending } = selectTerminalChunkReplay(chunks, lastChunkRef.current); if (reset) recreateRenderer(); if (pending.length === 0) return; const queued = replay && writeReplayAtDrawnSize(terminal, pending[0]) ? pending.slice(1) : pending; writeQueueRef.current += queued .map((chunk) => replay ? (chunk.replayData ?? chunk.data) : chunk.data) .join(''); lastChunkRef.current = chunks.at(-1)?.id ?? null; flush(); }, [chunks, flush, ready, recreateRenderer, writeReplayAtDrawnSize]); React.useEffect(() => { if (!autoFocus || !isVisible) return; const frame = requestAnimationFrame(() => terminalRef.current?.focus()); return () => cancelAnimationFrame(frame); }, [autoFocus, isVisible, ready, sessionKey]); React.useEffect(() => { const container = containerRef.current; if (!enableTouchScroll || !container) return; // ghostty-web only reads keydown/composition events and preventDefaults // beforeinput without consuming it. Android IMEs deliver text via // beforeinput (their keydown arrives as keyCode 229, which ghostty // ignores), so forward those payloads to the terminal here. Composition // updates are skipped: ghostty commits them itself on compositionend. const handleBeforeInput = (event: Event) => { const input = event as InputEvent; if (input.isComposing) return; switch (input.inputType) { case 'insertText': if (input.data) inputRef.current(input.data); break; case 'insertLineBreak': case 'insertParagraph': inputRef.current('\r'); break; case 'deleteContentBackward': inputRef.current('\x7f'); break; default: break; } }; container.addEventListener('beforeinput', handleBeforeInput); return () => container.removeEventListener('beforeinput', handleBeforeInput); }, [enableTouchScroll, ready]); React.useEffect(() => { const container = containerRef.current; const terminal = terminalRef.current; if (!enableTouchScroll || !container || !terminal) return; let pointerId: number | null = null; let longPressTimeout: ReturnType | null = null; let gesture: 'idle' | 'pending' | 'scrolling' | 'selecting' = 'idle'; let startX = 0; let startY = 0; let lastY = 0; let remainder = 0; let selectionFocus: TerminalCellPosition | null = null; const lineHeight = Math.max(12, fontSize + 2); // Android WebView only raises the soft keyboard for a native tap-focus; the // pointer-captured, touch-action:none tap here focuses programmatically, so // the IME must be summoned explicitly via the Capacitor Keyboard plugin. const showAndroidSoftKeyboard = () => { const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor; if (capacitor?.getPlatform?.() !== 'android') return; void import('@capacitor/keyboard') .then(({ Keyboard }) => Keyboard.show()) .catch(() => undefined); }; const clearLongPress = () => { if (!longPressTimeout) return; clearTimeout(longPressTimeout); longPressTimeout = null; }; const cellFromPoint = (clientX: number, clientY: number) => { const canvas = container.querySelector('canvas'); if (!canvas) return null; return getTerminalCellFromPoint(clientX, clientY, canvas.getBoundingClientRect(), terminal.cols, terminal.rows); }; const dispatchSelectionMouseEvent = ( type: 'mousedown' | 'mousemove', cell: TerminalCellPosition, ) => { const canvas = container.querySelector('canvas'); if (!canvas) return; const bounds = canvas.getBoundingClientRect(); const clientX = bounds.left + ((cell.column + 0.5) / terminal.cols) * bounds.width; const clientY = bounds.top + ((cell.row + 0.5) / terminal.rows) * bounds.height; canvas.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, buttons: 1, clientX, clientY, })); }; const finishSelection = () => { document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, button: 0, buttons: 0, })); }; const down = (event: PointerEvent) => { if (event.pointerType !== 'touch' || pointerId !== null) return; pointerId = event.pointerId; gesture = 'pending'; startX = event.clientX; startY = event.clientY; lastY = event.clientY; remainder = 0; selectionFocus = null; container.setPointerCapture(event.pointerId); longPressTimeout = setTimeout(() => { longPressTimeout = null; if (pointerId !== event.pointerId || gesture !== 'pending') return; const cell = cellFromPoint(startX, startY); if (!cell) return; const buffer = terminal.buffer.active; const lineIndex = Math.max(0, buffer.length - terminal.rows - buffer.viewportY + cell.row); const line = buffer.getLine(lineIndex); const cells = Array.from({ length: terminal.cols }, (_, column) => line?.getCell(column)?.getChars() ?? ''); const word = getTerminalWordRange(cells, cell.column); const selectionAnchor = { column: word.startColumn, row: cell.row }; selectionFocus = { column: word.endColumn, row: cell.row }; gesture = 'selecting'; dispatchSelectionMouseEvent('mousedown', selectionAnchor); dispatchSelectionMouseEvent('mousemove', selectionFocus); }, 350); }; const move = (event: PointerEvent) => { if (pointerId !== event.pointerId) return; if (gesture === 'selecting') { const focus = cellFromPoint(event.clientX, event.clientY); if (focus && (!selectionFocus || focus.column !== selectionFocus.column || focus.row !== selectionFocus.row)) { selectionFocus = focus; dispatchSelectionMouseEvent('mousemove', focus); } if (event.cancelable) event.preventDefault(); return; } if (gesture === 'pending') { const distance = Math.hypot(event.clientX - startX, event.clientY - startY); if (distance < 8) return; clearLongPress(); gesture = 'scrolling'; } if (gesture !== 'scrolling') return; const delta = lastY - event.clientY; lastY = event.clientY; remainder += delta; const lines = Math.trunc(remainder / lineHeight); if (lines) { terminal.scrollLines(lines); remainder -= lines * lineHeight; } if (event.cancelable) event.preventDefault(); }; const up = (event: PointerEvent) => { if (pointerId !== event.pointerId) return; const shouldFocus = gesture === 'pending'; const shouldFinishSelection = gesture === 'selecting'; clearLongPress(); if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); pointerId = null; gesture = 'idle'; if (shouldFinishSelection) finishSelection(); if (shouldFocus) { terminal.focus(); showAndroidSoftKeyboard(); } }; const cancel = (event: PointerEvent) => { if (pointerId !== event.pointerId) return; const shouldFinishSelection = gesture === 'selecting'; clearLongPress(); if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); pointerId = null; gesture = 'idle'; if (shouldFinishSelection) finishSelection(); }; container.addEventListener('pointerdown', down); container.addEventListener('pointermove', move, { passive: false }); container.addEventListener('pointerup', up); container.addEventListener('pointercancel', cancel); return () => { clearLongPress(); container.removeEventListener('pointerdown', down); container.removeEventListener('pointermove', move); container.removeEventListener('pointerup', up); container.removeEventListener('pointercancel', cancel); }; }, [enableTouchScroll, fontSize, ready]); React.useImperativeHandle(ref, () => ({ focus: () => terminalRef.current?.focus(), fit, getSelection: () => { const terminal = terminalRef.current; const range = terminal?.getSelectionPosition(); const text = terminal?.getSelection() ?? ''; if (!range || !text.trim()) return null; return { text, startLine: range.start.y + 1, endLine: range.end.y + 1 }; }, }), [fit]); return (
); }); TerminalViewport.displayName = 'TerminalViewport'; export { TerminalViewport };