import { ClipboardAddon } from "@xterm/addon-clipboard"; import { FitAddon } from "@xterm/addon-fit"; import { ImageAddon } from "@xterm/addon-image"; import { LigaturesAddon } from "@xterm/addon-ligatures"; import { SearchAddon } from "@xterm/addon-search"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import { Terminal, type ITheme } from "@xterm/xterm"; import type { TerminalState } from "@server/shared/messages"; import { openExternalUrl } from "@/utils/open-external-url"; import { type PendingTerminalModifiers, isTerminalModifierDomKey, mergeTerminalModifiers, normalizeDomTerminalKey, normalizeTerminalTransportKey, shouldInterceptDomTerminalKey, } from "@/utils/terminal-keys"; import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; export type TerminalEmulatorRuntimeMountInput = { root: HTMLDivElement; host: HTMLDivElement; initialSnapshot: TerminalState | null; theme: ITheme; }; export type TerminalEmulatorRuntimeCallbacks = { 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; }; type TerminalEmulatorRuntimeDisposables = { disposeInput: () => void; disconnectResizeObserver: () => void; removeWindowResize: () => void; removeWindowFocus: () => void; removeDocumentVisibilityChange: () => void; removeVisualViewportResize: () => void; clearFitInterval: () => void; clearFitTimeouts: () => void; removeFontListeners: () => void; removeTouchListeners: () => void; restoreDocumentStyles: () => void; restoreViewportStyles: () => void; disposeFitAddon: () => void; disposeWebglAddon: () => void; disposeTerminal: () => void; }; type TerminalOutputOperation = { type: "write" | "clear" | "snapshot"; text: string; rows?: number; cols?: number; suppressInput?: boolean; onCommitted?: () => void; }; declare global { interface Window { __paseoTerminal?: Terminal; } } const isMac = typeof navigator !== "undefined" && (/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") || /Mac/i.test((navigator as any).platform ?? "")); const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18; const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000]; const OUTPUT_OPERATION_TIMEOUT_MS = 5_000; const DEFAULT_TERMINAL_FONT_FAMILY = [ // Prefer common developer fonts, with Nerd Font variants for prompt/TUI glyphs. "JetBrains Mono", "JetBrainsMono Nerd Font", "JetBrainsMono NF", "MesloLGM Nerd Font", "MesloLGM NF", "Hack Nerd Font", "FiraCode Nerd Font", // PUA-only fallback (many Nerd glyphs live here on some systems). "Symbols Nerd Font", // System fallbacks. "SF Mono", "Menlo", "Monaco", "Consolas", "'Liberation Mono'", "monospace", ].join(", "); function withOverviewRulerBorderHidden(theme: ITheme): ITheme { return { ...theme, overviewRulerBorder: theme.background ?? "transparent", }; } export class TerminalEmulatorRuntime { private callbacks: TerminalEmulatorRuntimeCallbacks = {}; private pendingModifiers: PendingTerminalModifiers = { ctrl: false, shift: false, alt: false, }; private terminal: Terminal | null = null; private fitAddon: FitAddon | null = null; private fitAndEmitResize: ((force: boolean) => void) | null = null; private lastSize: { rows: number; cols: number } | null = null; private cleanup: (() => void) | null = null; private outputOperations: TerminalOutputOperation[] = []; private inFlightOutputOperation: TerminalOutputOperation | null = null; private inFlightOutputOperationTimeout: ReturnType | null = null; private suppressInput = false; private handleVisibilityRestore = (): void => { if (typeof document !== "undefined" && document.visibilityState !== "visible") { return; } this.fitAndEmitResize?.(true); if (typeof window.requestAnimationFrame === "function") { window.requestAnimationFrame(() => { this.fitAndEmitResize?.(true); }); } }; setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void { this.callbacks = input.callbacks; } setPendingModifiers(input: { pendingModifiers: PendingTerminalModifiers }): void { this.pendingModifiers = input.pendingModifiers; } mount(input: TerminalEmulatorRuntimeMountInput): void { this.unmount(); input.host.innerHTML = ""; this.lastSize = null; const terminal = new Terminal({ allowProposedApi: true, convertEol: false, cursorBlink: true, cursorStyle: "bar", fontFamily: DEFAULT_TERMINAL_FONT_FAMILY, fontSize: 13, lineHeight: 1.0, macOptionIsMeta: true, minimumContrastRatio: 1, rescaleOverlappingGlyphs: true, overviewRuler: { width: 8, }, scrollback: 10_000, theme: withOverviewRulerBorderHidden(input.theme), }); const fitAddon = new FitAddon(); const unicode11Addon = new Unicode11Addon(); let webglAddon: WebglAddon | null = null; terminal.loadAddon(fitAddon); terminal.loadAddon(unicode11Addon); terminal.loadAddon( new WebLinksAddon((event, uri) => { event.preventDefault(); void openExternalUrl(uri); }), ); terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 })); terminal.loadAddon(new ClipboardAddon()); try { terminal.loadAddon(new LigaturesAddon()); } catch { // Ligatures require Font Access API or compatible environment } terminal.open(input.host); try { terminal.unicode.activeVersion = "11"; } catch { // Ignore if unicode API isn't available in this build/runtime. } // Prefer GPU rendering when available. This tends to reduce visible seams in block characters // and improves scroll performance, but must gracefully fall back on platforms without WebGL. let webglAddonRaf: number | null = requestAnimationFrame(() => { webglAddonRaf = null; try { webglAddon = new WebglAddon(); webglAddon.onContextLoss(() => { try { webglAddon?.dispose(); } catch { // ignore } webglAddon = null; // Force a refresh after context loss to avoid visual corruption. try { terminal.refresh(0, terminal.rows - 1); } catch { // ignore } }); terminal.loadAddon(webglAddon); } catch { webglAddon = null; } }); terminal.loadAddon(new ImageAddon()); // Suppress terminal query responses — the server-side headless xterm handles these. // Without this, xterm.js generates DA/CPR responses via onData that feed back // to the PTY as visible text. terminal.parser.registerCsiHandler({ final: "c" }, () => true); terminal.parser.registerCsiHandler({ final: "R" }, () => true); const restoreDocumentStyles = this.applyDocumentBoundsStyles({ root: input.root, }); const restoreViewportStyles = this.applyViewportTouchStyles({ host: input.host, }); this.terminal = terminal; this.fitAddon = fitAddon; window.__paseoTerminal = terminal; const fitAndEmitResize = (force: boolean): void => { const currentTerminal = this.terminal; const currentFitAddon = this.fitAddon; if (!currentTerminal || !currentFitAddon) { return; } if (input.root.offsetWidth === 0 || input.root.offsetHeight === 0) { return; } try { currentFitAddon.fit(); } catch { return; } const nextRows = currentTerminal.rows; const nextCols = currentTerminal.cols; const previous = this.lastSize; if (!force && previous && previous.rows === nextRows && previous.cols === nextCols) { return; } this.lastSize = { rows: nextRows, cols: nextCols }; this.callbacks.onResize?.({ rows: nextRows, cols: nextCols, }); }; this.fitAndEmitResize = fitAndEmitResize; fitAndEmitResize(true); const inputDisposable = terminal.onData((data) => { if (this.suppressInput) { return; } this.callbacks.onInput?.(data); }); terminal.attachCustomKeyEventHandler((event) => { if (event.type !== "keydown" || event.isComposing) { return true; } if (!isMac && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) { const key = event.key.toLowerCase(); // Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT if (key === "c" && terminal.hasSelection()) { void navigator.clipboard.writeText(terminal.getSelection()); return false; } // Ctrl+V: paste from clipboard into terminal if (key === "v") { event.preventDefault(); void navigator.clipboard.readText().then((text) => { if (text) { terminal.paste(text); } }); return false; } return true; } const normalizedKey = normalizeDomTerminalKey(event.key); if (!normalizedKey || isTerminalModifierDomKey(event.key)) { return true; } if ( !shouldInterceptDomTerminalKey({ key: normalizedKey, ctrlKey: event.ctrlKey, shiftKey: event.shiftKey, altKey: event.altKey, metaKey: event.metaKey, pendingModifiers: this.pendingModifiers, }) ) { return true; } const modifiers = mergeTerminalModifiers({ pendingModifiers: this.pendingModifiers, ctrlKey: event.ctrlKey, shiftKey: event.shiftKey, altKey: event.altKey, metaKey: event.metaKey, }); this.callbacks.onTerminalKey?.({ key: normalizeTerminalTransportKey(normalizedKey), ...modifiers, }); if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) { this.callbacks.onPendingModifiersConsumed?.(); } event.preventDefault(); event.stopPropagation(); return false; }); const removeTouchListeners = this.setupTouchScrollHandlers({ root: input.root, host: input.host, terminal, }); const resizeObserver = new ResizeObserver(() => { fitAndEmitResize(false); }); resizeObserver.observe(input.root); resizeObserver.observe(input.host); const windowResizeHandler = () => fitAndEmitResize(false); window.addEventListener("resize", windowResizeHandler); const windowFocusHandler = () => { this.handleVisibilityRestore(); }; window.addEventListener("focus", windowFocusHandler); const documentVisibilityChangeHandler = () => { this.handleVisibilityRestore(); }; document.addEventListener("visibilitychange", documentVisibilityChangeHandler); const visualViewport = window.visualViewport; const visualViewportResizeHandler = () => fitAndEmitResize(false); visualViewport?.addEventListener("resize", visualViewportResizeHandler); const fitInterval = window.setInterval(() => { fitAndEmitResize(false); }, 250); const fitTimeouts = FIT_TIMEOUT_DELAYS_MS.map((delayMs) => window.setTimeout(() => { fitAndEmitResize(true); }, delayMs), ); const fontSet = document.fonts; const fontReadyHandler = () => { fitAndEmitResize(true); }; fontSet?.addEventListener?.("loadingdone", fontReadyHandler); void fontSet?.ready .then(() => { fitAndEmitResize(true); }) .catch(() => { // no-op }); window.setTimeout(() => { fitAndEmitResize(true); }, 0); if (input.initialSnapshot) { this.renderSnapshot({ state: input.initialSnapshot }); } this.processOutputQueue(); const disposables: TerminalEmulatorRuntimeDisposables = { disposeInput: () => { inputDisposable.dispose(); }, disconnectResizeObserver: () => { resizeObserver.disconnect(); }, removeWindowResize: () => { window.removeEventListener("resize", windowResizeHandler); }, removeWindowFocus: () => { window.removeEventListener("focus", windowFocusHandler); }, removeDocumentVisibilityChange: () => { document.removeEventListener("visibilitychange", documentVisibilityChangeHandler); }, removeVisualViewportResize: () => { visualViewport?.removeEventListener("resize", visualViewportResizeHandler); }, clearFitInterval: () => { window.clearInterval(fitInterval); }, clearFitTimeouts: () => { for (const handle of fitTimeouts) { window.clearTimeout(handle); } }, removeFontListeners: () => { fontSet?.removeEventListener?.("loadingdone", fontReadyHandler); }, removeTouchListeners, restoreDocumentStyles, restoreViewportStyles, disposeFitAddon: () => { fitAddon.dispose(); }, disposeWebglAddon: () => { if (webglAddonRaf !== null) { cancelAnimationFrame(webglAddonRaf); webglAddonRaf = null; } try { webglAddon?.dispose(); } catch { // ignore } webglAddon = null; }, disposeTerminal: () => { terminal.dispose(); }, }; this.cleanup = () => { disposables.disposeInput(); disposables.disconnectResizeObserver(); disposables.removeWindowResize(); disposables.removeWindowFocus(); disposables.removeDocumentVisibilityChange(); disposables.removeVisualViewportResize(); disposables.clearFitInterval(); disposables.clearFitTimeouts(); disposables.removeFontListeners(); disposables.removeTouchListeners(); disposables.disposeFitAddon(); disposables.disposeWebglAddon(); disposables.disposeTerminal(); disposables.restoreDocumentStyles(); disposables.restoreViewportStyles(); }; } write(input: { text: string; suppressInput?: boolean; onCommitted?: () => void }): void { if (input.text.length === 0) { input.onCommitted?.(); return; } this.outputOperations.push({ type: "write", text: input.text, suppressInput: input.suppressInput ?? false, ...(input.onCommitted ? { onCommitted: input.onCommitted } : {}), }); this.processOutputQueue(); } clear(input?: { onCommitted?: () => void }): void { this.outputOperations.push({ type: "clear", text: "", suppressInput: false, ...(input?.onCommitted ? { onCommitted: input.onCommitted } : {}), }); this.processOutputQueue(); } renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void { if (!input.state) { this.clear(input); return; } this.outputOperations.push({ type: "snapshot", text: renderTerminalSnapshotToAnsi(input.state), rows: input.state.rows, cols: input.state.cols, suppressInput: true, ...(input.onCommitted ? { onCommitted: input.onCommitted } : {}), }); this.processOutputQueue(); } resize(input?: { force?: boolean }): void { this.fitAndEmitResize?.(input?.force ?? false); } setTheme(input: { theme: ITheme }): void { const terminal = this.terminal; if (!terminal) { return; } try { terminal.options.theme = withOverviewRulerBorderHidden(input.theme); } catch { // ignore return; } try { terminal.refresh(0, Math.max(0, terminal.rows - 1)); } catch { // ignore } } focus(): void { this.terminal?.focus(); } unmount(): void { this.clearInFlightOutputTimeout(); const inFlightOperation = this.inFlightOutputOperation; this.inFlightOutputOperation = null; if (inFlightOperation?.onCommitted) { inFlightOperation.onCommitted(); } const pendingOperations = this.outputOperations.splice(0, this.outputOperations.length); for (const operation of pendingOperations) { operation.onCommitted?.(); } this.cleanup?.(); this.cleanup = null; if (window.__paseoTerminal === this.terminal) { window.__paseoTerminal = undefined; } this.terminal = null; this.fitAddon = null; this.fitAndEmitResize = null; this.lastSize = null; this.suppressInput = false; } private processOutputQueue(): void { if (this.inFlightOutputOperation) { return; } const terminal = this.terminal; if (!terminal) { return; } const operation = this.outputOperations.shift(); if (!operation) { return; } this.inFlightOutputOperation = operation; const previousSuppressInput = this.suppressInput; if (operation.type === "write") { this.suppressInput = Boolean(operation.suppressInput); } const finalizeOperation = (expectedOperation: TerminalOutputOperation) => { if (this.inFlightOutputOperation !== expectedOperation) { return; } this.inFlightOutputOperation = null; this.clearInFlightOutputTimeout(); this.suppressInput = previousSuppressInput; expectedOperation.onCommitted?.(); this.processOutputQueue(); }; if (operation.type === "clear") { terminal.reset(); finalizeOperation(operation); return; } if (operation.type === "snapshot") { try { if ( typeof operation.cols === "number" && typeof operation.rows === "number" && (terminal.cols !== operation.cols || terminal.rows !== operation.rows) ) { terminal.resize(operation.cols, operation.rows); } terminal.reset(); } catch { finalizeOperation(operation); return; } } const text = operation.text; this.inFlightOutputOperationTimeout = setTimeout(() => { finalizeOperation(operation); }, OUTPUT_OPERATION_TIMEOUT_MS); try { terminal.write(text, () => { finalizeOperation(operation); }); } catch { finalizeOperation(operation); } } private clearInFlightOutputTimeout(): void { if (!this.inFlightOutputOperationTimeout) { return; } clearTimeout(this.inFlightOutputOperationTimeout); this.inFlightOutputOperationTimeout = null; } private applyDocumentBoundsStyles(input: { root: HTMLDivElement }): () => void { const documentElement = document.documentElement; const body = document.body; const rootContainer = input.root.parentElement; const previousDocumentElementOverflow = documentElement.style.overflow; const previousDocumentElementWidth = documentElement.style.width; const previousDocumentElementHeight = documentElement.style.height; const previousDocumentElementTextSizeAdjust = documentElement.style.getPropertyValue("text-size-adjust"); const previousDocumentElementWebkitTextSizeAdjust = documentElement.style.getPropertyValue( "-webkit-text-size-adjust", ); const previousBodyOverflow = body.style.overflow; const previousBodyWidth = body.style.width; const previousBodyHeight = body.style.height; const previousBodyMargin = body.style.margin; const previousBodyPadding = body.style.padding; const previousBodyTextSizeAdjust = body.style.getPropertyValue("text-size-adjust"); const previousBodyWebkitTextSizeAdjust = body.style.getPropertyValue( "-webkit-text-size-adjust", ); const previousRootOverflow = rootContainer?.style.overflow ?? ""; const previousRootWidth = rootContainer?.style.width ?? ""; const previousRootHeight = rootContainer?.style.height ?? ""; documentElement.style.overflow = "hidden"; documentElement.style.width = "100%"; documentElement.style.height = "100%"; documentElement.style.setProperty("text-size-adjust", "100%"); documentElement.style.setProperty("-webkit-text-size-adjust", "100%"); body.style.overflow = "hidden"; body.style.width = "100%"; body.style.height = "100%"; body.style.margin = "0"; body.style.padding = "0"; body.style.setProperty("text-size-adjust", "100%"); body.style.setProperty("-webkit-text-size-adjust", "100%"); if (rootContainer) { rootContainer.style.overflow = "hidden"; rootContainer.style.width = "100%"; rootContainer.style.height = "100%"; } return () => { documentElement.style.overflow = previousDocumentElementOverflow; documentElement.style.width = previousDocumentElementWidth; documentElement.style.height = previousDocumentElementHeight; documentElement.style.setProperty("text-size-adjust", previousDocumentElementTextSizeAdjust); documentElement.style.setProperty( "-webkit-text-size-adjust", previousDocumentElementWebkitTextSizeAdjust, ); body.style.overflow = previousBodyOverflow; body.style.width = previousBodyWidth; body.style.height = previousBodyHeight; body.style.margin = previousBodyMargin; body.style.padding = previousBodyPadding; body.style.setProperty("text-size-adjust", previousBodyTextSizeAdjust); body.style.setProperty("-webkit-text-size-adjust", previousBodyWebkitTextSizeAdjust); if (rootContainer) { rootContainer.style.overflow = previousRootOverflow; rootContainer.style.width = previousRootWidth; rootContainer.style.height = previousRootHeight; } }; } private applyViewportTouchStyles(input: { host: HTMLDivElement }): () => void { const viewportElement = input.host.querySelector(".xterm-viewport"); const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? ""; const previousViewportTouchAction = viewportElement?.style.touchAction ?? ""; const previousViewportOverflowY = viewportElement?.style.overflowY ?? ""; const previousViewportOverflowX = viewportElement?.style.overflowX ?? ""; const previousViewportPointerEvents = viewportElement?.style.pointerEvents ?? ""; const previousViewportWebkitOverflowScrolling = viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? ""; if (viewportElement) { viewportElement.style.overscrollBehavior = "none"; viewportElement.style.touchAction = "pan-y"; viewportElement.style.overflowY = "auto"; viewportElement.style.overflowX = "hidden"; viewportElement.style.pointerEvents = "auto"; viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch"); } return () => { if (viewportElement) { viewportElement.style.overscrollBehavior = previousViewportOverscroll; viewportElement.style.touchAction = previousViewportTouchAction; viewportElement.style.overflowY = previousViewportOverflowY; viewportElement.style.overflowX = previousViewportOverflowX; viewportElement.style.pointerEvents = previousViewportPointerEvents; viewportElement.style.setProperty( "-webkit-overflow-scrolling", previousViewportWebkitOverflowScrolling, ); } }; } private setupTouchScrollHandlers(input: { root: HTMLDivElement; host: HTMLDivElement; terminal: Terminal; }): () => void { let touchScrollRemainderPx = 0; const measuredLineHeight = input.host.querySelector(".xterm-rows > div")?.getBoundingClientRect().height ?? 0; const touchScrollLineHeightPx = measuredLineHeight > 0 ? measuredLineHeight : DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX; const activeTouch = { identifier: -1, startX: 0, startY: 0, lastX: 0, lastY: 0, mode: null as "vertical" | "horizontal" | null, }; const touchStartHandler = (event: TouchEvent) => { if (event.touches.length !== 1) { touchScrollRemainderPx = 0; activeTouch.identifier = -1; activeTouch.mode = null; return; } const touch = event.touches[0]; if (!touch) { touchScrollRemainderPx = 0; activeTouch.identifier = -1; activeTouch.mode = null; return; } activeTouch.identifier = touch.identifier; activeTouch.startX = touch.clientX; activeTouch.startY = touch.clientY; activeTouch.lastX = touch.clientX; activeTouch.lastY = touch.clientY; activeTouch.mode = null; touchScrollRemainderPx = 0; }; const touchMoveHandler = (event: TouchEvent) => { if (event.touches.length !== 1) { return; } const touch = Array.from(event.touches).find( (candidate) => candidate.identifier === activeTouch.identifier, ); if (!touch) { return; } const totalDeltaX = touch.clientX - activeTouch.startX; const totalDeltaY = touch.clientY - activeTouch.startY; if (activeTouch.mode === null) { const absX = Math.abs(totalDeltaX); const absY = Math.abs(totalDeltaY); if (absX > 8 || absY > 8) { activeTouch.mode = absY >= absX ? "vertical" : "horizontal"; } } const deltaY = touch.clientY - activeTouch.lastY; activeTouch.lastX = touch.clientX; activeTouch.lastY = touch.clientY; if (activeTouch.mode !== "vertical") { return; } touchScrollRemainderPx += deltaY; const lineDelta = Math.trunc(touchScrollRemainderPx / touchScrollLineHeightPx); if (lineDelta !== 0) { input.terminal.scrollLines(-lineDelta); touchScrollRemainderPx -= lineDelta * touchScrollLineHeightPx; } event.preventDefault(); }; const touchEndHandler = (event: TouchEvent) => { const activeTouchEnded = Array.from(event.changedTouches).some( (touch) => touch.identifier === activeTouch.identifier, ); if (activeTouchEnded || event.touches.length === 0) { touchScrollRemainderPx = 0; activeTouch.identifier = -1; activeTouch.mode = null; } }; const touchCancelHandler = () => { touchScrollRemainderPx = 0; activeTouch.identifier = -1; activeTouch.mode = null; }; input.root.addEventListener("touchstart", touchStartHandler, { passive: true }); input.root.addEventListener("touchmove", touchMoveHandler, { passive: false }); input.root.addEventListener("touchend", touchEndHandler, { passive: true }); input.root.addEventListener("touchcancel", touchCancelHandler, { passive: true }); return () => { input.root.removeEventListener("touchstart", touchStartHandler); input.root.removeEventListener("touchmove", touchMoveHandler); input.root.removeEventListener("touchend", touchEndHandler); input.root.removeEventListener("touchcancel", touchCancelHandler); }; } }