diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 92e30000427..2db85dafc4d 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -57,7 +57,7 @@ describe("ElectronProtocol", () => { assert.equal(yield* Effect.promise(() => response.text()), "ok"); assert.include( response.headers.get("content-security-policy") ?? "", - "script-src 'self' 'unsafe-inline' https://clerk.t3.codes https://challenges.cloudflare.com", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://clerk.t3.codes https://challenges.cloudflare.com", ); assert.include( response.headers.get("content-security-policy") ?? "", @@ -212,6 +212,7 @@ describe("ElectronProtocol", () => { assert.deepEqual(directives["script-src"], [ "'self'", "'unsafe-inline'", + "'wasm-unsafe-eval'", "https://clerk.t3.codes", "https://challenges.cloudflare.com", ]); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 03c7ef64fd7..9d5a47806b3 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -71,6 +71,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat const scriptSources = [ "'self'", "'unsafe-inline'", + "'wasm-unsafe-eval'", ...(clerkOrigin ? [clerkOrigin] : []), "https://challenges.cloudflare.com", ]; diff --git a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md index 990dd4bbe9c..0af582c5900 100644 --- a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md +++ b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md @@ -18,13 +18,14 @@ Ghostty's MIT license applies to the vendored framework. Keep this notice in syn ## Ghostty / libghostty-vt The Android terminal renderer vendors upstream `libghostty-vt` shared libraries and C headers. +The web terminal vendors a WebAssembly build from the same revision and uses the same C ABI. - Upstream project: https://github.com/ghostty-org/ghostty - Vendored revision: `9f62873bf195e4d8a762d768a1405a5f2f7b1697` - License: MIT -Ghostty's MIT license applies to the vendored Android libraries. Keep this notice in sync when -updating `Vendor/libghostty-vt`. +Ghostty's MIT license applies to the vendored Android and web libraries. Keep this notice and both +artifacts in sync when updating `Vendor/libghostty-vt`. ## MesloLGS NF (Android terminal font) diff --git a/apps/web/package.json b/apps/web/package.json index a25e42cc84b..53b38b80ba0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "build:ghostty-wasm": "bash scripts/build-libghostty-wasm.sh", "dev": "vp dev", "build": "vp build", "preview": "vp preview", @@ -31,8 +32,6 @@ "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", - "@xterm/addon-fit": "^0.11.0", - "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "effect": "catalog:", "jose": "catalog:", diff --git a/apps/web/scripts/build-libghostty-wasm.sh b/apps/web/scripts/build-libghostty-wasm.sh new file mode 100755 index 00000000000..bceb1218c05 --- /dev/null +++ b/apps/web/scripts/build-libghostty-wasm.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WEB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +REPO_DIR="$(cd "${WEB_DIR}/../.." && pwd)" +MOBILE_VENDOR_DIR="${REPO_DIR}/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt" +VENDOR_DIR="${WEB_DIR}/src/terminal/ghostty/vendor" + +GHOSTTY_REVISION="$(tr -d '[:space:]' < "${MOBILE_VENDOR_DIR}/VERSION")" +GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${HOME}/.cache/t3code/ghostty-${GHOSTTY_REVISION:0:8}}" +GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}" +GHOSTTY_ZIG="${GHOSTTY_ZIG:-}" + +log() { + printf '[libghostty-vt-wasm] %s\n' "$*" +} + +die() { + printf '[libghostty-vt-wasm] error: %s\n' "$*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +ensure_zig() { + if [[ -n "${GHOSTTY_ZIG}" ]]; then + [[ -x "${GHOSTTY_ZIG}" ]] || die "GHOSTTY_ZIG is not executable: ${GHOSTTY_ZIG}" + return + fi + if command -v zig >/dev/null 2>&1 && [[ "$(zig version)" == "${GHOSTTY_ZIG_VERSION}" ]]; then + GHOSTTY_ZIG="$(command -v zig)" + return + fi + + local host_os host_arch cache_dir + host_os="$(uname -s | tr '[:upper:]' '[:lower:]')" + host_arch="$(uname -m)" + case "${host_os}" in + darwin) host_os="macos" ;; + linux) ;; + *) die "unsupported host OS for Zig download: ${host_os}" ;; + esac + case "${host_arch}" in + arm64) host_arch="aarch64" ;; + aarch64 | x86_64) ;; + *) die "unsupported host architecture: ${host_arch}" ;; + esac + + cache_dir="${HOME}/.cache/t3code/zig-${GHOSTTY_ZIG_VERSION}" + GHOSTTY_ZIG="${cache_dir}/zig" + if [[ -x "${GHOSTTY_ZIG}" ]]; then + return + fi + + require_cmd curl + require_cmd tar + mkdir -p "${cache_dir}" + log "downloading Zig ${GHOSTTY_ZIG_VERSION}" + curl -fsSL \ + "https://ziglang.org/download/${GHOSTTY_ZIG_VERSION}/zig-${host_arch}-${host_os}-${GHOSTTY_ZIG_VERSION}.tar.xz" \ + | tar -xJ --strip-components=1 -C "${cache_dir}" +} + +ensure_ghostty_source() { + require_cmd git + if [[ ! -d "${GHOSTTY_SOURCE_DIR}/.git" ]]; then + mkdir -p "$(dirname "${GHOSTTY_SOURCE_DIR}")" + log "cloning Ghostty ${GHOSTTY_REVISION}" + git clone --filter=blob:none --no-checkout https://github.com/ghostty-org/ghostty.git \ + "${GHOSTTY_SOURCE_DIR}" + fi + + # A cached checkout may still be on a previously pinned revision; converge on + # the pinned one instead of failing the rebuild. + local actual_revision + actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD 2>/dev/null || echo none)" + if [[ "${actual_revision}" != "${GHOSTTY_REVISION}" ]]; then + log "checking out Ghostty ${GHOSTTY_REVISION}" + git -C "${GHOSTTY_SOURCE_DIR}" fetch --depth=1 origin "${GHOSTTY_REVISION}" + git -C "${GHOSTTY_SOURCE_DIR}" checkout --detach "${GHOSTTY_REVISION}" + fi + + actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD)" + [[ "${actual_revision}" == "${GHOSTTY_REVISION}" ]] || \ + die "expected Ghostty ${GHOSTTY_REVISION}, found ${actual_revision}" +} + +ensure_zig +ensure_ghostty_source + +build_root="$(mktemp -d)" +trap 'rm -rf "${build_root}"' EXIT + +log "building ${GHOSTTY_REVISION} for wasm32-freestanding" +( + cd "${GHOSTTY_SOURCE_DIR}" + # The pinned revision rides along as semver build metadata so the artifact + # identifies its own provenance through ghostty_build_info(); mobile's + # VERSION file stays the single source of truth for the pin. + "${GHOSTTY_ZIG}" build \ + -Demit-lib-vt \ + -Dtarget=wasm32-freestanding \ + -Doptimize=ReleaseSmall \ + -Dstrip=true \ + -Dlib-version-string="0.1.0-dev+${GHOSTTY_REVISION}" \ + -p "${build_root}" +) + +mkdir -p "${VENDOR_DIR}" +cp "${build_root}/bin/ghostty-vt.wasm" "${VENDOR_DIR}/ghostty-vt.wasm" +"${GHOSTTY_ZIG}" build-exe \ + "${SCRIPT_DIR}/ghostty-write-pty.zig" \ + -target wasm32-freestanding \ + -O ReleaseSmall \ + -fno-entry \ + -rdynamic \ + -femit-bin="${VENDOR_DIR}/ghostty-write-pty.wasm" +chmod 0644 "${VENDOR_DIR}/ghostty-write-pty.wasm" +log "wrote ${VENDOR_DIR}/ghostty-vt.wasm" diff --git a/apps/web/scripts/ghostty-write-pty.zig b/apps/web/scripts/ghostty-write-pty.zig new file mode 100644 index 00000000000..dbe6688f5e8 --- /dev/null +++ b/apps/web/scripts/ghostty-write-pty.zig @@ -0,0 +1,5 @@ +extern "env" fn t3_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void; + +export fn ghostty_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void { + t3_write_pty(terminal, userdata, data, len); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f0a918e136f..2b9eda1a787 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -704,6 +704,19 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra () => drawerTerminalSessions.map((session) => session.target.terminalId), [drawerTerminalSessions], ); + // Every client-side id source participates in allocation: the server list + // lags fresh opens, and panel terminals are filtered out of the drawer's + // sessions — an id collision attaches two viewports to one PTY session. + const allocatableTerminalIds = useMemo( + () => [ + ...new Set([ + ...serverOrderedTerminalIds, + ...terminalUiState.terminalIds, + ...panelTerminalIds, + ]), + ], + [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], + ); const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); const storeSplitTerminalVertical = useTerminalUiStateStore( @@ -773,7 +786,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra if (!cwd) { return; } - const terminalId = nextTerminalId(serverOrderedTerminalIds); + const terminalId = nextTerminalId(allocatableTerminalIds); storeSplitTerminal(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ @@ -787,11 +800,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }, }); }, [ + allocatableTerminalIds, bumpFocusRequestId, cwd, effectiveWorktreePath, runtimeEnv, - serverOrderedTerminalIds, storeSplitTerminal, threadId, threadRef, @@ -801,7 +814,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra if (!cwd) { return; } - const terminalId = nextTerminalId(serverOrderedTerminalIds); + const terminalId = nextTerminalId(allocatableTerminalIds); storeSplitTerminalVertical(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ @@ -815,12 +828,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }, }); }, [ + allocatableTerminalIds, bumpFocusRequestId, cwd, effectiveWorktreePath, openTerminal, runtimeEnv, - serverOrderedTerminalIds, storeSplitTerminalVertical, threadId, threadRef, @@ -830,7 +843,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra if (!cwd) { return; } - const terminalId = nextTerminalId(serverOrderedTerminalIds); + const terminalId = nextTerminalId(allocatableTerminalIds); storeNewTerminal(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ @@ -847,8 +860,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId, cwd, effectiveWorktreePath, + allocatableTerminalIds, runtimeEnv, - serverOrderedTerminalIds, storeNewTerminal, threadId, threadRef, @@ -1521,6 +1534,10 @@ function ChatViewContent(props: ChatViewProps) { ), [rightPanelState.surfaces], ); + const allocatableActiveTerminalIds = useMemo( + () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])], + [activeKnownTerminalIds, panelTerminalIds], + ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; @@ -2628,7 +2645,7 @@ function ChatViewContent(props: ChatViewProps) { if (!cwdForOpen) { return; } - const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); + const terminalId = nextTerminalId(allocatableActiveTerminalIds); storeEnsureTerminal(activeThreadRef, terminalId, { open: true }); void openTerminal({ environmentId, @@ -2647,15 +2664,14 @@ function ChatViewContent(props: ChatViewProps) { } setTerminalOpen(nextOpen); }, [ - activeKnownTerminalIds, activeProject, activeThreadId, activeThreadRef, activeThreadWorktreePath, + allocatableActiveTerminalIds, environmentId, gitCwd, openTerminal, - panelTerminalIds, setTerminalOpen, storeEnsureTerminal, terminalUiState.terminalIds.length, @@ -2670,7 +2686,7 @@ function ChatViewContent(props: ChatViewProps) { if (!cwdForOpen) { return; } - const terminalId = nextTerminalId(activeKnownTerminalIds); + const terminalId = nextTerminalId(allocatableActiveTerminalIds); if (direction === "vertical") { storeSplitTerminalVertical(activeThreadRef, terminalId); } else { @@ -2693,8 +2709,8 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeProject, - activeKnownTerminalIds, activeThreadId, + allocatableActiveTerminalIds, activeThreadRef, openTerminal, activeThreadWorktreePath, @@ -2713,7 +2729,7 @@ function ChatViewContent(props: ChatViewProps) { if (!cwdForOpen) { return; } - const terminalId = nextTerminalId(activeKnownTerminalIds); + const terminalId = nextTerminalId(allocatableActiveTerminalIds); storeNewTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ @@ -2731,8 +2747,8 @@ function ChatViewContent(props: ChatViewProps) { }); }, [ activeProject, - activeKnownTerminalIds, activeThreadId, + allocatableActiveTerminalIds, activeThreadRef, openTerminal, activeThreadWorktreePath, @@ -2818,7 +2834,7 @@ function ChatViewContent(props: ChatViewProps) { ...(options?.env ? { extraEnv: options.env } : {}), }); const targetTerminalId = shouldCreateNewTerminal - ? nextTerminalId(activeKnownTerminalIds) + ? nextTerminalId(allocatableActiveTerminalIds) : baseTerminalId; const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal ? { @@ -2886,6 +2902,7 @@ function ChatViewContent(props: ChatViewProps) { environmentId, openTerminal, activeKnownTerminalIds, + allocatableActiveTerminalIds, runningTerminalIds, terminalUiState.activeTerminalId, writeTerminal, @@ -3144,7 +3161,7 @@ function ChatViewContent(props: ChatViewProps) { const addTerminalSurface = useCallback(() => { if (!activeThreadRef || !activeThreadId || !activeProject) return; const cwd = gitCwd ?? activeProject.workspaceRoot; - const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); + const terminalId = nextTerminalId(allocatableActiveTerminalIds); useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ @@ -3161,14 +3178,13 @@ function ChatViewContent(props: ChatViewProps) { }, }); }, [ - activeKnownTerminalIds, activeProject, activeThreadId, activeThreadRef, activeThreadWorktreePath, + allocatableActiveTerminalIds, gitCwd, openTerminal, - panelTerminalIds, ]); const splitPanelTerminal = useCallback( (direction: "horizontal" | "vertical" = "horizontal") => { @@ -3181,7 +3197,7 @@ function ChatViewContent(props: ChatViewProps) { ) { return; } - const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); + const terminalId = nextTerminalId(allocatableActiveTerminalIds); const cwd = gitCwd ?? activeProject.workspaceRoot; useRightPanelStore .getState() @@ -3202,15 +3218,14 @@ function ChatViewContent(props: ChatViewProps) { }); }, [ - activeKnownTerminalIds, activeProject, activeRightPanelSurface, activeThreadId, activeThreadRef, activeThreadWorktreePath, + allocatableActiveTerminalIds, gitCwd, openTerminal, - panelTerminalIds, ], ); const splitPanelTerminalVertical = useCallback(() => { diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 9f483af3c3b..e60d1d71678 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveTerminalSelectionActionPosition, + shouldHandleTerminalExit, shouldHandleTerminalSelectionMouseUp, terminalSelectionActionDelayForClickCount, + terminalSelectionLineRange, } from "./ThreadTerminalDrawer"; describe("resolveTerminalSelectionActionPosition", () => { @@ -72,4 +74,19 @@ describe("resolveTerminalSelectionActionPosition", () => { expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false); expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false); }); + + it("uses Ghostty's physical screen range for visually wrapped selections", () => { + expect( + terminalSelectionLineRange({ + start: { y: 4 }, + end: { y: 6 }, + }), + ).toEqual({ lineStart: 5, lineEnd: 7 }); + }); + + it("handles an exit that lands while the terminal surface is still loading", () => { + expect(shouldHandleTerminalExit("exited", "running", false)).toBe(true); + expect(shouldHandleTerminalExit("exited", "exited", false)).toBe(false); + expect(shouldHandleTerminalExit("closed", "running", true)).toBe(false); + }); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 8591c24c71a..dd7da738626 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,9 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; -import { FitAddon } from "@xterm/addon-fit"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; import { Plus, SquareSplitHorizontal, @@ -18,7 +18,6 @@ import { type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { Terminal, type ITheme } from "@xterm/xterm"; import { type PointerEvent as ReactPointerEvent, type ReactNode, @@ -34,15 +33,13 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; -import { useOpenInPreferredEditor } from "../editorPreferences"; import { - collectWrappedTerminalLinkLine, - extractTerminalLinks, - isTerminalLinkActivation, - resolvePathLinkTarget, - resolveWrappedTerminalLinkRange, - wrappedTerminalLinkRangeIntersectsBufferLine, -} from "../terminal-links"; + GhosttyTerminalSurface, + type GhosttyTerminalSurfaceOptions, +} from "~/terminal/ghostty/surface"; +import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; +import { useOpenInPreferredEditor } from "../editorPreferences"; +import { isTerminalLinkActivation, resolvePathLinkTarget } from "../terminal-links"; import { isDiffToggleShortcut, isTerminalClearShortcut, @@ -82,24 +79,34 @@ function clampDrawerHeight(height: number): number { return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight); } -function writeSystemMessage(terminal: Terminal, message: string): void { +function writeSystemMessage(terminal: GhosttyTerminalSurface, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } -function writeTerminalBuffer(terminal: Terminal, buffer: string): void { - terminal.write("\u001bc"); - if (buffer.length > 0) { - terminal.write(buffer); - } +function writeTerminalBuffer(terminal: GhosttyTerminalSurface, buffer: string): void { + terminal.resetAndWrite(buffer); } -function fitTerminalSafely(fitAddon: FitAddon): boolean { - try { - fitAddon.fit(); - return true; - } catch { - return false; - } +function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor { + if (typeof document === "undefined") return fallback; + + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) return fallback; + + context.clearRect(0, 0, 1, 1); + context.fillStyle = value; + context.fillRect(0, 0, 1, 1); + const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data; + if (alpha === 0) return fallback; + + return { + r: red ?? fallback.r, + g: green ?? fallback.g, + b: blue ?? fallback.b, + }; } function runtimeEnvSignature(runtimeEnv: Record | undefined): string { @@ -124,7 +131,7 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri return value ?? fallback; } -function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { +function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { const isDark = document.documentElement.classList.contains("dark"); const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; @@ -143,86 +150,22 @@ function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { normalizeComputedColor(bodyStyles.color, fallbackForeground), ); - if (isDark) { - return { + return { + background: parseTerminalColor( background, + isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 }, + ), + foreground: parseTerminalColor( foreground, - cursor: "rgb(180, 203, 255)", - selectionBackground: "rgba(180, 203, 255, 0.25)", - scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)", - scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)", - scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)", - black: "rgb(24, 30, 38)", - red: "rgb(255, 122, 142)", - green: "rgb(134, 231, 149)", - yellow: "rgb(244, 205, 114)", - blue: "rgb(137, 190, 255)", - magenta: "rgb(208, 176, 255)", - cyan: "rgb(124, 232, 237)", - white: "rgb(210, 218, 230)", - brightBlack: "rgb(110, 120, 136)", - brightRed: "rgb(255, 168, 180)", - brightGreen: "rgb(176, 245, 186)", - brightYellow: "rgb(255, 224, 149)", - brightBlue: "rgb(174, 210, 255)", - brightMagenta: "rgb(229, 203, 255)", - brightCyan: "rgb(167, 244, 247)", - brightWhite: "rgb(244, 247, 252)", - }; - } - - return { - background, - foreground, - cursor: "rgb(38, 56, 78)", - selectionBackground: "rgba(37, 63, 99, 0.2)", - scrollbarSliderBackground: "rgba(0, 0, 0, 0.15)", - scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)", - scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.3)", - black: "rgb(44, 53, 66)", - red: "rgb(191, 70, 87)", - green: "rgb(60, 126, 86)", - yellow: "rgb(146, 112, 35)", - blue: "rgb(72, 102, 163)", - magenta: "rgb(132, 86, 149)", - cyan: "rgb(53, 127, 141)", - white: "rgb(210, 215, 223)", - brightBlack: "rgb(112, 123, 140)", - brightRed: "rgb(212, 95, 112)", - brightGreen: "rgb(85, 148, 111)", - brightYellow: "rgb(173, 133, 45)", - brightBlue: "rgb(91, 124, 194)", - brightMagenta: "rgb(153, 107, 172)", - brightCyan: "rgb(70, 149, 164)", - brightWhite: "rgb(236, 240, 246)", + isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 }, + ), + cursor: isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, + // Matches the xterm selection overlays this renderer replaced; the text + // color underneath is left unchanged for contrast in both themes. + selectionBackground: isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)", }; } -function getTerminalSelectionRect(mountElement: HTMLElement): DOMRect | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { - return null; - } - - const range = selection.getRangeAt(0); - const commonAncestor = range.commonAncestorContainer; - const selectionRoot = - commonAncestor instanceof Element ? commonAncestor : commonAncestor.parentElement; - if (!(selectionRoot instanceof Element) || !mountElement.contains(selectionRoot)) { - return null; - } - - const rects = Array.from(range.getClientRects()).filter( - (rect) => rect.width > 0 || rect.height > 0, - ); - if (rects.length > 0) { - return rects[rects.length - 1] ?? null; - } - - const boundingRect = range.getBoundingClientRect(); - return boundingRect.width > 0 || boundingRect.height > 0 ? boundingRect : null; -} - export function resolveTerminalSelectionActionPosition(options: { bounds: { left: number; top: number; width: number; height: number }; selectionRect: { right: number; bottom: number } | null; @@ -269,6 +212,27 @@ export function shouldHandleTerminalSelectionMouseUp( return selectionGestureActive && button === 0; } +export function terminalSelectionLineRange(position: { + start: { y: number }; + end: { y: number }; +}): { lineStart: number; lineEnd: number } { + const lineStart = position.start.y + 1; + return { + lineStart, + lineEnd: Math.max(lineStart, position.end.y + 1), + }; +} + +export function shouldHandleTerminalExit( + current: TerminalSessionState["status"], + synchronized: TerminalSessionState["status"], + alreadyHandled: boolean, +): boolean { + return ( + (current === "closed" || current === "exited") && current !== synchronized && !alreadyHandled + ); +} + interface TerminalViewportProps { threadRef: ScopedThreadRef; threadId: ThreadId; @@ -309,8 +273,7 @@ export function TerminalViewport({ keybindings, }: TerminalViewportProps) { const containerRef = useRef(null); - const terminalRef = useRef(null); - const fitAddonRef = useRef(null); + const terminalRef = useRef(null); const environmentId = threadRef.environmentId; const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( @@ -367,6 +330,24 @@ export function TerminalViewport({ const terminalBuffer = terminalSession.buffer; const terminalError = terminalSession.error; const terminalStatus = terminalSession.status; + const synchronizedStatusRef = useRef("closed"); + const synchronizeTerminalStatus = useEffectEvent( + (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"]) => { + const synchronized = synchronizedStatusRef.current; + if (status === "running") { + hasHandledExitRef.current = false; + } else if (shouldHandleTerminalExit(status, synchronized, hasHandledExitRef.current)) { + hasHandledExitRef.current = true; + writeSystemMessage(terminal, status === "closed" ? "Terminal closed" : "Process exited"); + window.setTimeout(() => { + if (hasHandledExitRef.current) { + handleSessionExited(); + } + }, 0); + } + synchronizedStatusRef.current = status; + }, + ); const terminalVersion = terminalSession.version; const previousSessionRef = useRef({ buffer: terminalBuffer, @@ -374,6 +355,13 @@ export function TerminalViewport({ status: terminalStatus, version: terminalVersion, }); + const latestSessionRef = useRef(previousSessionRef.current); + latestSessionRef.current = { + buffer: terminalBuffer, + error: terminalError, + status: terminalStatus, + version: terminalVersion, + }; useEffect(() => { keybindingsRef.current = keybindings; @@ -384,356 +372,353 @@ export function TerminalViewport({ if (!mount) return; const localApi = readLocalApi(); - - const fitAddon = new FitAddon(); - const terminal = new Terminal({ - cursorBlink: true, - lineHeight: 1, - fontSize: 12, - scrollback: 5_000, - fontFamily: - '"SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace', - theme: terminalThemeFromApp(mount), - }); - terminal.loadAddon(fitAddon); - terminal.open(mount); - fitTerminalSafely(fitAddon); - - terminalRef.current = terminal; - fitAddonRef.current = fitAddon; - previousSessionRef.current = { - buffer: "", - status: "closed", - error: null, - version: 0, - }; - - const clearSelectionAction = () => { - selectionActionRequestIdRef.current += 1; - if (selectionActionTimerRef.current !== null) { - window.clearTimeout(selectionActionTimerRef.current); - selectionActionTimerRef.current = null; - } - }; - - const readSelectionAction = (): { - position: { x: number; y: number }; - clipboardText: string; - selection: TerminalContextSelection; - } | null => { - const activeTerminal = terminalRef.current; - const mountElement = containerRef.current; - if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { - return null; - } - const selectionText = activeTerminal.getSelection(); - const selectionPosition = activeTerminal.getSelectionPosition(); - const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); - if (!selectionPosition || normalizedText.length === 0) { + let cancelled = false; + let teardown: (() => void) | null = null; + let setupTerminal: GhosttyTerminalSurface | null = null; + let setupCleanups: Array<() => void> = []; + + const setup = async (): Promise<(() => void) | null> => { + const terminalOptions: GhosttyTerminalSurfaceOptions = { + theme: terminalThemeFromApp(mount), + onData: (data) => handleData(data), + onResize: (cols, rows) => void resizeTerminal(cols, rows), + onSelectionChange: () => handleSelectionChange(), + onCopy: (text) => handleCopy(text), + beforeKey: (event) => handleBeforeKey(event), + onLinkActivate: (text, event) => handleLinkActivate(text, event), + }; + const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions); + if (cancelled) { + terminal.dispose(); return null; } - const lineStart = selectionPosition.start.y + 1; - const lineCount = normalizedText.split("\n").length; - const lineEnd = Math.max(lineStart, lineStart + lineCount - 1); - const bounds = mountElement.getBoundingClientRect(); - const selectionRect = getTerminalSelectionRect(mountElement); - const position = resolveTerminalSelectionActionPosition({ - bounds, - selectionRect: - selectionRect === null - ? null - : { right: selectionRect.right, bottom: selectionRect.bottom }, - pointer: selectionPointerRef.current, - }); - return { - position, - clipboardText: selectionText, - selection: { - terminalId, - terminalLabel: readTerminalLabel(), - lineStart, - lineEnd, - text: normalizedText, - }, + // The theme observer is not installed yet, so re-read the theme in case + // the app toggled light/dark while the WASM surface was loading. + terminal.setTheme(terminalThemeFromApp(mount)); + setupTerminal = terminal; + terminalRef.current = terminal; + const latestSession = latestSessionRef.current; + previousSessionRef.current = latestSession; + if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer); + if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error); + // Attaching to a session that already exited must still run exit handling + // once, so mount synchronization starts from the empty "closed" state. + // (A session that is "closed" at mount is indistinguishable from one that + // never started, so only "exited" triggers the message — as with xterm.) + synchronizedStatusRef.current = "closed"; + synchronizeTerminalStatus(terminal, latestSession.status); + if (autoFocus) window.requestAnimationFrame(() => terminal.focus()); + + const clearSelectionAction = () => { + selectionActionRequestIdRef.current += 1; + if (selectionActionTimerRef.current !== null) { + window.clearTimeout(selectionActionTimerRef.current); + selectionActionTimerRef.current = null; + } }; - }; + setupCleanups.push(clearSelectionAction); - const showSelectionAction = async () => { - if (!localApi) { - clearSelectionAction(); - return; - } - if (selectionActionMenuOpenRef.current) { - return; - } - const nextAction = readSelectionAction(); - if (!nextAction) { - clearSelectionAction(); - return; - } - const requestId = ++selectionActionRequestIdRef.current; - selectionActionMenuOpenRef.current = true; - const clicked = await localApi.contextMenu - .show( - [ - { id: "add-to-chat", label: "Add to chat" }, - { id: "copy", label: "Copy" }, - ], - nextAction.position, - ) - .finally(() => { - selectionActionMenuOpenRef.current = false; + const readSelectionAction = (): { + position: { x: number; y: number }; + clipboardText: string; + selection: TerminalContextSelection; + } | null => { + const activeTerminal = terminalRef.current; + const mountElement = containerRef.current; + if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { + return null; + } + const selectionText = activeTerminal.getSelection(); + const selectionPosition = activeTerminal.getSelectionPosition(); + const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); + if (!selectionPosition || normalizedText.length === 0) { + return null; + } + const { lineStart, lineEnd } = terminalSelectionLineRange(selectionPosition); + const bounds = mountElement.getBoundingClientRect(); + const position = resolveTerminalSelectionActionPosition({ + bounds, + selectionRect: activeTerminal.getSelectionEndClientRect(), + pointer: selectionPointerRef.current, }); - if (requestId !== selectionActionRequestIdRef.current || clicked === null) { - return; - } - switch (clicked) { - case "add-to-chat": - handleAddTerminalContext(nextAction.selection); - terminalRef.current?.clearSelection(); - terminalRef.current?.focus(); + return { + position, + clipboardText: selectionText, + selection: { + terminalId, + terminalLabel: readTerminalLabel(), + lineStart, + lineEnd, + text: normalizedText, + }, + }; + }; + + const showSelectionAction = async () => { + if (!localApi) { + clearSelectionAction(); return; - case "copy": - try { - await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); - } catch (error) { - if (requestId !== selectionActionRequestIdRef.current) { - return; + } + if (selectionActionMenuOpenRef.current) { + return; + } + const nextAction = readSelectionAction(); + if (!nextAction) { + clearSelectionAction(); + return; + } + const requestId = ++selectionActionRequestIdRef.current; + selectionActionMenuOpenRef.current = true; + const clicked = await localApi.contextMenu + .show( + [ + { id: "add-to-chat", label: "Add to chat" }, + { id: "copy", label: "Copy" }, + ], + nextAction.position, + ) + .finally(() => { + selectionActionMenuOpenRef.current = false; + }); + if (requestId !== selectionActionRequestIdRef.current || clicked === null) { + return; + } + switch (clicked) { + case "add-to-chat": + handleAddTerminalContext(nextAction.selection); + terminalRef.current?.clearSelection(); + terminalRef.current?.focus(); + return; + case "copy": + try { + await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); + } catch (error) { + if (requestId !== selectionActionRequestIdRef.current) { + return; + } + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : "Unable to copy terminal selection", + ); + } } - const activeTerminal = terminalRef.current; - if (activeTerminal) { - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : "Unable to copy terminal selection", - ); + if (requestId === selectionActionRequestIdRef.current) { + terminalRef.current?.focus(); } - } - if (requestId === selectionActionRequestIdRef.current) { - terminalRef.current?.focus(); - } - return; - } - }; + return; + } + }; - const sendTerminalInput = async (data: string, fallbackError: string) => { - const activeTerminal = terminalRef.current; - if (!activeTerminal) return; - const result = await writeTerminal(data); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); - } - }; + const sendTerminalInput = async (data: string, fallbackError: string) => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + const result = await writeTerminal(data); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : fallbackError, + ); + } + }; - terminal.attachCustomKeyEventHandler((event) => { - const currentKeybindings = keybindingsRef.current; - const options = { context: { terminalFocus: true, terminalOpen: true } }; - if ( - isTerminalToggleShortcut(event, currentKeybindings, options) || - isTerminalSplitShortcut(event, currentKeybindings, options) || - isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || - isTerminalNewShortcut(event, currentKeybindings, options) || - isTerminalCloseShortcut(event, currentKeybindings, options) || - isDiffToggleShortcut(event, currentKeybindings, options) - ) { - return false; - } + function handleBeforeKey(event: KeyboardEvent): boolean { + const currentKeybindings = keybindingsRef.current; + const options = { context: { terminalFocus: true, terminalOpen: true } }; + if ( + isTerminalToggleShortcut(event, currentKeybindings, options) || + isTerminalSplitShortcut(event, currentKeybindings, options) || + isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || + isTerminalNewShortcut(event, currentKeybindings, options) || + isTerminalCloseShortcut(event, currentKeybindings, options) || + isDiffToggleShortcut(event, currentKeybindings, options) + ) { + return false; + } - const navigationData = terminalNavigationShortcutData(event); - if (navigationData !== null) { - event.preventDefault(); - event.stopPropagation(); - void sendTerminalInput(navigationData, "Failed to move cursor"); - return false; - } + const navigationData = terminalNavigationShortcutData(event); + if (navigationData !== null) { + event.preventDefault(); + event.stopPropagation(); + void sendTerminalInput(navigationData, "Failed to move cursor"); + return false; + } + + const deleteData = terminalDeleteShortcutData(event); + if (deleteData !== null) { + event.preventDefault(); + event.stopPropagation(); + void sendTerminalInput(deleteData, "Failed to delete terminal input"); + return false; + } - const deleteData = terminalDeleteShortcutData(event); - if (deleteData !== null) { + if (!isTerminalClearShortcut(event)) return true; event.preventDefault(); event.stopPropagation(); - void sendTerminalInput(deleteData, "Failed to delete terminal input"); + void sendTerminalInput("\u000c", "Failed to clear terminal"); return false; } - if (!isTerminalClearShortcut(event)) return true; - event.preventDefault(); - event.stopPropagation(); - void sendTerminalInput("\u000c", "Failed to clear terminal"); - return false; - }); - - const terminalLinksDisposable = terminal.registerLinkProvider({ - provideLinks: (bufferLineNumber, callback) => { - const activeTerminal = terminalRef.current; - if (!activeTerminal) { - callback(undefined); + function handleLinkActivate(text: string, event: MouseEvent): void { + if (!isTerminalLinkActivation(event)) return; + const latestTerminal = terminalRef.current; + if (!latestTerminal) return; + if (/^https?:\/\//u.test(text)) { + if (!localApi) { + writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); + return; + } + const fallbackToBrowser = () => { + void localApi.shell.openExternal(text).catch((error: unknown) => { + writeSystemMessage( + latestTerminal, + error instanceof Error ? error.message : "Unable to open link", + ); + }); + }; + void openTerminalLinkInPreview({ + url: text, + position: { x: event.clientX, y: event.clientY }, + threadRef, + openPreview, + localApi, + fallbackToBrowser, + }); return; } + const target = resolvePathLinkTarget(text, cwd); + void (async () => { + const result = await openTerminalPath(target); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + writeSystemMessage( + latestTerminal, + error instanceof Error ? error.message : "Unable to open path", + ); + })(); + } - const wrappedLine = collectWrappedTerminalLinkLine(bufferLineNumber, (bufferLineIndex) => - activeTerminal.buffer.active.getLine(bufferLineIndex), - ); - if (!wrappedLine) { - callback(undefined); - return; - } + function handleCopy(text: string): void { + void writeTextToClipboard(text, "terminal selection").catch((error: unknown) => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : "Unable to copy terminal selection", + ); + }); + } - const links = extractTerminalLinks(wrappedLine.text) - .map((match) => ({ - match, - range: resolveWrappedTerminalLinkRange(wrappedLine, match), - })) - .filter(({ range }) => - wrappedTerminalLinkRangeIntersectsBufferLine(range, bufferLineNumber), + function handleData(data: string): void { + void (async () => { + const result = await writeTerminal(data); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + writeSystemMessage( + terminal, + error instanceof Error ? error.message : "Terminal write failed", ); - if (links.length === 0) { - callback(undefined); + })(); + } + + function handleSelectionChange(): void { + if (terminalRef.current?.hasSelection()) { return; } + clearSelectionAction(); + } - callback( - links.map(({ match, range }) => ({ - text: match.text, - range, - activate: (event: MouseEvent) => { - if (!isTerminalLinkActivation(event)) return; - - const latestTerminal = terminalRef.current; - if (!latestTerminal) return; - - if (match.kind === "url") { - if (!localApi) { - writeSystemMessage( - latestTerminal, - "Opening links is unavailable in this browser.", - ); - return; - } - const fallbackToBrowser = () => { - void localApi.shell.openExternal(match.text).catch((error: unknown) => { - writeSystemMessage( - latestTerminal, - error instanceof Error ? error.message : "Unable to open link", - ); - }); - }; - void openTerminalLinkInPreview({ - url: match.text, - position: { x: event.clientX, y: event.clientY }, - threadRef, - openPreview, - localApi, - fallbackToBrowser, - }); - return; - } - - const target = resolvePathLinkTarget(match.text, cwd); - void (async () => { - const result = await openTerminalPath(target); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - const error = squashAtomCommandFailure(result); - writeSystemMessage( - latestTerminal, - error instanceof Error ? error.message : "Unable to open path", - ); - })(); - }, - })), + const handleMouseUp = (event: MouseEvent) => { + const shouldHandle = shouldHandleTerminalSelectionMouseUp( + selectionGestureActiveRef.current, + event.button, ); - }, - }); - - const inputDisposable = terminal.onData((data) => { - void (async () => { - const result = await writeTerminal(data); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + selectionGestureActiveRef.current = false; + if (!shouldHandle) { return; } - const error = squashAtomCommandFailure(result); - writeSystemMessage( - terminal, - error instanceof Error ? error.message : "Terminal write failed", - ); - })(); - }); + selectionPointerRef.current = { x: event.clientX, y: event.clientY }; + const delay = terminalSelectionActionDelayForClickCount(event.detail); + selectionActionTimerRef.current = window.setTimeout(() => { + selectionActionTimerRef.current = null; + window.requestAnimationFrame(() => { + void showSelectionAction(); + }); + }, delay); + }; + const handlePointerDown = (event: PointerEvent) => { + clearSelectionAction(); + selectionGestureActiveRef.current = event.button === 0; + }; + window.addEventListener("mouseup", handleMouseUp); + mount.addEventListener("pointerdown", handlePointerDown); + setupCleanups.push(() => { + window.removeEventListener("mouseup", handleMouseUp); + mount.removeEventListener("pointerdown", handlePointerDown); + }); - const selectionDisposable = terminal.onSelectionChange(() => { - if (terminalRef.current?.hasSelection()) { - return; - } - clearSelectionAction(); - }); + const themeObserver = new MutationObserver(() => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + activeTerminal.setTheme(terminalThemeFromApp(containerRef.current)); + }); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style"], + }); + setupCleanups.push(() => themeObserver.disconnect()); - const handleMouseUp = (event: MouseEvent) => { - const shouldHandle = shouldHandleTerminalSelectionMouseUp( - selectionGestureActiveRef.current, - event.button, - ); - selectionGestureActiveRef.current = false; - if (!shouldHandle) { - return; - } - selectionPointerRef.current = { x: event.clientX, y: event.clientY }; - const delay = terminalSelectionActionDelayForClickCount(event.detail); - selectionActionTimerRef.current = window.setTimeout(() => { - selectionActionTimerRef.current = null; - window.requestAnimationFrame(() => { - void showSelectionAction(); - }); - }, delay); - }; - const handlePointerDown = (event: PointerEvent) => { - clearSelectionAction(); - selectionGestureActiveRef.current = event.button === 0; + const fitTimer = window.setTimeout(() => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + const wasAtBottom = activeTerminal.isAtBottom(); + activeTerminal.fit(); + if (wasAtBottom) { + activeTerminal.scrollToBottom(); + } + }, 30); + setupCleanups.push(() => window.clearTimeout(fitTimer)); + + const cleanups = setupCleanups; + setupCleanups = []; + setupTerminal = null; + return () => { + for (const cleanup of cleanups.toReversed()) cleanup(); + if (terminalRef.current === terminal) terminalRef.current = null; + terminal.dispose(); + }; }; - window.addEventListener("mouseup", handleMouseUp); - mount.addEventListener("pointerdown", handlePointerDown); - - const themeObserver = new MutationObserver(() => { - const activeTerminal = terminalRef.current; - if (!activeTerminal) return; - activeTerminal.options.theme = terminalThemeFromApp(containerRef.current); - activeTerminal.refresh(0, activeTerminal.rows - 1); - }); - themeObserver.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class", "style"], - }); - const fitTimer = window.setTimeout(() => { - const activeTerminal = terminalRef.current; - const activeFitAddon = fitAddonRef.current; - if (!activeTerminal || !activeFitAddon) return; - const wasAtBottom = - activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; - fitTerminalSafely(activeFitAddon); - if (wasAtBottom) { - activeTerminal.scrollToBottom(); - } - void resizeTerminal(activeTerminal.cols, activeTerminal.rows); - }, 30); + void setup() + .then((nextTeardown) => { + if (cancelled) { + nextTeardown?.(); + return; + } + teardown = nextTeardown; + }) + .catch((error: unknown) => { + for (const cleanup of setupCleanups.toReversed()) cleanup(); + setupCleanups = []; + if (terminalRef.current === setupTerminal) terminalRef.current = null; + setupTerminal?.dispose(); + setupTerminal = null; + if (cancelled) return; + const message = + error instanceof Error ? error.message : "Unable to initialize libghostty-vt"; + mount.textContent = `${message} — close and reopen the terminal to retry.`; + }); return () => { - window.clearTimeout(fitTimer); - inputDisposable.dispose(); - selectionDisposable.dispose(); - terminalLinksDisposable.dispose(); - if (selectionActionTimerRef.current !== null) { - window.clearTimeout(selectionActionTimerRef.current); - } - window.removeEventListener("mouseup", handleMouseUp); - mount.removeEventListener("pointerdown", handlePointerDown); - themeObserver.disconnect(); - terminalRef.current = null; - fitAddonRef.current = null; - terminal.dispose(); + cancelled = true; + teardown?.(); }; // autoFocus is intentionally omitted; // it is only read at mount time and must not trigger terminal teardown/recreation. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); useEffect(() => { @@ -750,6 +735,7 @@ export function TerminalViewport({ } const previous = previousSessionRef.current; + synchronizeTerminalStatus(terminal, current.status); if (current.version === previous.version) { return; } @@ -768,25 +754,6 @@ export function TerminalViewport({ writeSystemMessage(terminal, current.error); } - if (current.status === "running") { - hasHandledExitRef.current = false; - } else if ( - (current.status === "closed" || current.status === "exited") && - current.status !== previous.status && - !hasHandledExitRef.current - ) { - hasHandledExitRef.current = true; - writeSystemMessage( - terminal, - current.status === "closed" ? "Terminal closed" : "Process exited", - ); - window.setTimeout(() => { - if (hasHandledExitRef.current) { - handleSessionExited(); - } - }, 0); - } - if (previous.version === 0 && autoFocus) { window.requestAnimationFrame(() => { terminal.focus(); @@ -809,15 +776,15 @@ export function TerminalViewport({ useEffect(() => { const terminal = terminalRef.current; - const fitAddon = fitAddonRef.current; - if (!terminal || !fitAddon) return; - const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; + if (!terminal) return; + const wasAtBottom = terminal.isAtBottom(); + // The surface reports grid changes through onResize, which is the single + // channel for PTY resize RPCs; fitting here only refreshes the layout. const frame = window.requestAnimationFrame(() => { - fitTerminalSafely(fitAddon); + terminal.fit(); if (wasAtBottom) { terminal.scrollToBottom(); } - void resizeTerminal(terminal.cols, terminal.rows); }); return () => { window.cancelAnimationFrame(frame); @@ -1276,7 +1243,7 @@ export default function ThreadTerminalDrawer({ {!hasTerminalSidebar && (
-
+
.scrollbar.vertical { - width: 6px !important; -} - -.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar > .slider { - border-radius: 3px; -} - -.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical > .slider { - width: 6px !important; - left: 0 !important; -} - /* Reasoning select -- clickable label surface */ label:has(> select#reasoning-effort) { position: relative; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index c03cc65f654..7406f960cd3 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,7 +8,6 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" import "@fontsource-variable/dm-sans/index.css"; import "@fontsource/jetbrains-mono/400.css"; import "@fontsource/jetbrains-mono/500.css"; -import "@xterm/xterm/css/xterm.css"; import "./index.css"; import { isElectron } from "./env"; diff --git a/apps/web/src/terminal/ghostty/README.md b/apps/web/src/terminal/ghostty/README.md new file mode 100644 index 00000000000..b563c6e833b --- /dev/null +++ b/apps/web/src/terminal/ghostty/README.md @@ -0,0 +1,19 @@ +# Ghostty web terminal + +This directory is the browser adapter for the same official `libghostty-vt` C ABI used by Android. +It is intentionally not an xterm compatibility layer. + +- `runtime.ts` owns the singleton WebAssembly instance and runtime ABI layouts. +- `ghostty-write-pty.wasm` is a 112-byte callback trampoline for terminal-generated PTY replies. +- `core.ts` owns per-terminal Ghostty handles and translates the C ABI into render snapshots. +- `renderer.ts` batches backgrounds and style runs into a Canvas 2D frame. +- `surface.ts` owns browser input, IME, selection, scrolling, sizing, links, and cursor blinking. +- `fonts/` vendors the symbols-only Nerd Font (MIT) the surface registers lazily, so + prompt glyphs render without a locally installed Nerd Font. +- `vendor/` holds only the artifacts, reproducibly generated by + `apps/web/scripts/build-libghostty-wasm.sh`. The upstream pin and license live once, at + `apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/`; the wasm embeds the pinned revision + in its build info and the ABI test verifies it against mobile's `VERSION`. + +Keep browser behavior here and terminal transport in the existing client runtime. Do not add React +state to the render loop. Both WASM artifacts are ordinary read-only assets, not executables. diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts new file mode 100644 index 00000000000..204a94b6a8c --- /dev/null +++ b/apps/web/src/terminal/ghostty/core.ts @@ -0,0 +1,1189 @@ +import { + type GhosttyKeyboardLayoutMap, + ghosttyKeyForCode, + ghosttyUnshiftedCodepoint, + loadGhosttyKeyboardLayoutMap, +} from "./keyCodes"; +import { GhosttyRuntime, loadGhosttyRuntime } from "./runtime"; + +const GHOSTTY_SUCCESS = 0; +const GHOSTTY_OUT_OF_SPACE = -3; +const MAX_SCROLLBACK_ROWS = 10_000; +// wasm32 C ABI layout for GhosttyTerminalSelectionFormatOptions at the +// libghostty-vt revision pinned alongside this module. +const SELECTION_FORMAT_OPTIONS_SIZE = 16; + +const RENDER_DATA = { + cols: 1, + rows: 2, + dirty: 3, + rowIterator: 4, + background: 5, + foreground: 6, + cursor: 7, + cursorHasValue: 8, + cursorStyle: 10, + cursorVisible: 11, + cursorBlinking: 12, + cursorInViewport: 14, + cursorX: 15, + cursorY: 16, +} as const; + +const ROW_DATA = { + dirty: 1, + raw: 2, + cells: 3, +} as const; + +const CELL_DATA = { + raw: 1, + style: 2, + graphemesLength: 3, + graphemes: 4, + background: 5, + foreground: 6, + selected: 7, +} as const; + +const RAW_CELL_DATA = { + wide: 3, +} as const; + +export const GHOSTTY_CELL_WIDE = { + narrow: 0, + wide: 1, + spacerTail: 2, + spacerHead: 3, +} as const; + +export interface GhosttyColor { + readonly r: number; + readonly g: number; + readonly b: number; +} + +export interface GhosttyTheme { + readonly foreground: GhosttyColor; + readonly background: GhosttyColor; + readonly cursor: GhosttyColor; + /** CSS color the renderer overlays on selected cells; not sent to Ghostty. */ + readonly selectionBackground?: string; +} + +export interface GhosttyCell { + readonly text: string; + readonly wide: number; + readonly foreground: GhosttyColor; + readonly background: GhosttyColor; + readonly bold: boolean; + readonly italic: boolean; + readonly invisible: boolean; + readonly strikethrough: boolean; + readonly overline: boolean; + readonly underline: boolean; + readonly selected: boolean; +} + +export interface GhosttyRow { + readonly cells: readonly GhosttyCell[]; + readonly text: string; + readonly isWrapContinuation: boolean; + /** Whether this row soft-wraps onto the next row. */ + readonly wrapsToNext: boolean; +} + +export interface GhosttySnapshot { + readonly cols: number; + readonly rows: number; + readonly foreground: GhosttyColor; + readonly background: GhosttyColor; + readonly cursor: GhosttyColor; + readonly cursorX: number; + readonly cursorY: number; + readonly cursorVisible: boolean; + readonly cursorBlinking: boolean; + readonly cursorStyle: number; + readonly dirtyRows: ReadonlySet; + readonly rowData: readonly GhosttyRow[]; +} + +export interface GhosttySelectionRange { + readonly viewport: { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; + }; + readonly screen: { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; + }; +} + +export interface GhosttyScrollbar { + readonly total: number; + readonly offset: number; + readonly len: number; +} + +/** Grid position tagged with its Ghostty coordinate space: 1 viewport, 2 screen. */ +export interface GhosttyPointInput { + readonly x: number; + readonly y: number; + readonly tag?: 1 | 2; +} + +export interface GhosttyMouseInput { + readonly action: "press" | "release" | "motion"; + readonly button: number | null; + readonly mods: number; + readonly x: number; + readonly y: number; + readonly screenWidth: number; + readonly screenHeight: number; + readonly cellWidth: number; + readonly cellHeight: number; + readonly paddingLeft: number; + readonly paddingRight: number; + readonly paddingTop: number; + readonly paddingBottom: number; + readonly anyButtonPressed: boolean; +} + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +function blend(foreground: GhosttyColor, background: GhosttyColor): GhosttyColor { + const channel = (front: number, back: number) => Math.floor((front * 155 + back * 100) / 255); + return { + r: channel(foreground.r, background.r), + g: channel(foreground.g, background.g), + b: channel(foreground.b, background.b), + }; +} + +function sameColor(left: GhosttyColor, right: GhosttyColor): boolean { + return left.r === right.r && left.g === right.g && left.b === right.b; +} + +export class GhosttyTerminalCore { + private readonly runtime: GhosttyRuntime; + private terminalSlot = 0; + private terminal = 0; + private renderStateSlot = 0; + private renderState = 0; + private rowIteratorSlot = 0; + private rowCellsSlot = 0; + private keyEncoderSlot = 0; + private keyEncoder = 0; + private keyEventSlot = 0; + private keyEvent = 0; + private mouseEncoderSlot = 0; + private mouseEncoder = 0; + private mouseEventSlot = 0; + private mouseEvent = 0; + private ptyWriterId = 0; + private ptyWriter: ((data: string) => void) | null = null; + private scratch = 0; + private style = 0; + private scrollbar = 0; + private rows: GhosttyRow[] = []; + private disposed = false; + private keyboardLayoutMap: GhosttyKeyboardLayoutMap | undefined; + + private constructor(runtime: GhosttyRuntime) { + this.runtime = runtime; + void loadGhosttyKeyboardLayoutMap().then((layoutMap) => { + if (!this.disposed) this.keyboardLayoutMap = layoutMap; + }); + } + + static async create( + cols: number, + rows: number, + cellWidth: number, + cellHeight: number, + theme: GhosttyTheme, + onPtyData: (data: string) => void, + ): Promise { + const core = new GhosttyTerminalCore(await loadGhosttyRuntime()); + try { + core.initialize(cols, rows, cellWidth, cellHeight, theme, onPtyData); + return core; + } catch (error) { + core.dispose(); + throw error; + } + } + + private initialize( + cols: number, + rows: number, + cellWidth: number, + cellHeight: number, + theme: GhosttyTheme, + onPtyData: (data: string) => void, + ): void { + const optionsSize = this.runtime.layout("GhosttyTerminalOptions").size; + const options = this.runtime.alloc(optionsSize); + this.runtime.setField(options, "GhosttyTerminalOptions", "cols", cols); + this.runtime.setField(options, "GhosttyTerminalOptions", "rows", rows); + this.runtime.setField(options, "GhosttyTerminalOptions", "max_scrollback", MAX_SCROLLBACK_ROWS); + this.terminalSlot = this.runtime.allocOpaque(); + const terminalResult = this.runtime.call("ghostty_terminal_new", 0, this.terminalSlot, options); + this.runtime.free(options, optionsSize); + this.assertSuccess("ghostty_terminal_new", terminalResult); + this.terminal = this.runtime.readPointer(this.terminalSlot); + this.ptyWriter = onPtyData; + this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, onPtyData); + + this.renderStateSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_render_state_new", + this.runtime.call("ghostty_render_state_new", 0, this.renderStateSlot), + ); + this.renderState = this.runtime.readPointer(this.renderStateSlot); + + this.rowIteratorSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_render_state_row_iterator_new", + this.runtime.call("ghostty_render_state_row_iterator_new", 0, this.rowIteratorSlot), + ); + this.rowCellsSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_render_state_row_cells_new", + this.runtime.call("ghostty_render_state_row_cells_new", 0, this.rowCellsSlot), + ); + + this.keyEncoderSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_key_encoder_new", + this.runtime.call("ghostty_key_encoder_new", 0, this.keyEncoderSlot), + ); + this.keyEncoder = this.runtime.readPointer(this.keyEncoderSlot); + this.keyEventSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_key_event_new", + this.runtime.call("ghostty_key_event_new", 0, this.keyEventSlot), + ); + this.keyEvent = this.runtime.readPointer(this.keyEventSlot); + + this.mouseEncoderSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_mouse_encoder_new", + this.runtime.call("ghostty_mouse_encoder_new", 0, this.mouseEncoderSlot), + ); + this.mouseEncoder = this.runtime.readPointer(this.mouseEncoderSlot); + this.mouseEventSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_mouse_event_new", + this.runtime.call("ghostty_mouse_event_new", 0, this.mouseEventSlot), + ); + this.mouseEvent = this.runtime.readPointer(this.mouseEventSlot); + + this.scratch = this.runtime.alloc(16); + const styleSize = this.runtime.layout("GhosttyStyle").size; + this.style = this.runtime.alloc(styleSize); + this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); + this.scrollbar = this.runtime.alloc(this.runtime.layout("GhosttyTerminalScrollbar").size); + this.setTheme(theme); + this.resize(cols, rows, cellWidth, cellHeight); + } + + write(data: string | Uint8Array): void { + this.ensureActive(); + const bytes = typeof data === "string" ? encoder.encode(data) : data; + if (bytes.length === 0) return; + const pointer = this.runtime.alloc(bytes.length); + this.runtime.bytes(pointer, bytes.length).set(bytes); + this.runtime.call("ghostty_terminal_vt_write", this.terminal, pointer, bytes.length); + this.runtime.free(pointer, bytes.length); + } + + resetAndWrite(data: string): void { + this.ensureActive(); + this.runtime.call("ghostty_terminal_reset", this.terminal); + this.rows = []; + if (data.length === 0) return; + const writer = this.ptyWriter; + if (this.ptyWriterId !== 0) { + this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId); + this.ptyWriterId = 0; + } + try { + this.write(data); + } finally { + if (writer !== null && !this.disposed) { + this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, writer); + } + } + } + + resize(cols: number, rows: number, cellWidth: number, cellHeight: number): void { + this.ensureActive(); + this.assertSuccess( + "ghostty_terminal_resize", + this.runtime.call( + "ghostty_terminal_resize", + this.terminal, + Math.max(1, Math.min(65_535, cols)), + Math.max(1, Math.min(65_535, rows)), + Math.max(1, Math.round(cellWidth)), + Math.max(1, Math.round(cellHeight)), + ), + ); + } + + setTheme(theme: GhosttyTheme): void { + this.ensureActive(); + const color = this.runtime.alloc(3); + for (const [option, value] of [ + [11, theme.foreground], + [12, theme.background], + [13, theme.cursor], + ] as const) { + this.runtime.bytes(color, 3).set([value.r, value.g, value.b]); + this.runtime.call("ghostty_terminal_set", this.terminal, option, color); + } + this.runtime.free(color, 3); + } + + scroll(deltaRows: number): void { + this.ensureActive(); + const layout = this.runtime.layout("GhosttyTerminalScrollViewport"); + const scroll = this.runtime.alloc(layout.size); + this.runtime.setField(scroll, "GhosttyTerminalScrollViewport", "tag", 2); + const value = layout.fields.value!; + this.runtime.view(scroll + value.offset, value.size).setInt32(0, deltaRows, true); + this.runtime.call("ghostty_terminal_scroll_viewport", this.terminal, scroll); + this.runtime.free(scroll, layout.size); + } + + scrollToBottom(): void { + this.ensureActive(); + const layout = this.runtime.layout("GhosttyTerminalScrollViewport"); + const scroll = this.runtime.alloc(layout.size); + this.runtime.setField(scroll, "GhosttyTerminalScrollViewport", "tag", 1); + this.runtime.call("ghostty_terminal_scroll_viewport", this.terminal, scroll); + this.runtime.free(scroll, layout.size); + } + + isViewportActive(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_terminal_get", this.terminal, 32, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + scrollbarState(): GhosttyScrollbar | null { + this.ensureActive(); + const layout = this.runtime.layout("GhosttyTerminalScrollbar"); + this.runtime.bytes(this.scrollbar, layout.size).fill(0); + if ( + this.runtime.call("ghostty_terminal_get", this.terminal, 9, this.scrollbar) !== + GHOSTTY_SUCCESS + ) { + return null; + } + return { + total: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "total"), + offset: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "offset"), + len: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "len"), + }; + } + + isMouseTracking(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_terminal_get", this.terminal, 11, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + isMouseAnyEventTracking(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_terminal_mode_get", this.terminal, 1003, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + isAlternateScreen(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 4).fill(0); + return ( + this.runtime.call("ghostty_terminal_get", this.terminal, 6, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.view(this.scratch, 4).getUint32(0, true) === 1 + ); + } + + isApplicationCursorKeys(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_terminal_mode_get", this.terminal, 1, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + encodeKey(event: KeyboardEvent, action: "press" | "release" = "press"): string { + this.ensureActive(); + this.runtime.call("ghostty_key_encoder_setopt_from_terminal", this.keyEncoder, this.terminal); + this.runtime.call( + "ghostty_key_event_set_action", + this.keyEvent, + action === "release" ? 0 : event.repeat ? 2 : 1, + ); + this.runtime.call("ghostty_key_event_set_key", this.keyEvent, ghosttyKeyForCode(event.code)); + const mods = + (event.shiftKey ? 1 : 0) | + (event.ctrlKey ? 1 << 1 : 0) | + (event.altKey ? 1 << 2 : 0) | + (event.metaKey ? 1 << 3 : 0) | + (event.getModifierState("CapsLock") ? 1 << 4 : 0) | + (event.getModifierState("NumLock") ? 1 << 5 : 0); + this.runtime.call("ghostty_key_event_set_mods", this.keyEvent, mods); + this.runtime.call("ghostty_key_event_set_consumed_mods", this.keyEvent, 0); + this.runtime.call("ghostty_key_event_set_composing", this.keyEvent, event.isComposing ? 1 : 0); + this.runtime.call( + "ghostty_key_event_set_unshifted_codepoint", + this.keyEvent, + ghosttyUnshiftedCodepoint(event, this.keyboardLayoutMap), + ); + + const text = event.key.length === 1 ? event.key : ""; + const textBytes = encoder.encode(text); + const textPointer = textBytes.length === 0 ? 0 : this.runtime.alloc(textBytes.length); + if (textPointer !== 0) this.runtime.bytes(textPointer, textBytes.length).set(textBytes); + this.runtime.call("ghostty_key_event_set_utf8", this.keyEvent, textPointer, textBytes.length); + + const written = this.runtime.call("ghostty_wasm_alloc_usize"); + const encoded = this.encodeOutput(written, (output, outputSize) => + this.runtime.call( + "ghostty_key_encoder_encode", + this.keyEncoder, + this.keyEvent, + output, + outputSize, + written, + ), + ); + this.runtime.call("ghostty_wasm_free_usize", written); + if (textPointer !== 0) this.runtime.free(textPointer, textBytes.length); + return encoded; + } + + encodePaste(data: string): string { + this.ensureActive(); + const input = encoder.encode(data); + if (input.length === 0) return ""; + const inputPointer = this.runtime.alloc(input.length); + this.runtime.bytes(inputPointer, input.length).set(input); + this.runtime.bytes(this.scratch, 1)[0] = 0; + const bracketed = + this.runtime.call("ghostty_terminal_mode_get", this.terminal, 2004, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0; + const written = this.runtime.call("ghostty_wasm_alloc_usize"); + let encoded = ""; + const sizeResult = this.runtime.call( + "ghostty_paste_encode", + inputPointer, + input.length, + bracketed ? 1 : 0, + 0, + 0, + written, + ); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { + const output = this.runtime.alloc(outputSize); + const result = this.runtime.call( + "ghostty_paste_encode", + inputPointer, + input.length, + bracketed ? 1 : 0, + output, + outputSize, + written, + ); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + encoded = + result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : ""; + this.runtime.free(output, outputSize); + } + this.runtime.call("ghostty_wasm_free_usize", written); + this.runtime.free(inputPointer, input.length); + return encoded; + } + + encodeMouse(input: GhosttyMouseInput): string { + this.ensureActive(); + this.runtime.call( + "ghostty_mouse_encoder_setopt_from_terminal", + this.mouseEncoder, + this.terminal, + ); + + const sizeLayout = this.runtime.layout("GhosttyMouseEncoderSize"); + const size = this.runtime.alloc(sizeLayout.size); + for (const [field, value] of [ + ["size", sizeLayout.size], + ["screen_width", input.screenWidth], + ["screen_height", input.screenHeight], + ["cell_width", input.cellWidth], + ["cell_height", input.cellHeight], + ["padding_top", input.paddingTop], + ["padding_bottom", input.paddingBottom], + ["padding_right", input.paddingRight], + ["padding_left", input.paddingLeft], + ] as const) { + this.runtime.setField(size, "GhosttyMouseEncoderSize", field, Math.max(0, Math.round(value))); + } + this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 2, size); + this.runtime.free(size, sizeLayout.size); + + this.runtime.bytes(this.scratch, 1)[0] = input.anyButtonPressed ? 1 : 0; + this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 3, this.scratch); + this.runtime.bytes(this.scratch, 1)[0] = 1; + this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 4, this.scratch); + + this.runtime.call( + "ghostty_mouse_event_set_action", + this.mouseEvent, + input.action === "press" ? 0 : input.action === "release" ? 1 : 2, + ); + if (input.button === null) { + this.runtime.call("ghostty_mouse_event_clear_button", this.mouseEvent); + } else { + this.runtime.call("ghostty_mouse_event_set_button", this.mouseEvent, input.button); + } + this.runtime.call("ghostty_mouse_event_set_mods", this.mouseEvent, input.mods); + const positionLayout = this.runtime.layout("GhosttyMousePosition"); + const position = this.runtime.alloc(positionLayout.size); + const positionView = this.runtime.view(position, positionLayout.size); + positionView.setFloat32(positionLayout.fields.x!.offset, input.x, true); + positionView.setFloat32(positionLayout.fields.y!.offset, input.y, true); + this.runtime.call("ghostty_mouse_event_set_position", this.mouseEvent, position); + this.runtime.free(position, positionLayout.size); + + const written = this.runtime.call("ghostty_wasm_alloc_usize"); + const encoded = this.encodeOutput(written, (output, outputSize) => + this.runtime.call( + "ghostty_mouse_encoder_encode", + this.mouseEncoder, + this.mouseEvent, + output, + outputSize, + written, + ), + ); + this.runtime.call("ghostty_wasm_free_usize", written); + return encoded; + } + + setSelection(anchor: GhosttyPointInput, end: GhosttyPointInput): void { + this.ensureActive(); + const selectionLayout = this.runtime.layout("GhosttySelection"); + const gridRefSize = this.runtime.layout("GhosttyGridRef").size; + const selection = this.runtime.alloc(selectionLayout.size); + let start = 0; + let endRef = 0; + try { + this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size); + start = this.gridRef(anchor.x, anchor.y, anchor.tag ?? 1); + endRef = this.gridRef(end.x, end.y, end.tag ?? 1); + const startField = selectionLayout.fields.start!; + const endField = selectionLayout.fields.end!; + this.runtime + .bytes(selection + startField.offset, startField.size) + .set(this.runtime.bytes(start, startField.size)); + this.runtime + .bytes(selection + endField.offset, endField.size) + .set(this.runtime.bytes(endRef, endField.size)); + this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); + } finally { + this.runtime.free(start, gridRefSize); + this.runtime.free(endRef, gridRefSize); + this.runtime.free(selection, selectionLayout.size); + } + } + + selectAll(): void { + this.ensureActive(); + const layout = this.runtime.layout("GhosttySelection"); + const selection = this.runtime.alloc(layout.size); + this.runtime.setField(selection, "GhosttySelection", "size", layout.size); + if ( + this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) === GHOSTTY_SUCCESS + ) { + this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); + } + this.runtime.free(selection, layout.size); + } + + selectWord(col: number, row: number): GhosttySelectionRange | null { + return this.selectAt( + "GhosttyTerminalSelectWordOptions", + "ghostty_terminal_select_word", + col, + row, + ); + } + + selectLine(col: number, row: number): GhosttySelectionRange | null { + return this.selectAt( + "GhosttyTerminalSelectLineOptions", + "ghostty_terminal_select_line", + col, + row, + ); + } + + hyperlinkAt(col: number, row: number): string | null { + this.ensureActive(); + const ref = this.gridRef(col, row); + const written = this.runtime.call("ghostty_wasm_alloc_usize"); + const sizeResult = this.runtime.call("ghostty_grid_ref_hyperlink_uri", ref, 0, 0, written); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + let hyperlink: string | null = null; + if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { + const output = this.runtime.alloc(outputSize); + const result = this.runtime.call( + "ghostty_grid_ref_hyperlink_uri", + ref, + output, + outputSize, + written, + ); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + if (result === GHOSTTY_SUCCESS && outputLength > 0) { + hyperlink = decoder.decode(this.runtime.bytes(output, outputLength)); + } + this.runtime.free(output, outputSize); + } + this.runtime.call("ghostty_wasm_free_usize", written); + this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); + return hyperlink; + } + + clearSelection(): void { + this.ensureActive(); + this.runtime.call("ghostty_terminal_set", this.terminal, 21, 0); + } + + snapshot(): GhosttySnapshot { + this.ensureActive(); + this.assertSuccess( + "ghostty_render_state_update", + this.runtime.call("ghostty_render_state_update", this.renderState, this.terminal), + ); + const cols = this.getU16(RENDER_DATA.cols); + const rowCount = this.getU16(RENDER_DATA.rows); + const dirty = this.getU32(RENDER_DATA.dirty); + const foreground = this.getColor(RENDER_DATA.foreground, { r: 229, g: 231, b: 235 }); + const background = this.getColor(RENDER_DATA.background, { r: 0, g: 0, b: 0 }); + const cursorHasValue = this.getBool(RENDER_DATA.cursorHasValue); + const cursor = cursorHasValue ? this.getColor(RENDER_DATA.cursor, foreground) : foreground; + const cursorInViewport = this.getBool(RENDER_DATA.cursorInViewport); + const cursorVisible = this.getBool(RENDER_DATA.cursorVisible) && cursorInViewport; + const cursorX = cursorInViewport ? this.getU16(RENDER_DATA.cursorX) : -1; + const cursorY = cursorInViewport ? this.getU16(RENDER_DATA.cursorY) : -1; + + if (this.rows.length !== rowCount || this.rows.some((row) => row.cells.length !== cols)) { + this.rows = Array.from({ length: rowCount }, () => ({ + cells: Array.from({ length: cols }, () => this.emptyCell(foreground, background)), + text: "", + isWrapContinuation: false, + wrapsToNext: false, + })); + } + + const dirtyRows = new Set(); + if (dirty !== 0) { + this.assertSuccess( + "ghostty_render_state_get(row iterator)", + this.runtime.call( + "ghostty_render_state_get", + this.renderState, + RENDER_DATA.rowIterator, + this.rowIteratorSlot, + ), + ); + const iterator = this.runtime.readPointer(this.rowIteratorSlot); + let rowIndex = 0; + while ( + rowIndex < rowCount && + this.runtime.call("ghostty_render_state_row_iterator_next", iterator) !== 0 + ) { + const rowDirty = dirty === 2 || this.getRowBool(iterator, ROW_DATA.dirty); + if (rowDirty) { + this.rows[rowIndex] = this.readRow(iterator, cols, foreground, background); + dirtyRows.add(rowIndex); + this.runtime.bytes(this.scratch, 1)[0] = 0; + this.runtime.call("ghostty_render_state_row_set", iterator, 0, this.scratch); + } + rowIndex += 1; + } + this.runtime.view(this.scratch, 4).setUint32(0, 0, true); + this.runtime.call("ghostty_render_state_set", this.renderState, 0, this.scratch); + } + + return { + cols, + rows: rowCount, + foreground, + background, + cursor, + cursorX, + cursorY, + cursorVisible, + cursorBlinking: this.getBool(RENDER_DATA.cursorBlinking), + cursorStyle: this.getU32(RENDER_DATA.cursorStyle), + dirtyRows, + rowData: this.rows, + }; + } + + selectionText(): string { + this.ensureActive(); + const options = this.runtime.alloc(SELECTION_FORMAT_OPTIONS_SIZE); + const optionsView = this.runtime.view(options, SELECTION_FORMAT_OPTIONS_SIZE); + optionsView.setUint32(0, SELECTION_FORMAT_OPTIONS_SIZE, true); + optionsView.setUint32(4, 0, true); + optionsView.setUint8(8, 1); + optionsView.setUint8(9, 1); + optionsView.setUint32(12, 0, true); + const written = this.runtime.call("ghostty_wasm_alloc_usize"); + const sizeResult = this.runtime.call( + "ghostty_terminal_selection_format_buf", + this.terminal, + options, + 0, + 0, + written, + ); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + let text = ""; + if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { + const output = this.runtime.alloc(outputSize); + const result = this.runtime.call( + "ghostty_terminal_selection_format_buf", + this.terminal, + options, + output, + outputSize, + written, + ); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + if (result === GHOSTTY_SUCCESS) { + text = decoder.decode(this.runtime.bytes(output, outputLength)); + } + this.runtime.free(output, outputSize); + } + this.runtime.call("ghostty_wasm_free_usize", written); + this.runtime.free(options, SELECTION_FORMAT_OPTIONS_SIZE); + return text; + } + + viewportPointToScreen(col: number, row: number): { x: number; y: number } | null { + return this.convertPoint(col, row, 1, 2); + } + + screenPointToViewport(col: number, row: number): { x: number; y: number } | null { + return this.convertPoint(col, row, 2, 1); + } + + private convertPoint( + col: number, + row: number, + fromTag: 1 | 2, + toTag: 1 | 2, + ): { x: number; y: number } | null { + this.ensureActive(); + const ref = this.gridRef(col, row, fromTag); + const point = this.pointFromGridRef(ref, toTag); + this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); + return point; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.mouseEvent) this.runtime.call("ghostty_mouse_event_free", this.mouseEvent); + if (this.mouseEncoder) this.runtime.call("ghostty_mouse_encoder_free", this.mouseEncoder); + if (this.keyEvent) this.runtime.call("ghostty_key_event_free", this.keyEvent); + if (this.keyEncoder) this.runtime.call("ghostty_key_encoder_free", this.keyEncoder); + if (this.rowCellsSlot) { + const cells = this.runtime.readPointer(this.rowCellsSlot); + if (cells) this.runtime.call("ghostty_render_state_row_cells_free", cells); + } + if (this.rowIteratorSlot) { + const iterator = this.runtime.readPointer(this.rowIteratorSlot); + if (iterator) this.runtime.call("ghostty_render_state_row_iterator_free", iterator); + } + if (this.renderState) this.runtime.call("ghostty_render_state_free", this.renderState); + if (this.terminal) { + if (this.ptyWriterId) this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId); + this.runtime.call("ghostty_terminal_free", this.terminal); + } + if (this.style) this.runtime.free(this.style, this.runtime.layout("GhosttyStyle").size); + if (this.scrollbar) { + this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size); + } + if (this.scratch) this.runtime.free(this.scratch, 16); + for (const slot of [ + this.mouseEventSlot, + this.mouseEncoderSlot, + this.keyEventSlot, + this.keyEncoderSlot, + this.rowCellsSlot, + this.rowIteratorSlot, + this.renderStateSlot, + this.terminalSlot, + ]) { + this.runtime.freeOpaque(slot); + } + } + + private encodeOutput( + written: number, + encode: (output: number, outputSize: number) => number, + ): string { + const sizeResult = encode(0, 0); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + if (sizeResult === GHOSTTY_SUCCESS && outputSize === 0) return ""; + if (sizeResult !== GHOSTTY_OUT_OF_SPACE || outputSize === 0) return ""; + + const output = this.runtime.alloc(outputSize); + const result = encode(output, outputSize); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + const encoded = + result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : ""; + this.runtime.free(output, outputSize); + return encoded; + } + + private readRow( + iterator: number, + cols: number, + defaultForeground: GhosttyColor, + defaultBackground: GhosttyColor, + ): GhosttyRow { + this.assertSuccess( + "ghostty_render_state_row_get(raw)", + this.runtime.call("ghostty_render_state_row_get", iterator, ROW_DATA.raw, this.scratch), + ); + const rawRow = this.runtime.view(this.scratch, 8).getBigUint64(0, true); + this.runtime.bytes(this.scratch + 8, 1)[0] = 0; + this.assertSuccess( + "ghostty_row_get(wrap continuation)", + this.runtime.call("ghostty_row_get", rawRow, 2, this.scratch + 8), + ); + const isWrapContinuation = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; + this.runtime.bytes(this.scratch + 8, 1)[0] = 0; + this.assertSuccess( + "ghostty_row_get(wrap)", + this.runtime.call("ghostty_row_get", rawRow, 1, this.scratch + 8), + ); + const wrapsToNext = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; + + this.assertSuccess( + "ghostty_render_state_row_get(cells)", + this.runtime.call( + "ghostty_render_state_row_get", + iterator, + ROW_DATA.cells, + this.rowCellsSlot, + ), + ); + const cellsIterator = this.runtime.readPointer(this.rowCellsSlot); + const cells: GhosttyCell[] = []; + while ( + cells.length < cols && + this.runtime.call("ghostty_render_state_row_cells_next", cellsIterator) !== 0 + ) { + let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground); + let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground); + const styleSize = this.runtime.layout("GhosttyStyle").size; + this.runtime.bytes(this.style, styleSize).fill(0); + this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); + this.runtime.call( + "ghostty_render_state_row_cells_get", + cellsIterator, + CELL_DATA.style, + this.style, + ); + const inverse = this.runtime.readField(this.style, "GhosttyStyle", "inverse") !== 0; + if (inverse) [foreground, background] = [background, foreground]; + if (this.runtime.readField(this.style, "GhosttyStyle", "faint") !== 0) { + foreground = blend(foreground, background); + } + const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength); + let text = ""; + if (graphemeLength > 0) { + const bufferSize = graphemeLength * 4; + const codepoints = this.runtime.alloc(bufferSize); + if ( + this.runtime.call( + "ghostty_render_state_row_cells_get", + cellsIterator, + CELL_DATA.graphemes, + codepoints, + ) === GHOSTTY_SUCCESS + ) { + // Read through a DataView: the byte-array allocator guarantees no + // 4-byte alignment, which a Uint32Array view would require. + const codepointView = this.runtime.view(codepoints, bufferSize); + const codes: number[] = []; + for (let index = 0; index < graphemeLength; index += 1) { + codes.push(codepointView.getUint32(index * 4, true)); + } + text = String.fromCodePoint(...codes); + } + this.runtime.free(codepoints, bufferSize); + } + let wide = 0; + if (text.length === 0 && cells.at(-1)?.text.length) { + this.assertSuccess( + "ghostty_render_state_row_cells_get(raw)", + this.runtime.call( + "ghostty_render_state_row_cells_get", + cellsIterator, + CELL_DATA.raw, + this.scratch, + ), + ); + const rawCell = this.runtime.view(this.scratch, 8).getBigUint64(0, true); + this.runtime.view(this.scratch + 8, 4).setUint32(0, 0, true); + this.assertSuccess( + "ghostty_cell_get(wide)", + this.runtime.call("ghostty_cell_get", rawCell, RAW_CELL_DATA.wide, this.scratch + 8), + ); + wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true); + } + cells.push({ + text, + wide, + foreground, + background, + bold: this.runtime.readField(this.style, "GhosttyStyle", "bold") !== 0, + italic: this.runtime.readField(this.style, "GhosttyStyle", "italic") !== 0, + invisible: this.runtime.readField(this.style, "GhosttyStyle", "invisible") !== 0, + strikethrough: this.runtime.readField(this.style, "GhosttyStyle", "strikethrough") !== 0, + overline: this.runtime.readField(this.style, "GhosttyStyle", "overline") !== 0, + underline: this.runtime.readField(this.style, "GhosttyStyle", "underline") !== 0, + selected: this.getCellBool(cellsIterator, CELL_DATA.selected), + }); + } + while (cells.length < cols) cells.push(this.emptyCell(defaultForeground, defaultBackground)); + return { + cells, + text: cells + .map((cell) => cell.text || " ") + .join("") + .trimEnd(), + isWrapContinuation, + wrapsToNext, + }; + } + + private gridRef(col: number, row: number, tag: 1 | 2 = 1): number { + const pointLayout = this.runtime.layout("GhosttyPoint"); + const point = this.runtime.alloc(pointLayout.size); + this.runtime.setField(point, "GhosttyPoint", "tag", tag); + const pointValue = pointLayout.fields.value!; + const valueOffset = pointValue.offset; + const view = this.runtime.view(point + valueOffset, pointValue.size); + view.setUint16(0, Math.max(0, col), true); + view.setUint32(4, Math.max(0, row), true); + const gridRefSize = this.runtime.layout("GhosttyGridRef").size; + const gridRef = this.runtime.alloc(gridRefSize); + this.runtime.setField(gridRef, "GhosttyGridRef", "size", gridRefSize); + const result = this.runtime.call("ghostty_terminal_grid_ref", this.terminal, point, gridRef); + this.runtime.free(point, pointLayout.size); + if (result !== GHOSTTY_SUCCESS) { + this.runtime.free(gridRef, gridRefSize); + this.assertSuccess("ghostty_terminal_grid_ref", result); + } + return gridRef; + } + + private selectAt( + optionsName: "GhosttyTerminalSelectWordOptions" | "GhosttyTerminalSelectLineOptions", + operation: "ghostty_terminal_select_word" | "ghostty_terminal_select_line", + col: number, + row: number, + ): GhosttySelectionRange | null { + this.ensureActive(); + const optionsLayout = this.runtime.layout(optionsName); + const selectionLayout = this.runtime.layout("GhosttySelection"); + const options = this.runtime.alloc(optionsLayout.size); + let ref = 0; + let selection = 0; + let range: GhosttySelectionRange | null = null; + try { + this.runtime.setField(options, optionsName, "size", optionsLayout.size); + ref = this.gridRef(col, row); + const refField = optionsLayout.fields.ref!; + this.runtime + .bytes(options + refField.offset, refField.size) + .set(this.runtime.bytes(ref, refField.size)); + selection = this.runtime.alloc(selectionLayout.size); + this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size); + const result = this.runtime.call(operation, this.terminal, options, selection); + if (result === GHOSTTY_SUCCESS) { + const start = selection + selectionLayout.fields.start!.offset; + const end = selection + selectionLayout.fields.end!.offset; + const viewportStart = this.pointFromGridRef(start, 1); + const viewportEnd = this.pointFromGridRef(end, 1); + const screenStart = this.pointFromGridRef(start, 2); + const screenEnd = this.pointFromGridRef(end, 2); + if (viewportStart && viewportEnd && screenStart && screenEnd) { + range = { + viewport: { start: viewportStart, end: viewportEnd }, + screen: { start: screenStart, end: screenEnd }, + }; + } + this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); + } + } finally { + this.runtime.free(selection, selectionLayout.size); + this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); + this.runtime.free(options, optionsLayout.size); + } + return range; + } + + private pointFromGridRef(ref: number, tag: 1 | 2): { x: number; y: number } | null { + const coordinateLayout = this.runtime.layout("GhosttyPointCoordinate"); + const coordinate = this.runtime.alloc(coordinateLayout.size); + const result = this.runtime.call( + "ghostty_terminal_point_from_grid_ref", + this.terminal, + ref, + tag, + coordinate, + ); + const point = + result === GHOSTTY_SUCCESS + ? { + x: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "x"), + y: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "y"), + } + : null; + this.runtime.free(coordinate, coordinateLayout.size); + return point; + } + + private getU16(data: number): number { + this.runtime.bytes(this.scratch, 2).fill(0); + this.assertSuccess( + "ghostty_render_state_get", + this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), + ); + return this.runtime.view(this.scratch, 2).getUint16(0, true); + } + + private getU32(data: number): number { + this.runtime.bytes(this.scratch, 4).fill(0); + this.assertSuccess( + "ghostty_render_state_get", + this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), + ); + return this.runtime.view(this.scratch, 4).getUint32(0, true); + } + + private getBool(data: number): boolean { + this.runtime.bytes(this.scratch, 1)[0] = 0; + this.assertSuccess( + "ghostty_render_state_get", + this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), + ); + return this.runtime.bytes(this.scratch, 1)[0] !== 0; + } + + private getColor(data: number, fallback: GhosttyColor): GhosttyColor { + this.runtime.bytes(this.scratch, 3).fill(0); + const result = this.runtime.call( + "ghostty_render_state_get", + this.renderState, + data, + this.scratch, + ); + return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback; + } + + private getRowBool(iterator: number, data: number): boolean { + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_render_state_row_get", iterator, data, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + private getCellU32(iterator: number, data: number): number { + this.runtime.bytes(this.scratch, 4).fill(0); + const result = this.runtime.call( + "ghostty_render_state_row_cells_get", + iterator, + data, + this.scratch, + ); + return result === GHOSTTY_SUCCESS ? this.runtime.view(this.scratch, 4).getUint32(0, true) : 0; + } + + private getCellBool(iterator: number, data: number): boolean { + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_render_state_row_cells_get", iterator, data, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor { + this.runtime.bytes(this.scratch, 3).fill(0); + const result = this.runtime.call( + "ghostty_render_state_row_cells_get", + iterator, + data, + this.scratch, + ); + return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback; + } + + private readColor(pointer: number): GhosttyColor { + const bytes = this.runtime.bytes(pointer, 3); + return { r: bytes[0] ?? 0, g: bytes[1] ?? 0, b: bytes[2] ?? 0 }; + } + + private emptyCell(foreground: GhosttyColor, background: GhosttyColor): GhosttyCell { + return { + text: "", + wide: 0, + foreground, + background, + bold: false, + italic: false, + invisible: false, + strikethrough: false, + overline: false, + underline: false, + selected: false, + }; + } + + private assertSuccess(operation: string, result: number): void { + if (result !== GHOSTTY_SUCCESS) throw new Error(`${operation} failed with result ${result}`); + } + + private ensureActive(): void { + if (this.disposed) throw new Error("libghostty-vt terminal has been disposed"); + } +} + +export function ghosttyColorsEqual(left: GhosttyColor, right: GhosttyColor): boolean { + return sameColor(left, right); +} diff --git a/apps/web/src/terminal/ghostty/fonts/LICENSE b/apps/web/src/terminal/ghostty/fonts/LICENSE new file mode 100644 index 00000000000..06eb073d6b5 --- /dev/null +++ b/apps/web/src/terminal/ghostty/fonts/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/web/src/terminal/ghostty/fonts/SymbolsNerdFontMono-Regular.woff2 b/apps/web/src/terminal/ghostty/fonts/SymbolsNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..1e2127fadb9 Binary files /dev/null and b/apps/web/src/terminal/ghostty/fonts/SymbolsNerdFontMono-Regular.woff2 differ diff --git a/apps/web/src/terminal/ghostty/keyCodes.test.ts b/apps/web/src/terminal/ghostty/keyCodes.test.ts new file mode 100644 index 00000000000..d6fc0cca45e --- /dev/null +++ b/apps/web/src/terminal/ghostty/keyCodes.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ghosttyKeyForCode, ghosttyUnshiftedCodepoint } from "./keyCodes"; + +describe("ghosttyKeyForCode", () => { + it("keeps the tail of the pinned Ghostty key enum in order", () => { + expect(ghosttyKeyForCode("F25")).toBe(ghosttyKeyForCode("F24") + 1); + expect(ghosttyKeyForCode("PrintScreen")).toBe(ghosttyKeyForCode("FnLock") + 1); + expect(ghosttyKeyForCode("Pause")).toBe(ghosttyKeyForCode("ScrollLock") + 1); + expect(ghosttyKeyForCode("Paste")).toBe(ghosttyKeyForCode("Cut") + 1); + }); +}); + +describe("ghosttyUnshiftedCodepoint", () => { + it("provides the logical base character for Kitty keyboard encoding", () => { + expect(ghosttyUnshiftedCodepoint({ code: "KeyC", key: "c", shiftKey: false })).toBe( + "c".codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: "KeyC", key: "C", shiftKey: true })).toBe( + "c".codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: "Digit1", key: "!", shiftKey: true })).toBe( + "1".codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: "Slash", key: "?", shiftKey: true })).toBe( + "/".codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: "Digit1", key: "&", shiftKey: false })).toBe( + "&".codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: "Enter", key: "Enter", shiftKey: false })).toBe(0); + }); + + it("reports unknown instead of the shifted character without layout data", () => { + expect(ghosttyUnshiftedCodepoint({ code: "Digit7", key: "/", shiftKey: true })).toBe(0); + expect(ghosttyUnshiftedCodepoint({ code: "KeyD", key: "Д", shiftKey: true })).toBe( + "д".codePointAt(0), + ); + }); + + it("prefers the active browser layout over US physical key positions", () => { + const layoutMap = new Map([ + ["Digit1", "&"], + ["KeyC", "j"], + ]); + expect(ghosttyUnshiftedCodepoint({ code: "Digit1", key: "1", shiftKey: true }, layoutMap)).toBe( + "&".codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: "KeyC", key: "J", shiftKey: true }, layoutMap)).toBe( + "j".codePointAt(0), + ); + }); +}); diff --git a/apps/web/src/terminal/ghostty/keyCodes.ts b/apps/web/src/terminal/ghostty/keyCodes.ts new file mode 100644 index 00000000000..a82c748b274 --- /dev/null +++ b/apps/web/src/terminal/ghostty/keyCodes.ts @@ -0,0 +1,258 @@ +// This order mirrors GhosttyKey in ghostty/vt/key/event.h. The values are +// intentionally derived from the official W3C-aligned enum instead of +// maintaining a second keyboard protocol. +const ghosttyKeyboardCodes = [ + "Unidentified", + "Backquote", + "Backslash", + "BracketLeft", + "BracketRight", + "Comma", + "Digit0", + "Digit1", + "Digit2", + "Digit3", + "Digit4", + "Digit5", + "Digit6", + "Digit7", + "Digit8", + "Digit9", + "Equal", + "IntlBackslash", + "IntlRo", + "IntlYen", + "KeyA", + "KeyB", + "KeyC", + "KeyD", + "KeyE", + "KeyF", + "KeyG", + "KeyH", + "KeyI", + "KeyJ", + "KeyK", + "KeyL", + "KeyM", + "KeyN", + "KeyO", + "KeyP", + "KeyQ", + "KeyR", + "KeyS", + "KeyT", + "KeyU", + "KeyV", + "KeyW", + "KeyX", + "KeyY", + "KeyZ", + "Minus", + "Period", + "Quote", + "Semicolon", + "Slash", + "AltLeft", + "AltRight", + "Backspace", + "CapsLock", + "ContextMenu", + "ControlLeft", + "ControlRight", + "Enter", + "MetaLeft", + "MetaRight", + "ShiftLeft", + "ShiftRight", + "Space", + "Tab", + "Convert", + "KanaMode", + "NonConvert", + "Delete", + "End", + "Help", + "Home", + "Insert", + "PageDown", + "PageUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "NumLock", + "Numpad0", + "Numpad1", + "Numpad2", + "Numpad3", + "Numpad4", + "Numpad5", + "Numpad6", + "Numpad7", + "Numpad8", + "Numpad9", + "NumpadAdd", + "NumpadBackspace", + "NumpadClear", + "NumpadClearEntry", + "NumpadComma", + "NumpadDecimal", + "NumpadDivide", + "NumpadEnter", + "NumpadEqual", + "NumpadMemoryAdd", + "NumpadMemoryClear", + "NumpadMemoryRecall", + "NumpadMemoryStore", + "NumpadMemorySubtract", + "NumpadMultiply", + "NumpadParenLeft", + "NumpadParenRight", + "NumpadSubtract", + "NumpadSeparator", + "NumpadArrowUp", + "NumpadArrowDown", + "NumpadArrowRight", + "NumpadArrowLeft", + "NumpadBegin", + "NumpadHome", + "NumpadEnd", + "NumpadInsert", + "NumpadDelete", + "NumpadPageUp", + "NumpadPageDown", + "Escape", + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12", + "F13", + "F14", + "F15", + "F16", + "F17", + "F18", + "F19", + "F20", + "F21", + "F22", + "F23", + "F24", + "F25", + "Fn", + "FnLock", + "PrintScreen", + "ScrollLock", + "Pause", + "BrowserBack", + "BrowserFavorites", + "BrowserForward", + "BrowserHome", + "BrowserRefresh", + "BrowserSearch", + "BrowserStop", + "Eject", + "LaunchApp1", + "LaunchApp2", + "LaunchMail", + "MediaPlayPause", + "MediaSelect", + "MediaStop", + "MediaTrackNext", + "MediaTrackPrevious", + "Power", + "Sleep", + "AudioVolumeDown", + "AudioVolumeMute", + "AudioVolumeUp", + "WakeUp", + "Copy", + "Cut", + "Paste", +] as const; + +const codeToGhosttyKey = new Map( + ghosttyKeyboardCodes.map((code, index) => [code, index]), +); + +export function ghosttyKeyForCode(code: string): number { + return codeToGhosttyKey.get(code) ?? 0; +} + +export interface GhosttyKeyboardLayoutMap { + get(code: string): string | undefined; +} + +const shiftedToUnshiftedCharacter = new Map([ + ["!", "1"], + ["@", "2"], + ["#", "3"], + ["$", "4"], + ["%", "5"], + ["^", "6"], + ["&", "7"], + ["*", "8"], + ["(", "9"], + [")", "0"], + ["~", "`"], + ["_", "-"], + ["+", "="], + ["{", "["], + ["}", "]"], + ["|", "\\"], + [":", ";"], + ['"', "'"], + ["<", ","], + [">", "."], + ["?", "/"], +]); + +let keyboardLayoutMapPromise: Promise | undefined; + +export function loadGhosttyKeyboardLayoutMap(): Promise { + if (keyboardLayoutMapPromise) return keyboardLayoutMapPromise; + const browserNavigator = globalThis.navigator as + | (Navigator & { + readonly keyboard?: { + getLayoutMap(): Promise; + }; + }) + | undefined; + const keyboard = browserNavigator?.keyboard; + const promise = keyboard?.getLayoutMap().catch(() => undefined) ?? Promise.resolve(undefined); + keyboardLayoutMapPromise = promise; + return promise; +} + +export function ghosttyUnshiftedCodepoint( + event: Pick, + layoutMap?: GhosttyKeyboardLayoutMap, +): number { + if ([...event.key].length !== 1) return 0; + const layoutCharacter = layoutMap?.get(event.code); + if (layoutCharacter && [...layoutCharacter].length === 1) { + return layoutCharacter.codePointAt(0) ?? 0; + } + if (/^[A-Z]$/u.test(event.key)) return event.key.charCodeAt(0) + 32; + if (event.shiftKey) { + const unshiftedCharacter = shiftedToUnshiftedCharacter.get(event.key); + if (unshiftedCharacter) return unshiftedCharacter.codePointAt(0) ?? 0; + const lowercase = event.key.toLowerCase(); + if (lowercase !== event.key && [...lowercase].length === 1) { + return lowercase.codePointAt(0) ?? 0; + } + // Without layout data the unshifted form of a shifted key is unknowable; + // reporting the shifted character as unshifted corrupts Kitty alternate keys. + return 0; + } + return event.key.codePointAt(0) ?? 0; +} diff --git a/apps/web/src/terminal/ghostty/renderer.test.ts b/apps/web/src/terminal/ghostty/renderer.test.ts new file mode 100644 index 00000000000..e4d292a2c33 --- /dev/null +++ b/apps/web/src/terminal/ghostty/renderer.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { GHOSTTY_CELL_WIDE, type GhosttyCell, type GhosttySnapshot } from "./core"; +import { + ghosttyTextRunEnd, + measureGhosttyCell, + renderGhosttySnapshot, + terminalGridSize, +} from "./renderer"; + +const cell = (text: string, wide = 0): GhosttyCell => ({ + text, + wide, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + bold: false, + italic: false, + invisible: false, + strikethrough: false, + overline: false, + underline: false, + selected: false, +}); + +describe("terminalGridSize", () => { + it("matches the mobile renderer's cell-and-padding sizing model", () => { + expect(terminalGridSize(808, 408, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({ + cols: 80, + rows: 20, + }); + }); + + it("never sends an invalid zero-sized terminal to libghostty", () => { + expect(terminalGridSize(0, 0, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({ + cols: 1, + rows: 1, + }); + }); +}); + +describe("measureGhosttyCell", () => { + it("uses descender-aware metrics and the mobile terminal line-height", () => { + const measureText = (text: string) => + text === "M" + ? { width: 7.2, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 0 } + : { width: 14.4, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 3 }; + const context = { + font: "", + measureText, + } as unknown as CanvasRenderingContext2D; + + expect(measureGhosttyCell(context, 12, "monospace")).toEqual({ + width: 7.2, + height: 16, + baseline: 11, + }); + }); +}); + +describe("ghosttyTextRunEnd", () => { + it("includes wide spacer tails in the visual clip without rendering spaces", () => { + const cells = [ + cell("界", GHOSTTY_CELL_WIDE.wide), + cell("", GHOSTTY_CELL_WIDE.spacerTail), + cell("🙂", GHOSTTY_CELL_WIDE.wide), + cell("", GHOSTTY_CELL_WIDE.spacerTail), + cell(""), + ]; + expect(ghosttyTextRunEnd(cells, 0, () => true)).toBe(4); + }); +}); + +describe("renderGhosttySnapshot", () => { + it("constrains text runs and cursor glyphs to their terminal cells", () => { + const fillTextCalls: unknown[][] = []; + const context = { + canvas: { width: 200, height: 40 }, + beginPath: () => {}, + clip: () => {}, + fillRect: () => {}, + fillText: (...args: unknown[]) => fillTextCalls.push(args), + rect: () => {}, + resetTransform: () => {}, + restore: () => {}, + save: () => {}, + set fillStyle(_value: string) {}, + set font(_value: string) {}, + set textBaseline(_value: string) {}, + } as unknown as CanvasRenderingContext2D; + const cells = [cell("a"), cell("b"), cell("x")]; + const snapshot: GhosttySnapshot = { + cols: 3, + rows: 1, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + cursorX: 2, + cursorY: 0, + cursorVisible: true, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set([0]), + rowData: [{ cells, text: "abx", isWrapContinuation: false, wrapsToNext: false }], + }; + + renderGhosttySnapshot({ + context, + snapshot, + metrics: { width: 7.2, height: 16, baseline: 11 }, + fontSize: 12, + fontFamily: "monospace", + padding: 4, + forceFull: false, + cursorOn: true, + }); + + expect(fillTextCalls).toEqual([ + ["abx", 4, 15, 21.6], + ["x", 18.4, 15, 7.2], + ]); + }); + + it("repaints the previous cursor row after the cursor moves", () => { + const clearedRows: number[] = []; + const context = { + canvas: { width: 200, height: 80 }, + beginPath: () => {}, + clip: () => {}, + fillRect: (_left: number, top: number, _width: number, height: number) => { + if (height === 16) clearedRows.push(top); + }, + fillText: () => {}, + rect: () => {}, + resetTransform: () => {}, + restore: () => {}, + save: () => {}, + set fillStyle(_value: string) {}, + set font(_value: string) {}, + set textBaseline(_value: string) {}, + } as unknown as CanvasRenderingContext2D; + const snapshot: GhosttySnapshot = { + cols: 1, + rows: 3, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + cursorX: 0, + cursorY: 2, + cursorVisible: true, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set(), + rowData: [0, 1, 2].map(() => ({ + cells: [cell("")], + text: "", + isWrapContinuation: false, + wrapsToNext: false, + })), + }; + + renderGhosttySnapshot({ + context, + snapshot, + metrics: { width: 7.2, height: 16, baseline: 11 }, + fontSize: 12, + fontFamily: "monospace", + padding: 4, + forceFull: false, + cursorOn: true, + previousCursorY: 0, + }); + + expect(clearedRows).toEqual([4, 36, 36]); + }); +}); diff --git a/apps/web/src/terminal/ghostty/renderer.ts b/apps/web/src/terminal/ghostty/renderer.ts new file mode 100644 index 00000000000..0cb7d6f535d --- /dev/null +++ b/apps/web/src/terminal/ghostty/renderer.ts @@ -0,0 +1,255 @@ +import { + GHOSTTY_CELL_WIDE, + ghosttyColorsEqual, + type GhosttyCell, + type GhosttyColor, + type GhosttySnapshot, +} from "./core"; + +export interface GhosttyCellMetrics { + readonly width: number; + readonly height: number; + readonly baseline: number; +} + +const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)"; + +function cssColor(color: GhosttyColor): string { + return `rgb(${color.r}, ${color.g}, ${color.b})`; +} + +function sameTextStyle(left: GhosttyCell, right: GhosttyCell): boolean { + // Selection deliberately does not participate: it only tints the background + // overlay, and splitting a text run at a selection boundary visibly shifts + // glyph spacing whenever the face's true advance differs from the cell width. + return ( + ghosttyColorsEqual(left.foreground, right.foreground) && + left.bold === right.bold && + left.italic === right.italic && + left.invisible === right.invisible + ); +} + +export function ghosttyTextRunEnd( + cells: readonly GhosttyCell[], + start: number, + sameStyle: (cell: GhosttyCell) => boolean, +): number { + let end = start + 1; + while (end < cells.length) { + const next = cells[end]; + if (!next) break; + if (next.wide === GHOSTTY_CELL_WIDE.spacerTail) { + end += 1; + continue; + } + if (next.text.length === 0 || !sameStyle(next)) break; + end += 1; + } + return end; +} + +function fontForCell(cell: GhosttyCell, fontSize: number, fontFamily: string): string { + const style = cell.italic ? "italic" : "normal"; + const weight = cell.bold ? "700" : "400"; + return `${style} ${weight} ${fontSize}px ${fontFamily}`; +} + +export function measureGhosttyCell( + context: CanvasRenderingContext2D, + fontSize: number, + fontFamily: string, +): GhosttyCellMetrics { + context.font = `normal 400 ${fontSize}px ${fontFamily}`; + const widthMeasurement = context.measureText("M"); + const verticalMeasurement = context.measureText("Mg"); + const ascent = verticalMeasurement.actualBoundingBoxAscent || fontSize; + const descent = verticalMeasurement.actualBoundingBoxDescent; + const glyphHeight = ascent + descent; + const height = Math.max(1, Math.round(fontSize * 1.35), Math.ceil(glyphHeight)); + return { + width: Math.max(1, widthMeasurement.width), + height, + baseline: Math.round((height - glyphHeight) / 2 + ascent), + }; +} + +export function terminalGridSize( + width: number, + height: number, + metrics: GhosttyCellMetrics, + padding: number, +): { cols: number; rows: number } { + return { + cols: Math.max(1, Math.floor((width - padding * 2) / metrics.width)), + rows: Math.max(1, Math.floor((height - padding * 2) / metrics.height)), + }; +} + +export function renderGhosttySnapshot(options: { + readonly context: CanvasRenderingContext2D; + readonly snapshot: GhosttySnapshot; + readonly metrics: GhosttyCellMetrics; + readonly fontSize: number; + readonly fontFamily: string; + readonly padding: number; + readonly forceFull: boolean; + readonly cursorOn: boolean; + readonly previousCursorY?: number | null; + readonly focused?: boolean; + readonly selectionBackground?: string; + /** Vertical origin of row 0; defaults to the horizontal padding. */ + readonly originY?: number; +}): void { + const { + context, + snapshot, + metrics, + fontSize, + fontFamily, + padding, + forceFull, + cursorOn, + previousCursorY, + } = options; + const focused = options.focused ?? true; + const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND; + const originY = options.originY ?? padding; + const rowsToDraw = forceFull + ? Array.from({ length: snapshot.rows }, (_, index) => index) + : [...snapshot.dirtyRows]; + if ( + previousCursorY !== null && + previousCursorY !== undefined && + previousCursorY >= 0 && + !rowsToDraw.includes(previousCursorY) + ) { + rowsToDraw.push(previousCursorY); + } + if (snapshot.cursorVisible && snapshot.cursorY >= 0 && !rowsToDraw.includes(snapshot.cursorY)) { + rowsToDraw.push(snapshot.cursorY); + } + + if (forceFull) { + context.save(); + context.resetTransform(); + context.fillStyle = cssColor(snapshot.background); + context.fillRect(0, 0, context.canvas.width, context.canvas.height); + context.restore(); + } + + context.textBaseline = "alphabetic"; + for (const rowIndex of rowsToDraw) { + const row = snapshot.rowData[rowIndex]; + if (!row) continue; + const top = originY + rowIndex * metrics.height; + + context.fillStyle = cssColor(snapshot.background); + context.fillRect(padding, top, snapshot.cols * metrics.width, metrics.height); + + let backgroundStart = 0; + while (backgroundStart < row.cells.length) { + const first = row.cells[backgroundStart]; + if (!first) break; + let backgroundEnd = backgroundStart + 1; + while (backgroundEnd < row.cells.length) { + const next = row.cells[backgroundEnd]; + if ( + !next || + next.selected !== first.selected || + !ghosttyColorsEqual(next.background, first.background) + ) { + break; + } + backgroundEnd += 1; + } + if (first.selected || !ghosttyColorsEqual(first.background, snapshot.background)) { + const left = padding + backgroundStart * metrics.width; + const width = (backgroundEnd - backgroundStart) * metrics.width; + if (!ghosttyColorsEqual(first.background, snapshot.background)) { + context.fillStyle = cssColor(first.background); + context.fillRect(left, top, width, metrics.height); + } + if (first.selected) { + context.fillStyle = selectionBackground; + context.fillRect(left, top, width, metrics.height); + } + } + backgroundStart = backgroundEnd; + } + + let runStart = 0; + while (runStart < row.cells.length) { + const first = row.cells[runStart]; + if (!first) break; + if (first.text.length === 0) { + runStart += 1; + continue; + } + const runEnd = ghosttyTextRunEnd(row.cells, runStart, (cell) => sameTextStyle(cell, first)); + const text = row.cells + .slice(runStart, runEnd) + .map((cell) => cell.text) + .join(""); + if (!first.invisible && text.trim().length > 0) { + context.save(); + context.beginPath(); + context.rect( + padding + runStart * metrics.width, + top, + (runEnd - runStart) * metrics.width, + metrics.height, + ); + context.clip(); + context.font = fontForCell(first, fontSize, fontFamily); + context.fillStyle = cssColor(first.foreground); + context.fillText( + text, + padding + runStart * metrics.width, + top + metrics.baseline, + (runEnd - runStart) * metrics.width, + ); + context.restore(); + } + runStart = runEnd; + } + + for (let column = 0; column < row.cells.length; column += 1) { + const cell = row.cells[column]; + if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline)) continue; + context.fillStyle = cssColor(cell.foreground); + const left = padding + column * metrics.width; + if (cell.underline) context.fillRect(left, top + metrics.height - 2, metrics.width, 1); + if (cell.strikethrough) { + context.fillRect(left, top + Math.floor(metrics.height * 0.55), metrics.width, 1); + } + if (cell.overline) context.fillRect(left, top + 1, metrics.width, 1); + } + } + + if (cursorOn && snapshot.cursorVisible && snapshot.cursorX >= 0 && snapshot.cursorY >= 0) { + const left = padding + snapshot.cursorX * metrics.width; + const top = originY + snapshot.cursorY * metrics.height; + context.fillStyle = cssColor(snapshot.cursor); + if (!focused) { + // An unfocused terminal draws a hollow cursor so the active pane is obvious. + context.strokeStyle = cssColor(snapshot.cursor); + context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); + } else if (snapshot.cursorStyle === 0) { + context.fillRect(left, top, 2, metrics.height); + } else if (snapshot.cursorStyle === 2) { + context.fillRect(left, top + metrics.height - 2, metrics.width, 2); + } else if (snapshot.cursorStyle === 3) { + context.strokeStyle = cssColor(snapshot.cursor); + context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); + } else { + context.fillRect(left, top, metrics.width, metrics.height); + const cell = snapshot.rowData[snapshot.cursorY]?.cells[snapshot.cursorX]; + if (cell?.text) { + context.font = fontForCell(cell, fontSize, fontFamily); + context.fillStyle = cssColor(snapshot.background); + context.fillText(cell.text, left, top + metrics.baseline, metrics.width); + } + } + } +} diff --git a/apps/web/src/terminal/ghostty/runtime.ts b/apps/web/src/terminal/ghostty/runtime.ts new file mode 100644 index 00000000000..aca82e7cc3c --- /dev/null +++ b/apps/web/src/terminal/ghostty/runtime.ts @@ -0,0 +1,221 @@ +import ghosttyWasmUrl from "./vendor/ghostty-vt.wasm?url"; +import ghosttyWritePtyWasmUrl from "./vendor/ghostty-write-pty.wasm?url&no-inline"; + +type WasmFunction = (...args: Array) => number; + +interface TypeField { + readonly offset: number; + readonly size: number; + readonly type: string; +} + +interface TypeLayout { + readonly size: number; + readonly align: number; + readonly fields: Readonly>; +} + +type TypeLayouts = Readonly>; + +const textDecoder = new TextDecoder(); + +export class GhosttyRuntime { + readonly memory: WebAssembly.Memory; + readonly layouts: TypeLayouts; + private readonly exports: WebAssembly.Exports; + private readonly ptyWriters = new Map void>(); + private nextPtyWriterId = 1; + private writePtyFunctionIndex = 0; + + private constructor(instance: WebAssembly.Instance) { + this.exports = instance.exports; + const memory = instance.exports.memory; + if (!(memory instanceof WebAssembly.Memory)) { + throw new Error("libghostty-vt did not export WebAssembly memory"); + } + this.memory = memory; + const jsonPointer = this.call("ghostty_type_json"); + const bytes = new Uint8Array(memory.buffer); + let end = jsonPointer; + while (end < bytes.length && bytes[end] !== 0) end += 1; + this.layouts = JSON.parse(textDecoder.decode(bytes.subarray(jsonPointer, end))) as TypeLayouts; + } + + static async load(): Promise { + const response = await fetch(ghosttyWasmUrl); + if (!response.ok) { + throw new Error(`Unable to load libghostty-vt (${response.status})`); + } + let instance: WebAssembly.Instance | undefined; + const imports = { + env: { + log: (pointer: number, length: number) => { + if (!instance) return; + const memory = instance.exports.memory; + if (!(memory instanceof WebAssembly.Memory)) return; + const message = textDecoder.decode(new Uint8Array(memory.buffer, pointer, length)); + console.debug("[libghostty-vt]", message); + }, + }, + }; + const result = await WebAssembly.instantiate(await response.arrayBuffer(), imports); + instance = result.instance; + const runtime = new GhosttyRuntime(result.instance); + await runtime.installWritePtyTrampoline(); + return runtime; + } + + call(name: string, ...args: Array): number { + const fn = this.exports[name]; + if (typeof fn !== "function") { + throw new Error(`libghostty-vt export is unavailable: ${name}`); + } + return (fn as WasmFunction)(...args); + } + + layout(name: string): TypeLayout { + const layout = this.layouts[name]; + if (!layout) throw new Error(`libghostty-vt type layout is unavailable: ${name}`); + return layout; + } + + alloc(size: number): number { + const pointer = this.call("ghostty_wasm_alloc_u8_array", size); + if (pointer === 0) throw new Error(`libghostty-vt failed to allocate ${size} bytes`); + new Uint8Array(this.memory.buffer, pointer, size).fill(0); + return pointer; + } + + free(pointer: number, size: number): void { + if (pointer !== 0) this.call("ghostty_wasm_free_u8_array", pointer, size); + } + + allocOpaque(): number { + const pointer = this.call("ghostty_wasm_alloc_opaque"); + if (pointer === 0) throw new Error("libghostty-vt failed to allocate an opaque pointer"); + // The slot is uninitialized until a *_new call writes it; zero it so dispose + // paths that run after a partial initialization never free a garbage pointer. + new DataView(this.memory.buffer).setUint32(pointer, 0, true); + return pointer; + } + + freeOpaque(pointer: number): void { + if (pointer !== 0) this.call("ghostty_wasm_free_opaque", pointer); + } + + readPointer(slot: number): number { + return new DataView(this.memory.buffer).getUint32(slot, true); + } + + attachPtyWriter(terminal: number, writer: (data: string) => void): number { + if (this.writePtyFunctionIndex === 0) { + throw new Error("libghostty-vt PTY callback trampoline is unavailable"); + } + const id = this.nextPtyWriterId++; + this.ptyWriters.set(id, writer); + this.call("ghostty_terminal_set", terminal, 0, id); + this.call("ghostty_terminal_set", terminal, 1, this.writePtyFunctionIndex); + return id; + } + + detachPtyWriter(terminal: number, id: number): void { + this.call("ghostty_terminal_set", terminal, 1, 0); + this.call("ghostty_terminal_set", terminal, 0, 0); + this.ptyWriters.delete(id); + } + + view(pointer: number, size?: number): DataView { + return new DataView(this.memory.buffer, pointer, size); + } + + bytes(pointer: number, size: number): Uint8Array { + return new Uint8Array(this.memory.buffer, pointer, size); + } + + setField(pointer: number, structName: string, fieldName: string, value: number): void { + const field = this.layout(structName).fields[fieldName]; + if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); + const view = this.view(pointer + field.offset, field.size); + switch (field.type) { + case "bool": + case "u8": + view.setUint8(0, value); + return; + case "u16": + view.setUint16(0, value, true); + return; + case "i32": + view.setInt32(0, value, true); + return; + case "u32": + case "enum": + view.setUint32(0, value, true); + return; + case "u64": + view.setBigUint64(0, BigInt(value), true); + return; + default: + throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); + } + } + + readField(pointer: number, structName: string, fieldName: string): number { + const field = this.layout(structName).fields[fieldName]; + if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); + const view = this.view(pointer + field.offset, field.size); + switch (field.type) { + case "bool": + case "u8": + return view.getUint8(0); + case "u16": + return view.getUint16(0, true); + case "i32": + return view.getInt32(0, true); + case "u32": + case "enum": + return view.getUint32(0, true); + case "u64": + return Number(view.getBigUint64(0, true)); + default: + throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); + } + } + + private async installWritePtyTrampoline(): Promise { + const response = await fetch(ghosttyWritePtyWasmUrl); + if (!response.ok) { + throw new Error(`Unable to load the libghostty-vt PTY trampoline (${response.status})`); + } + const result = await WebAssembly.instantiate(await response.arrayBuffer(), { + env: { + t3_write_pty: (_terminal: number, userdata: number, pointer: number, length: number) => { + const writer = this.ptyWriters.get(userdata); + if (!writer || length === 0) return; + writer(textDecoder.decode(new Uint8Array(this.memory.buffer, pointer, length))); + }, + }, + }); + const trampoline = result.instance.exports.ghostty_write_pty; + const table = this.exports.__indirect_function_table; + if (typeof trampoline !== "function" || !(table instanceof WebAssembly.Table)) { + throw new Error("libghostty-vt did not expose its callback table"); + } + const index = table.length; + // grow-then-set instead of grow(1, fn): WebKit stores a grow init value + // with broken type information and every later call_indirect through the + // entry traps with a signature mismatch. table.set canonicalizes correctly. + table.grow(1); + table.set(index, trampoline); + this.writePtyFunctionIndex = index; + } +} + +let runtimePromise: Promise | null = null; + +export function loadGhosttyRuntime(): Promise { + runtimePromise ??= GhosttyRuntime.load().catch((error) => { + runtimePromise = null; + throw error; + }); + return runtimePromise; +} diff --git a/apps/web/src/terminal/ghostty/runtimeAbi.test.ts b/apps/web/src/terminal/ghostty/runtimeAbi.test.ts new file mode 100644 index 00000000000..0b3eac7f1ed --- /dev/null +++ b/apps/web/src/terminal/ghostty/runtimeAbi.test.ts @@ -0,0 +1,561 @@ +import { describe, expect, it } from "vite-plus/test"; + +import wasmDataUrl from "./vendor/ghostty-vt.wasm?inline"; +import writePtyWasmDataUrl from "./vendor/ghostty-write-pty.wasm?inline"; +import mobileVersion from "../../../../mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION?raw"; +import { ghosttyKeyForCode } from "./keyCodes"; + +type WasmFunction = (...args: number[]) => number; + +function decodeWasmDataUrl(dataUrl: string): Uint8Array { + const encoded = dataUrl.split(",", 2)[1]; + if (!encoded) throw new Error("The vendored Ghostty WASM data URL is invalid"); + return Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0)); +} + +describe("vendored libghostty-vt WebAssembly", () => { + it("stays pinned to mobile's canonical revision and size budget", async () => { + const wasm = decodeWasmDataUrl(wasmDataUrl); + expect(wasm.byteLength).toBeLessThan(750_000); + + // The artifact carries its own provenance: the build embeds the pinned + // revision as semver build metadata, so mobile's VERSION file is the + // single source of truth and drift is caught here without a copy. + const result = await WebAssembly.instantiate(wasm.buffer as ArrayBuffer, { + env: { log: () => {} }, + }); + const instance = result instanceof WebAssembly.Instance ? result : result.instance; + const memory = instance.exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => + (instance.exports[name] as WasmFunction)(...args); + const out = call("ghostty_wasm_alloc_u8_array", 8); + expect(call("ghostty_build_info", 10, out)).toBe(0); + const view = new DataView(memory.buffer, out, 8); + const embeddedRevision = new TextDecoder().decode( + new Uint8Array(memory.buffer, view.getUint32(0, true), view.getUint32(4, true)), + ); + call("ghostty_wasm_free_u8_array", out, 8); + expect(embeddedRevision).toBe(mobileVersion.trim()); + }); + + it("creates, writes multi-codepoint graphemes, and frees repeated terminals", async () => { + const bytes = decodeWasmDataUrl(wasmDataUrl); + let memory: WebAssembly.Memory | null = null; + const instantiated = await WebAssembly.instantiate(bytes.buffer as ArrayBuffer, { + env: { + log: () => {}, + }, + }); + const instance = + instantiated instanceof WebAssembly.Instance ? instantiated : instantiated.instance; + const exports = instance.exports; + memory = exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => (exports[name] as WasmFunction)(...args); + const jsonPointer = call("ghostty_type_json"); + const jsonBytes = new Uint8Array(memory.buffer, jsonPointer); + const jsonEnd = jsonBytes.indexOf(0); + const layouts = JSON.parse(new TextDecoder().decode(jsonBytes.subarray(0, jsonEnd))) as Record< + string, + { size: number } + >; + const optionsSize = layouts.GhosttyTerminalOptions?.size; + expect(optionsSize).toBe(8); + if (optionsSize === undefined) throw new Error("GhosttyTerminalOptions layout is missing"); + + for (let iteration = 0; iteration < 25; iteration += 1) { + const options = call("ghostty_wasm_alloc_u8_array", optionsSize); + const optionsView = new DataView(memory.buffer, options, optionsSize); + optionsView.setUint16(0, 80, true); + optionsView.setUint16(2, 24, true); + optionsView.setUint32(4, 5_000, true); + const terminalSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0); + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); + + const input = new TextEncoder().encode("e\u0301 👨‍👩‍👧‍👦 العربية\r\n"); + const inputPointer = call("ghostty_wasm_alloc_u8_array", input.length); + new Uint8Array(memory.buffer, inputPointer, input.length).set(input); + call("ghostty_terminal_vt_write", terminal, inputPointer, input.length); + call("ghostty_wasm_free_u8_array", inputPointer, input.length); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_opaque", terminalSlot); + call("ghostty_wasm_free_u8_array", options, optionsSize); + } + }); + + it("reports and scrolls the viewport with Ghostty's scrollbar state", async () => { + const result = await WebAssembly.instantiate( + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, + { env: { log: () => {} } }, + ); + const instance = result instanceof WebAssembly.Instance ? result : result.instance; + const memory = instance.exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => + (instance.exports[name] as WasmFunction)(...args); + const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size); + const options = alloc(8); + const optionsView = new DataView(memory.buffer, options, 8); + optionsView.setUint16(0, 80, true); + optionsView.setUint16(2, 10, true); + optionsView.setUint32(4, 1_000, true); + const terminalSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0); + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); + const input = new TextEncoder().encode( + Array.from({ length: 50 }, (_, index) => `${index + 1}\r\n`).join(""), + ); + const inputPointer = alloc(input.length); + new Uint8Array(memory.buffer, inputPointer, input.length).set(input); + call("ghostty_terminal_vt_write", terminal, inputPointer, input.length); + + const scrollbar = alloc(24); + const scrollbarView = new DataView(memory.buffer, scrollbar, 24); + expect(call("ghostty_terminal_get", terminal, 9, scrollbar)).toBe(0); + expect([0, 8, 16].map((offset) => Number(scrollbarView.getBigUint64(offset, true)))).toEqual([ + 51, 41, 10, + ]); + + const scroll = alloc(24); + const scrollView = new DataView(memory.buffer, scroll, 24); + scrollView.setUint32(0, 2, true); + scrollView.setInt32(8, -5, true); + call("ghostty_terminal_scroll_viewport", terminal, scroll); + expect(call("ghostty_terminal_get", terminal, 9, scrollbar)).toBe(0); + expect(Number(scrollbarView.getBigUint64(8, true))).toBe(36); + + call("ghostty_wasm_free_u8_array", scroll, 24); + call("ghostty_wasm_free_u8_array", scrollbar, 24); + call("ghostty_wasm_free_u8_array", inputPointer, input.length); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_opaque", terminalSlot); + call("ghostty_wasm_free_u8_array", options, 8); + }); + + it("routes terminal-generated replies through the shared callback table", async () => { + const mainResult = await WebAssembly.instantiate( + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, + { env: { log: () => {} } }, + ); + const main = mainResult instanceof WebAssembly.Instance ? mainResult : mainResult.instance; + const memory = main.exports.memory as WebAssembly.Memory; + let reply = ""; + const trampolineResult = await WebAssembly.instantiate( + decodeWasmDataUrl(writePtyWasmDataUrl).buffer as ArrayBuffer, + { + env: { + t3_write_pty: (_terminal: number, _userdata: number, pointer: number, length: number) => { + reply += new TextDecoder().decode(new Uint8Array(memory.buffer, pointer, length)); + }, + }, + }, + ); + const trampoline = + trampolineResult instanceof WebAssembly.Instance + ? trampolineResult + : trampolineResult.instance; + const table = main.exports.__indirect_function_table as WebAssembly.Table; + const callbackIndex = table.length; + table.grow(1, trampoline.exports.ghostty_write_pty as CallableFunction); + const call = (name: string, ...args: number[]) => (main.exports[name] as WasmFunction)(...args); + const options = call("ghostty_wasm_alloc_u8_array", 8); + const optionsView = new DataView(memory.buffer, options, 8); + optionsView.setUint16(0, 80, true); + optionsView.setUint16(2, 24, true); + const terminalSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0); + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); + call("ghostty_terminal_set", terminal, 0, 1); + call("ghostty_terminal_set", terminal, 1, callbackIndex); + const query = new TextEncoder().encode("\u001b[5n"); + const queryPointer = call("ghostty_wasm_alloc_u8_array", query.length); + new Uint8Array(memory.buffer, queryPointer, query.length).set(query); + call("ghostty_terminal_vt_write", terminal, queryPointer, query.length); + + expect(reply).toBe("\u001b[0n"); + reply = ""; + call("ghostty_terminal_set", terminal, 1, 0); + call("ghostty_terminal_set", terminal, 0, 0); + call("ghostty_terminal_vt_write", terminal, queryPointer, query.length); + expect(reply).toBe(""); + call("ghostty_terminal_set", terminal, 0, 1); + call("ghostty_terminal_set", terminal, 1, callbackIndex); + call("ghostty_terminal_vt_write", terminal, queryPointer, query.length); + expect(reply).toBe("\u001b[0n"); + call("ghostty_wasm_free_u8_array", queryPointer, query.length); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_opaque", terminalSlot); + call("ghostty_wasm_free_u8_array", options, 8); + }); + + it("formats the active selection with Ghostty's copy semantics", async () => { + const result = await WebAssembly.instantiate( + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, + { env: { log: () => {} } }, + ); + const instance = result instanceof WebAssembly.Instance ? result : result.instance; + const memory = instance.exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => + (instance.exports[name] as WasmFunction)(...args); + const options = call("ghostty_wasm_alloc_u8_array", 8); + const optionsView = new DataView(memory.buffer, options, 8); + optionsView.setUint16(0, 80, true); + optionsView.setUint16(2, 24, true); + const terminalSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0); + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); + const input = new TextEncoder().encode("a\r\n\r\nb"); + const inputPointer = call("ghostty_wasm_alloc_u8_array", input.length); + new Uint8Array(memory.buffer, inputPointer, input.length).set(input); + call("ghostty_terminal_vt_write", terminal, inputPointer, input.length); + + const selection = call("ghostty_wasm_alloc_u8_array", 32); + new DataView(memory.buffer, selection, 32).setUint32(0, 32, true); + expect(call("ghostty_terminal_select_all", terminal, selection)).toBe(0); + expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0); + const formatOptions = call("ghostty_wasm_alloc_u8_array", 16); + const formatView = new DataView(memory.buffer, formatOptions, 16); + formatView.setUint32(0, 16, true); + formatView.setUint8(8, 1); + formatView.setUint8(9, 1); + const written = call("ghostty_wasm_alloc_usize"); + expect( + call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written), + ).toBe(-3); + const outputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const output = call("ghostty_wasm_alloc_u8_array", outputSize); + expect( + call( + "ghostty_terminal_selection_format_buf", + terminal, + formatOptions, + output, + outputSize, + written, + ), + ).toBe(0); + expect(new TextDecoder().decode(new Uint8Array(memory.buffer, output, outputSize))).toBe( + "a\n\nb", + ); + + call("ghostty_wasm_free_u8_array", output, outputSize); + call("ghostty_wasm_free_usize", written); + call("ghostty_wasm_free_u8_array", formatOptions, 16); + call("ghostty_wasm_free_u8_array", selection, 32); + call("ghostty_wasm_free_u8_array", inputPointer, input.length); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_opaque", terminalSlot); + call("ghostty_wasm_free_u8_array", options, 8); + }); + + it("uses Ghostty for mouse encoding, word selection, and OSC 8 hit testing", async () => { + const result = await WebAssembly.instantiate( + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, + { env: { log: () => {} } }, + ); + const instance = result instanceof WebAssembly.Instance ? result : result.instance; + const memory = instance.exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => + (instance.exports[name] as WasmFunction)(...args); + const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size); + const free = (pointer: number, size: number) => + call("ghostty_wasm_free_u8_array", pointer, size); + + const terminalOptions = alloc(8); + const terminalOptionsView = new DataView(memory.buffer, terminalOptions, 8); + terminalOptionsView.setUint16(0, 80, true); + terminalOptionsView.setUint16(2, 24, true); + const terminalSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_terminal_new", 0, terminalSlot, terminalOptions)).toBe(0); + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); + const input = new TextEncoder().encode( + "\u001b[?1000h\u001b[?1006h\u001b]8;;https://t3.codes/docs\u001b\\linked\u001b]8;;\u001b\\ plain", + ); + const inputPointer = alloc(input.length); + new Uint8Array(memory.buffer, inputPointer, input.length).set(input); + call("ghostty_terminal_vt_write", terminal, inputPointer, input.length); + + const modeFlag = alloc(1); + new Uint8Array(memory.buffer, modeFlag, 1)[0] = 0; + expect(call("ghostty_terminal_get", terminal, 11, modeFlag)).toBe(0); + expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(1); + new Uint8Array(memory.buffer, modeFlag, 1)[0] = 1; + expect(call("ghostty_terminal_mode_get", terminal, 1003, modeFlag)).toBe(0); + expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(0); + const anyEventInput = new TextEncoder().encode("\u001b[?1003h"); + const anyEventPointer = alloc(anyEventInput.length); + new Uint8Array(memory.buffer, anyEventPointer, anyEventInput.length).set(anyEventInput); + call("ghostty_terminal_vt_write", terminal, anyEventPointer, anyEventInput.length); + expect(call("ghostty_terminal_mode_get", terminal, 1003, modeFlag)).toBe(0); + expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(1); + const anyEventReset = new TextEncoder().encode("\u001b[?1003l\u001b[?1000h"); + const anyEventResetPointer = alloc(anyEventReset.length); + new Uint8Array(memory.buffer, anyEventResetPointer, anyEventReset.length).set(anyEventReset); + call("ghostty_terminal_vt_write", terminal, anyEventResetPointer, anyEventReset.length); + expect(call("ghostty_terminal_mode_get", terminal, 1003, modeFlag)).toBe(0); + expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(0); + free(anyEventResetPointer, anyEventReset.length); + free(anyEventPointer, anyEventInput.length); + free(modeFlag, 1); + + const point = alloc(24); + const pointView = new DataView(memory.buffer, point, 24); + pointView.setUint32(0, 1, true); + pointView.setUint16(8, 1, true); + pointView.setUint32(12, 0, true); + const gridRef = alloc(12); + new DataView(memory.buffer, gridRef, 12).setUint32(0, 12, true); + expect(call("ghostty_terminal_grid_ref", terminal, point, gridRef)).toBe(0); + + const written = call("ghostty_wasm_alloc_usize"); + expect(call("ghostty_grid_ref_hyperlink_uri", gridRef, 0, 0, written)).toBe(-3); + const hyperlinkSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const hyperlink = alloc(hyperlinkSize); + expect(call("ghostty_grid_ref_hyperlink_uri", gridRef, hyperlink, hyperlinkSize, written)).toBe( + 0, + ); + expect(new TextDecoder().decode(new Uint8Array(memory.buffer, hyperlink, hyperlinkSize))).toBe( + "https://t3.codes/docs", + ); + + const wordOptions = alloc(24); + const wordOptionsView = new DataView(memory.buffer, wordOptions, 24); + wordOptionsView.setUint32(0, 24, true); + new Uint8Array(memory.buffer, wordOptions + 4, 12).set( + new Uint8Array(memory.buffer, gridRef, 12), + ); + const selection = alloc(32); + new DataView(memory.buffer, selection, 32).setUint32(0, 32, true); + expect(call("ghostty_terminal_select_word", terminal, wordOptions, selection)).toBe(0); + expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0); + const formatOptions = alloc(16); + const formatView = new DataView(memory.buffer, formatOptions, 16); + formatView.setUint32(0, 16, true); + formatView.setUint8(8, 1); + formatView.setUint8(9, 1); + expect( + call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written), + ).toBe(-3); + const selectionSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const selectionText = alloc(selectionSize); + expect( + call( + "ghostty_terminal_selection_format_buf", + terminal, + formatOptions, + selectionText, + selectionSize, + written, + ), + ).toBe(0); + expect( + new TextDecoder().decode(new Uint8Array(memory.buffer, selectionText, selectionSize)), + ).toBe("linked"); + + const lineOptions = alloc(28); + const lineOptionsView = new DataView(memory.buffer, lineOptions, 28); + lineOptionsView.setUint32(0, 28, true); + new Uint8Array(memory.buffer, lineOptions + 4, 12).set( + new Uint8Array(memory.buffer, gridRef, 12), + ); + expect(call("ghostty_terminal_select_line", terminal, lineOptions, selection)).toBe(0); + expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0); + expect( + call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written), + ).toBe(-3); + const lineSelectionSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const lineSelectionText = alloc(lineSelectionSize); + expect( + call( + "ghostty_terminal_selection_format_buf", + terminal, + formatOptions, + lineSelectionText, + lineSelectionSize, + written, + ), + ).toBe(0); + expect( + new TextDecoder().decode(new Uint8Array(memory.buffer, lineSelectionText, lineSelectionSize)), + ).toBe("linked plain"); + + const mouseEncoderSlot = call("ghostty_wasm_alloc_opaque"); + const mouseEventSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_mouse_encoder_new", 0, mouseEncoderSlot)).toBe(0); + expect(call("ghostty_mouse_event_new", 0, mouseEventSlot)).toBe(0); + const view = new DataView(memory.buffer); + const mouseEncoder = view.getUint32(mouseEncoderSlot, true); + const mouseEvent = view.getUint32(mouseEventSlot, true); + call("ghostty_mouse_encoder_setopt_from_terminal", mouseEncoder, terminal); + const mouseSize = alloc(36); + const mouseSizeView = new DataView(memory.buffer, mouseSize, 36); + for (const [offset, value] of [ + [0, 36], + [4, 800], + [8, 480], + [12, 10], + [16, 20], + ] as const) { + mouseSizeView.setUint32(offset, value, true); + } + call("ghostty_mouse_encoder_setopt", mouseEncoder, 2, mouseSize); + call("ghostty_mouse_event_set_action", mouseEvent, 0); + call("ghostty_mouse_event_set_button", mouseEvent, 1); + const mousePosition = new DataView(new ArrayBuffer(8)); + mousePosition.setFloat32(0, 15, true); + mousePosition.setFloat32(4, 25, true); + const mousePositionPointer = alloc(8); + new Uint8Array(memory.buffer, mousePositionPointer, 8).set( + new Uint8Array(mousePosition.buffer), + ); + call("ghostty_mouse_event_set_position", mouseEvent, mousePositionPointer); + const mouseOutput = alloc(128); + expect( + call("ghostty_mouse_encoder_encode", mouseEncoder, mouseEvent, mouseOutput, 128, written), + ).toBe(0); + const mouseOutputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + expect( + new TextDecoder().decode(new Uint8Array(memory.buffer, mouseOutput, mouseOutputSize)), + ).toBe("\u001b[<0;2;2M"); + + call("ghostty_mouse_event_free", mouseEvent); + call("ghostty_mouse_encoder_free", mouseEncoder); + call("ghostty_wasm_free_opaque", mouseEventSlot); + call("ghostty_wasm_free_opaque", mouseEncoderSlot); + free(mousePositionPointer, 8); + free(mouseOutput, 128); + free(mouseSize, 36); + free(lineSelectionText, lineSelectionSize); + free(lineOptions, 28); + free(selectionText, selectionSize); + free(formatOptions, 16); + free(selection, 32); + free(wordOptions, 24); + free(hyperlink, hyperlinkSize); + call("ghostty_wasm_free_usize", written); + free(gridRef, 12); + free(point, 24); + free(inputPointer, input.length); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_opaque", terminalSlot); + free(terminalOptions, 8); + }); + + it("encodes modified printable keys in Kitty keyboard mode", async () => { + const result = await WebAssembly.instantiate( + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, + { env: { log: () => {} } }, + ); + const instance = result instanceof WebAssembly.Instance ? result : result.instance; + const memory = instance.exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => + (instance.exports[name] as WasmFunction)(...args); + const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size); + const free = (pointer: number, size: number) => + call("ghostty_wasm_free_u8_array", pointer, size); + + const terminalOptions = alloc(8); + const terminalOptionsView = new DataView(memory.buffer, terminalOptions, 8); + terminalOptionsView.setUint16(0, 80, true); + terminalOptionsView.setUint16(2, 24, true); + const terminalSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_terminal_new", 0, terminalSlot, terminalOptions)).toBe(0); + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); + const kittyMode = new TextEncoder().encode("\u001b[>1u"); + const kittyModePointer = alloc(kittyMode.length); + new Uint8Array(memory.buffer, kittyModePointer, kittyMode.length).set(kittyMode); + call("ghostty_terminal_vt_write", terminal, kittyModePointer, kittyMode.length); + + const encoderSlot = call("ghostty_wasm_alloc_opaque"); + const eventSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_key_encoder_new", 0, encoderSlot)).toBe(0); + expect(call("ghostty_key_event_new", 0, eventSlot)).toBe(0); + const view = new DataView(memory.buffer); + const keyEncoder = view.getUint32(encoderSlot, true); + const keyEvent = view.getUint32(eventSlot, true); + call("ghostty_key_encoder_setopt_from_terminal", keyEncoder, terminal); + call("ghostty_key_event_set_action", keyEvent, 1); + call("ghostty_key_event_set_key", keyEvent, ghosttyKeyForCode("KeyC")); + call("ghostty_key_event_set_mods", keyEvent, 1 << 1); + call("ghostty_key_event_set_consumed_mods", keyEvent, 0); + call("ghostty_key_event_set_composing", keyEvent, 0); + call("ghostty_key_event_set_unshifted_codepoint", keyEvent, "c".codePointAt(0)!); + const text = new TextEncoder().encode("c"); + const textPointer = alloc(text.length); + new Uint8Array(memory.buffer, textPointer, text.length).set(text); + call("ghostty_key_event_set_utf8", keyEvent, textPointer, text.length); + + const written = call("ghostty_wasm_alloc_usize"); + expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(-3); + const outputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const output = alloc(outputSize); + expect( + call("ghostty_key_encoder_encode", keyEncoder, keyEvent, output, outputSize, written), + ).toBe(0); + const outputLength = new DataView(memory.buffer, written, 4).getUint32(0, true); + expect(new TextDecoder().decode(new Uint8Array(memory.buffer, output, outputLength))).toBe( + "\u001b[99;5u", + ); + + const remappedText = new TextEncoder().encode("j"); + const remappedTextPointer = alloc(remappedText.length); + new Uint8Array(memory.buffer, remappedTextPointer, remappedText.length).set(remappedText); + call("ghostty_key_event_set_unshifted_codepoint", keyEvent, "j".codePointAt(0)!); + call("ghostty_key_event_set_utf8", keyEvent, remappedTextPointer, remappedText.length); + expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(-3); + const remappedOutputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const remappedOutput = alloc(remappedOutputSize); + expect( + call( + "ghostty_key_encoder_encode", + keyEncoder, + keyEvent, + remappedOutput, + remappedOutputSize, + written, + ), + ).toBe(0); + const remappedOutputLength = new DataView(memory.buffer, written, 4).getUint32(0, true); + expect( + new TextDecoder().decode(new Uint8Array(memory.buffer, remappedOutput, remappedOutputLength)), + ).toBe("\u001b[106;5u"); + + // Without the Kitty report-event-types flag a release encodes nothing, so + // the surface's keyup handler stays silent for legacy sessions. + call("ghostty_key_event_set_action", keyEvent, 0); + expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(0); + expect(new DataView(memory.buffer, written, 4).getUint32(0, true)).toBe(0); + + // With report-event-types enabled the same release encodes an event-typed code. + const reportEvents = new TextEncoder().encode("\u001b[>3u"); + const reportEventsPointer = alloc(reportEvents.length); + new Uint8Array(memory.buffer, reportEventsPointer, reportEvents.length).set(reportEvents); + call("ghostty_terminal_vt_write", terminal, reportEventsPointer, reportEvents.length); + call("ghostty_key_encoder_setopt_from_terminal", keyEncoder, terminal); + expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(-3); + const releaseSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const releaseOutput = alloc(releaseSize); + expect( + call("ghostty_key_encoder_encode", keyEncoder, keyEvent, releaseOutput, releaseSize, written), + ).toBe(0); + const releaseLength = new DataView(memory.buffer, written, 4).getUint32(0, true); + expect( + new TextDecoder().decode(new Uint8Array(memory.buffer, releaseOutput, releaseLength)), + ).toBe("\u001b[106;5:3u"); + free(releaseOutput, releaseSize); + free(reportEventsPointer, reportEvents.length); + + free(remappedOutput, remappedOutputSize); + free(remappedTextPointer, remappedText.length); + free(output, outputSize); + call("ghostty_wasm_free_usize", written); + free(textPointer, text.length); + call("ghostty_key_event_free", keyEvent); + call("ghostty_key_encoder_free", keyEncoder); + call("ghostty_wasm_free_opaque", eventSlot); + call("ghostty_wasm_free_opaque", encoderSlot); + free(kittyModePointer, kittyMode.length); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_opaque", terminalSlot); + free(terminalOptions, 8); + }); +}); diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts new file mode 100644 index 00000000000..1cb9502649b --- /dev/null +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { GhosttyCell, GhosttyRow } from "./core"; +import { + DEFAULT_TERMINAL_FONT_FAMILY, + DEFAULT_TERMINAL_FONT_SIZE, + advanceTerminalSelectionClickSequence, + ghosttyMouseButton, + isTerminalAltGraphText, + isTerminalCompositionCommitInput, + isTerminalCopyShortcut, + isTerminalLinkPointerGesture, + isTerminalPasteShortcut, + shouldReportTerminalMouse, + terminalScrollbarGeometry, + terminalScrollbarOffsetAtPointer, + terminalLinkAtColumn, + terminalLinkAtPosition, + terminalContentOriginY, + terminalFontFamily, + terminalFontSize, + terminalWheelArrowData, + terminalWheelDeltaRows, +} from "./surface"; + +const cell = (text: string): GhosttyCell => ({ + text, + wide: 0, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + bold: false, + italic: false, + invisible: false, + strikethrough: false, + overline: false, + underline: false, + selected: false, +}); + +describe("isTerminalAltGraphText", () => { + it("defers printable AltGr output to the textarea input event", () => { + expect( + isTerminalAltGraphText({ + key: "@", + getModifierState: (modifier) => modifier === "AltGraph", + }), + ).toBe(true); + expect( + isTerminalAltGraphText({ + key: "ArrowRight", + getModifierState: (modifier) => modifier === "AltGraph", + }), + ).toBe(false); + }); +}); + +describe("terminalLinkAtColumn", () => { + it("maps terminal cells to UTF-16 offsets after a wide emoji", () => { + const cells = [ + cell("🙂"), + cell(""), + ...Array.from("https://t3.codes", (character) => cell(character)), + ]; + const row: GhosttyRow = { + cells, + text: cells + .map((value) => value.text || " ") + .join("") + .trimEnd(), + isWrapContinuation: false, + wrapsToNext: false, + }; + + expect(terminalLinkAtColumn(row, 2)).toBe("https://t3.codes"); + expect(terminalLinkAtColumn(row, cells.length - 1)).toBe("https://t3.codes"); + expect(terminalLinkAtColumn(row, 0)).toBeNull(); + }); + + it("uses shared path matching and reconstructs soft-wrapped links", () => { + const row = (text: string, isWrapContinuation: boolean, wrapsToNext = false): GhosttyRow => ({ + cells: Array.from(text.padEnd(16), (character) => cell(character)), + text: text.trimEnd(), + isWrapContinuation, + wrapsToNext, + }); + const rows = [ + row("https://example.", false), + row("com/reference", true), + row("~/project/file", false), + row("C:\\repo\\file.ts", false), + ]; + + expect(terminalLinkAtPosition(rows, 0, 8)).toBe("https://example.com/reference"); + expect(terminalLinkAtPosition(rows, 1, 4)).toBe("https://example.com/reference"); + expect(terminalLinkAtPosition(rows, 2, 2)).toBe("~/project/file"); + expect(terminalLinkAtPosition(rows, 3, 4)).toBe("C:\\repo\\file.ts"); + }); + + it("refuses links truncated at the viewport edges instead of mis-resolving", () => { + const row = (text: string, isWrapContinuation: boolean, wrapsToNext = false): GhosttyRow => ({ + cells: Array.from(text.padEnd(16), (character) => cell(character)), + text: text.trimEnd(), + isWrapContinuation, + wrapsToNext, + }); + // The head of the wrapped line scrolled above the viewport. + const headCut = [row("ple.com/missing", true), row("head", true)]; + expect(terminalLinkAtPosition(headCut, 0, 4)).toBeNull(); + // The bottom row soft-wraps on below the viewport. + const tailCut = [row("https://t3.codes", false, true)]; + expect(terminalLinkAtPosition(tailCut, 0, 8)).toBeNull(); + // A partial bottom row is provably complete and still resolves. + const complete = [row("https://t3.codes", false), row("", false)]; + expect(terminalLinkAtPosition(complete, 0, 8)).toBe("https://t3.codes"); + // A wide grapheme earlier in the row must not break truncation detection: + // the soft-wrap flag decides, not string-length-versus-cell-count. + const wideFull: GhosttyRow = { + cells: [ + { ...cell("🙂"), wide: 1 }, + { ...cell(""), wide: 2 }, + ...Array.from("https://t3.code", (character) => cell(character)), + ], + text: "🙂 https://t3.code", + isWrapContinuation: false, + wrapsToNext: true, + }; + expect(terminalLinkAtPosition([wideFull], 0, 8)).toBeNull(); + // Unwritten trailing cells prove the bottom row is complete. + const unwrittenTail: GhosttyRow = { + cells: [ + ...Array.from("https://t3.codes", (character) => cell(character)), + cell(""), + cell(""), + ], + text: "https://t3.codes", + isWrapContinuation: false, + wrapsToNext: false, + }; + expect(terminalLinkAtPosition([unwrittenTail], 0, 8)).toBe("https://t3.codes"); + }); +}); + +describe("isTerminalCopyShortcut", () => { + const event = (overrides: Partial[0]> = {}) => ({ + ctrlKey: false, + key: "c", + metaKey: false, + shiftKey: false, + ...overrides, + }); + + it("keeps Ctrl+C available for SIGINT on macOS", () => { + expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "MacIntel")).toBe(false); + expect(isTerminalCopyShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); + }); + + it("uses the conventional Ctrl+Shift+C shortcut elsewhere", () => { + expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(false); + expect(isTerminalCopyShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe( + true, + ); + }); + + it("uses the produced character instead of the physical key position", () => { + expect(isTerminalCopyShortcut(event({ key: "C", metaKey: true }), "MacIntel")).toBe(true); + expect(isTerminalCopyShortcut(event({ key: "j", metaKey: true }), "MacIntel")).toBe(false); + }); +}); + +describe("isTerminalPasteShortcut", () => { + const event = (overrides: Partial[0]> = {}) => ({ + ctrlKey: false, + key: "v", + metaKey: false, + shiftKey: false, + ...overrides, + }); + + it("uses Cmd+V on macOS", () => { + expect(isTerminalPasteShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); + expect(isTerminalPasteShortcut(event({ ctrlKey: true }), "MacIntel")).toBe(false); + }); + + it("preserves Ctrl+V and uses Ctrl+Shift+V elsewhere", () => { + expect(isTerminalPasteShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(false); + expect(isTerminalPasteShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe( + true, + ); + }); +}); + +describe("isTerminalCompositionCommitInput", () => { + it("identifies browser composition follow-up input", () => { + expect(isTerminalCompositionCommitInput({ inputType: "" })).toBe(true); + expect(isTerminalCompositionCommitInput({ inputType: "insertCompositionText" })).toBe(true); + expect(isTerminalCompositionCommitInput({ inputType: "insertFromComposition" })).toBe(true); + }); + + it("keeps a fast repeated input as legitimate text", () => { + expect(isTerminalCompositionCommitInput({ inputType: "insertText" })).toBe(false); + }); +}); + +describe("application mouse reporting", () => { + const event = (overrides: Partial[1]> = {}) => ({ + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...overrides, + }); + + it("keeps Shift and link activation modifiers available to the browser", () => { + expect(shouldReportTerminalMouse(true, event())).toBe(true); + expect(shouldReportTerminalMouse(true, event({ shiftKey: true }))).toBe(false); + expect(shouldReportTerminalMouse(true, event({ ctrlKey: true }))).toBe(false); + expect(shouldReportTerminalMouse(true, event({ metaKey: true }))).toBe(false); + expect(shouldReportTerminalMouse(false, event())).toBe(false); + }); + + it("maps browser buttons to Ghostty's button enum", () => { + expect([0, 1, 2, 3, 4, 5].map(ghosttyMouseButton)).toEqual([1, 3, 2, 4, 5, null]); + }); +}); + +describe("terminal font resolution", () => { + it("keeps the glyph fallbacks behind a custom text face", () => { + expect(terminalFontFamily()).toBe(DEFAULT_TERMINAL_FONT_FAMILY); + expect(terminalFontFamily(" ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY); + const custom = terminalFontFamily('"Fira Code"'); + expect(custom.startsWith('"Fira Code", ')).toBe(true); + expect(custom).toContain('"Symbols Nerd Font Mono"'); + expect(custom.endsWith("monospace")).toBe(true); + }); + + it("clamps requested font sizes to the supported range", () => { + expect(terminalFontSize()).toBe(DEFAULT_TERMINAL_FONT_SIZE); + expect(terminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE); + expect(terminalFontSize(13.4)).toBe(13); + expect(terminalFontSize(2)).toBe(6); + expect(terminalFontSize(90)).toBe(32); + }); +}); + +describe("terminalContentOriginY", () => { + it("stays top-anchored like a fresh terminal until scrollback exists", () => { + expect(terminalContentOriginY(100, 4, 5, 16, false)).toBe(4); + }); + + it("pins the grid to the bottom by moving the sub-row slack above row 0", () => { + // 100px mount, 4px padding, 5 rows of 16px: 92 - 80 = 12px slack on top. + expect(terminalContentOriginY(100, 4, 5, 16, true)).toBe(16); + // Exact fit keeps the origin at the padding. + expect(terminalContentOriginY(88, 4, 5, 16, true)).toBe(4); + // A mount smaller than the grid never pushes the origin above the padding. + expect(terminalContentOriginY(80, 4, 5, 16, true)).toBe(4); + }); + + it("keeps the prompt stationary while a drag crosses row boundaries", () => { + // Growing the mount pixel by pixel: the bottom edge (origin + rows*cell) + // tracks the mount bottom exactly until a new row fits. + for (let height = 88; height < 104; height += 1) { + const rows = Math.max(1, Math.floor((height - 8) / 16)); + const origin = terminalContentOriginY(height, 4, rows, 16, true); + expect(origin + rows * 16).toBe(height - 4); + } + }); +}); + +describe("terminalWheelDeltaRows", () => { + it("converts line-mode deltas into whole rows", () => { + const result = terminalWheelDeltaRows({ deltaY: 3, deltaMode: 1 }, 16, 24, 0); + expect(result.rows).toBe(3); + expect(result.remainder).toBe(0); + }); + + it("accumulates fractional pixel deltas across events", () => { + let remainder = 0; + let scrolled = 0; + for (let index = 0; index < 4; index += 1) { + const result = terminalWheelDeltaRows({ deltaY: 5, deltaMode: 0 }, 16, 24, remainder); + remainder = result.remainder; + scrolled += result.rows; + } + expect(scrolled).toBe(1); + expect(remainder).toBeCloseTo(4 / 16); + }); + + it("keeps direction for negative page-mode deltas", () => { + const result = terminalWheelDeltaRows({ deltaY: -1, deltaMode: 2 }, 16, 24, 0); + expect(result.rows).toBe(-24); + expect(result.remainder).toBe(0); + }); +}); + +describe("terminalWheelArrowData", () => { + it("emits one arrow per row honoring application cursor keys", () => { + expect(terminalWheelArrowData(-2, false)).toBe("\u001b[A\u001b[A"); + expect(terminalWheelArrowData(3, false)).toBe("\u001b[B\u001b[B\u001b[B"); + expect(terminalWheelArrowData(-1, true)).toBe("\u001bOA"); + expect(terminalWheelArrowData(0, true)).toBe(""); + }); +}); + +describe("isTerminalLinkPointerGesture", () => { + it("uses Command on macOS and Control elsewhere", () => { + expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "MacIntel")).toBe(true); + expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "MacIntel")).toBe(false); + expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "Linux x86_64")).toBe( + true, + ); + expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "Linux x86_64")).toBe( + false, + ); + }); +}); + +describe("advanceTerminalSelectionClickSequence", () => { + it("recognizes stationary double and triple pointer presses without PointerEvent.detail", () => { + const first = advanceTerminalSelectionClickSequence(null, { + clientX: 20, + clientY: 30, + timeStamp: 1_000, + }); + const second = advanceTerminalSelectionClickSequence(first, { + clientX: 22, + clientY: 29, + timeStamp: 1_200, + }); + const third = advanceTerminalSelectionClickSequence(second, { + clientX: 21, + clientY: 31, + timeStamp: 1_400, + }); + + expect([first.count, second.count, third.count]).toEqual([1, 2, 3]); + }); + + it("starts over after movement, delay, or a completed triple click", () => { + const previous = { count: 3, time: 1_000, x: 20, y: 30 }; + expect( + advanceTerminalSelectionClickSequence(previous, { + clientX: 20, + clientY: 30, + timeStamp: 1_100, + }).count, + ).toBe(1); + expect( + advanceTerminalSelectionClickSequence(previous, { + clientX: 30, + clientY: 30, + timeStamp: 1_100, + }).count, + ).toBe(1); + expect( + advanceTerminalSelectionClickSequence(previous, { + clientX: 20, + clientY: 30, + timeStamp: 1_501, + }).count, + ).toBe(1); + }); +}); + +describe("terminal scrollbar", () => { + it("maps Ghostty's viewport state to a proportional thumb", () => { + expect(terminalScrollbarGeometry({ total: 100, offset: 40, len: 20 }, 200)).toEqual({ + thumbHeight: 40, + thumbTop: 80, + maxOffset: 80, + }); + expect(terminalScrollbarGeometry({ total: 100, offset: 0, len: 100 }, 200)).toBeNull(); + }); + + it("keeps the thumb usable for large scrollback and maps dragging back to rows", () => { + const state = { total: 10_000, offset: 0, len: 20 }; + expect(terminalScrollbarGeometry(state, 200)).toEqual({ + thumbHeight: 18, + thumbTop: 0, + maxOffset: 9_980, + }); + expect(terminalScrollbarOffsetAtPointer(state, 200, 191, 9)).toBe(9_980); + }); +}); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts new file mode 100644 index 00000000000..86c1ad5329c --- /dev/null +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -0,0 +1,1393 @@ +import { isMacPlatform } from "../../lib/utils"; +import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links"; +import { + GhosttyTerminalCore, + type GhosttyScrollbar, + type GhosttySnapshot, + type GhosttyTheme, +} from "./core"; +import { + measureGhosttyCell, + renderGhosttySnapshot, + terminalGridSize, + type GhosttyCellMetrics, +} from "./renderer"; +import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; + +export const DEFAULT_TERMINAL_FONT_SIZE = 12; +const MIN_TERMINAL_FONT_SIZE = 6; +const MAX_TERMINAL_FONT_SIZE = 32; +// The glyph fallbacks only supply symbols the text faces are missing (powerline +// separators, devicons, and other private-use prompt symbols), so shells +// configured for a locally installed Nerd Font keep their prompt glyphs no +// matter which text face is active. +const TERMINAL_GLYPH_FALLBACKS = + '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono Nerd Font", ' + + '"JetBrainsMono NF", "FiraCode Nerd Font", "Hack Nerd Font", "MesloLGS NF", ' + + '"CaskaydiaCove Nerd Font", "PowerlineSymbols", monospace'; +// SF Mono where the platform has it (macOS), otherwise the bundled JetBrains +// Mono webfont, so the default rendering is identical everywhere else. +export const DEFAULT_TERMINAL_FONT_FAMILY = + '"SF Mono", "SFMono-Regular", "JetBrains Mono", ' + TERMINAL_GLYPH_FALLBACKS; +const CONTENT_PADDING = 4; +const MIN_SCROLLBAR_THUMB_HEIGHT = 18; + +/** Requested terminal font; omitted fields fall back to the defaults. */ +export interface GhosttyTerminalFont { + readonly family?: string; + readonly size?: number; +} + +let symbolsFontLoad: Promise | null = null; + +/** + * Register the bundled symbols-only Nerd Font once per page. It loads lazily + * with the first terminal, and because it carries no regular text glyphs it + * composes with any text face without changing metrics — prompt symbols and + * devicons render even on machines without a locally installed Nerd Font. + */ +function ensureTerminalSymbolsFont(): Promise { + if (symbolsFontLoad !== null) return symbolsFontLoad; + symbolsFontLoad = (async () => { + try { + const face = new FontFace("Symbols Nerd Font Mono", `url(${symbolsFontUrl})`); + document.fonts.add(await face.load()); + } catch { + // Locally installed fallback faces still apply. + } + })(); + return symbolsFontLoad; +} + +export function terminalFontFamily(family?: string): string { + const custom = family?.trim(); + if (!custom) return DEFAULT_TERMINAL_FONT_FAMILY; + // A custom face keeps the glyph fallbacks so prompt symbols stay covered. + return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; +} + +export function terminalFontSize(size?: number): number { + if (size === undefined || !Number.isFinite(size)) return DEFAULT_TERMINAL_FONT_SIZE; + return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size))); +} + +/** + * Vertical origin of the grid inside the mount. While content is shorter than + * the viewport the grid sits at the top like a fresh terminal. Once scrollback + * exists the prompt lives on the bottom row, so the grid anchors to the bottom + * edge instead: the sub-row remainder moves above row 0 and resizing within a + * row boundary keeps the prompt pinned instead of snapping up and down. + */ +export function terminalContentOriginY( + mountHeight: number, + padding: number, + rows: number, + cellHeight: number, + anchorBottom: boolean, +): number { + if (!anchorBottom) return padding; + const slack = mountHeight - padding * 2 - rows * cellHeight; + return padding + Math.max(0, slack); +} + +export interface TerminalScrollbarGeometry { + readonly thumbHeight: number; + readonly thumbTop: number; + readonly maxOffset: number; +} + +export function terminalScrollbarGeometry( + state: GhosttyScrollbar, + trackHeight: number, +): TerminalScrollbarGeometry | null { + const total = Math.max(0, state.total); + const len = Math.max(0, Math.min(state.len, total)); + const maxOffset = Math.max(0, total - len); + if (trackHeight <= 0 || len <= 0 || maxOffset === 0) return null; + const thumbHeight = Math.min( + trackHeight, + Math.max(MIN_SCROLLBAR_THUMB_HEIGHT, (trackHeight * len) / total), + ); + const travel = Math.max(0, trackHeight - thumbHeight); + const offset = Math.max(0, Math.min(state.offset, maxOffset)); + return { + thumbHeight, + thumbTop: travel * (offset / maxOffset), + maxOffset, + }; +} + +export function terminalScrollbarOffsetAtPointer( + state: GhosttyScrollbar, + trackHeight: number, + pointerY: number, + pointerOffset: number, +): number { + const geometry = terminalScrollbarGeometry(state, trackHeight); + if (geometry === null) return 0; + const travel = Math.max(0, trackHeight - geometry.thumbHeight); + if (travel === 0) return 0; + const thumbTop = Math.max(0, Math.min(pointerY - pointerOffset, travel)); + return Math.round((thumbTop / travel) * geometry.maxOffset); +} + +function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { + const text = row.cells.map((cell) => cell.text || " ").join(""); + return trimRight ? text.trimEnd() : text; +} + +function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: number): number { + let offset = 0; + for (let cellIndex = 0; cellIndex < column; cellIndex += 1) { + offset += row.cells[cellIndex]?.text.length || 1; + } + return offset; +} + +export function terminalLinkAtPosition( + rows: GhosttySnapshot["rowData"], + rowIndex: number, + column: number, +): string | null { + const wrappedLine = collectWrappedTerminalLinkLine(rowIndex + 1, (index) => { + const row = rows[index]; + if (!row) return null; + return { + isWrapped: row.isWrapContinuation, + translateToString: (trimRight = false) => terminalRowText(row, trimRight), + }; + }); + if (!wrappedLine) return null; + // Only viewport rows are available: a wrapped line whose head scrolled above + // the viewport would resolve a truncated match into a wrong link. + const firstSegment = wrappedLine.segments[0]; + if (firstSegment && rows[firstSegment.bufferLineNumber - 1]?.isWrapContinuation) { + return null; + } + const segment = wrappedLine.segments.find((value) => value.bufferLineNumber === rowIndex + 1); + const row = rows[rowIndex]; + if (!segment || !row) return null; + const lastSegment = wrappedLine.segments.at(-1); + const lastRow = lastSegment ? rows[lastSegment.bufferLineNumber - 1] : undefined; + // Ghostty's soft-wrap flag is authoritative: when the last collected row + // still wraps onward, its continuation is outside the viewport. + const continuesBelowViewport = lastRow !== undefined && lastRow.wrapsToNext; + const offset = segment.startIndex + terminalColumnOffset(row, column); + for (const match of extractTerminalLinks(wrappedLine.text)) { + if (offset >= match.start && offset < match.end) { + // A truncated tail must not activate as a complete link. + if (match.end === wrappedLine.text.length && continuesBelowViewport) return null; + return match.text; + } + } + return null; +} + +export function terminalLinkAtColumn(row: GhosttySnapshot["rowData"][number], column: number) { + return terminalLinkAtPosition([row], 0, column); +} + +export function isTerminalCopyShortcut( + event: Pick, + platform = navigator.platform, +) { + if (event.key.toLowerCase() !== "c") return false; + return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; +} + +export function isTerminalPasteShortcut( + event: Pick, + platform = navigator.platform, +) { + if (event.key.toLowerCase() !== "v") return false; + return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; +} + +export function isTerminalCompositionCommitInput(event: Pick): boolean { + return ( + event.inputType === "" || + event.inputType === "insertCompositionText" || + event.inputType === "insertFromComposition" + ); +} + +export function isTerminalAltGraphText( + event: Pick, +): boolean { + return event.getModifierState("AltGraph") && [...event.key].length === 1; +} + +export function shouldReportTerminalMouse( + tracking: boolean, + event: Pick, +): boolean { + return tracking && !event.shiftKey && !event.ctrlKey && !event.metaKey; +} + +export function terminalWheelDeltaRows( + event: Pick, + cellHeight: number, + viewportRows: number, + remainder: number, +): { readonly rows: number; readonly remainder: number } { + // deltaMode: 0 pixels, 1 lines, 2 pages. + const pixels = + event.deltaMode === 1 + ? event.deltaY * cellHeight + : event.deltaMode === 2 + ? event.deltaY * viewportRows * cellHeight + : event.deltaY; + const total = remainder + pixels / cellHeight; + const rows = Math.trunc(total); + return { rows, remainder: total - rows }; +} + +export function terminalWheelArrowData(rows: number, applicationCursorKeys: boolean): string { + if (rows === 0) return ""; + const sequence = + rows < 0 + ? applicationCursorKeys + ? "\u001bOA" + : "\u001b[A" + : applicationCursorKeys + ? "\u001bOB" + : "\u001b[B"; + return sequence.repeat(Math.abs(rows)); +} + +export function isTerminalLinkPointerGesture( + event: Pick, + platform = navigator.platform, +): boolean { + return isMacPlatform(platform) + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; +} + +export function ghosttyMouseButton(button: number): number | null { + switch (button) { + case 0: + return 1; + case 1: + return 3; + case 2: + return 2; + case 3: + return 4; + case 4: + return 5; + default: + return null; + } +} + +export interface TerminalSelectionClickSequence { + readonly count: number; + readonly time: number; + readonly x: number; + readonly y: number; +} + +export function advanceTerminalSelectionClickSequence( + previous: TerminalSelectionClickSequence | null, + event: Pick, +): TerminalSelectionClickSequence { + const repeats = + previous !== null && + event.timeStamp - previous.time <= 500 && + Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= 4; + return { + count: repeats ? (previous.count >= 3 ? 1 : previous.count + 1) : 1, + time: event.timeStamp, + x: event.clientX, + y: event.clientY, + }; +} + +export interface GhosttySelectionPosition { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; +} + +export interface GhosttyTerminalSurfaceOptions { + readonly theme: GhosttyTheme; + readonly font?: GhosttyTerminalFont; + readonly onData: (data: string) => void; + readonly onResize: (cols: number, rows: number) => void; + readonly onSelectionChange: () => void; + readonly onCopy: (text: string) => void; + readonly beforeKey: (event: KeyboardEvent) => boolean; + readonly onLinkActivate: (text: string, event: MouseEvent) => void; +} + +export class GhosttyTerminalSurface { + readonly canvas: HTMLCanvasElement; + readonly input: HTMLTextAreaElement; + readonly scrollbar: HTMLDivElement; + cols = 1; + rows = 1; + + private readonly mount: HTMLElement; + private readonly context: CanvasRenderingContext2D; + private readonly core: GhosttyTerminalCore; + private readonly options: GhosttyTerminalSurfaceOptions; + private metrics: GhosttyCellMetrics; + private fontFamily: string; + private fontSize: number; + private fontEpoch = 0; + private readonly resizeObserver: ResizeObserver; + private readonly scrollbarThumb: HTMLDivElement; + private snapshot: GhosttySnapshot | null = null; + private frame = 0; + private cursorTimer: number | null = null; + private compositionInputToSuppress: string | null = null; + private compositionSuppressionTimer: number | null = null; + private cursorOn = true; + private renderedCursorY: number | null = null; + private forceFullRender = true; + private scrollbarDirty = true; + private scrollbarState: GhosttyScrollbar | null = null; + private scrollbarPointerId: number | null = null; + private scrollbarPointerOffset = 0; + private disposed = false; + private resizeNotifyTimer: number | null = null; + private originY = CONTENT_PADDING; + private mountHeight = 0; + private selectionEnd: { x: number; y: number } | null = null; + private selectionAnchorScreen: { x: number; y: number } | null = null; + private selectionEndScreen: { x: number; y: number } | null = null; + private selectionMode: "cell" | "word" | "line" = "cell"; + // Word/line selection base in screen coordinates so streaming output cannot + // shift the origin of a drag selection. + private selectionBase: { + start: { x: number; y: number }; + end: { x: number; y: number }; + } | null = null; + private selectionScrollTimer: number | null = null; + private selectionScrollDelta = 0; + private selectionPointer: { x: number; y: number } | null = null; + private mouseReportingPointerId: number | null = null; + private mouseReportingButton: number | null = null; + private linkActivationPointerId: number | null = null; + private selectionClickSequence: TerminalSelectionClickSequence | null = null; + private selectionMoved = false; + private composing = false; + private focused = false; + private resizeNotified = false; + private canvasConfigured = false; + private theme: GhosttyTheme; + private readonly suppressedKeyCodes = new Set(); + private pasteShortcutToken = 0; + private wheelRemainder = 0; + private dprMedia: MediaQueryList | null = null; + private inputLeft = -1; + private inputTop = -1; + + private constructor( + mount: HTMLElement, + canvas: HTMLCanvasElement, + input: HTMLTextAreaElement, + scrollbar: HTMLDivElement, + scrollbarThumb: HTMLDivElement, + context: CanvasRenderingContext2D, + core: GhosttyTerminalCore, + metrics: GhosttyCellMetrics, + options: GhosttyTerminalSurfaceOptions, + ) { + this.mount = mount; + this.canvas = canvas; + this.input = input; + this.scrollbar = scrollbar; + this.scrollbarThumb = scrollbarThumb; + this.context = context; + this.core = core; + this.metrics = metrics; + this.options = options; + this.theme = options.theme; + this.fontFamily = terminalFontFamily(options.font?.family); + this.fontSize = terminalFontSize(options.font?.size); + this.resizeObserver = new ResizeObserver(() => this.fit()); + this.installEvents(); + this.watchDevicePixelRatio(); + document.fonts.addEventListener("loadingdone", this.onFontsLoaded); + this.resizeObserver.observe(mount); + } + + static async create( + mount: HTMLElement, + options: GhosttyTerminalSurfaceOptions, + ): Promise { + const canvas = document.createElement("canvas"); + canvas.className = "t3-ghostty-canvas"; + canvas.style.cssText = "display:block;width:100%;height:100%;"; + canvas.setAttribute("aria-hidden", "true"); + + const input = document.createElement("textarea"); + input.className = "t3-ghostty-input"; + input.setAttribute("aria-label", "Terminal input"); + input.autocapitalize = "off"; + input.autocomplete = "off"; + input.spellcheck = false; + input.style.cssText = + "position:absolute;left:4px;top:4px;width:1px;height:1px;opacity:0;padding:0;border:0;resize:none;pointer-events:none;"; + + const scrollbar = document.createElement("div"); + scrollbar.className = "t3-ghostty-scrollbar"; + scrollbar.setAttribute("role", "scrollbar"); + scrollbar.setAttribute("aria-label", "Terminal scrollback"); + scrollbar.setAttribute("aria-orientation", "vertical"); + scrollbar.tabIndex = 0; + scrollbar.hidden = true; + const scrollbarThumb = document.createElement("div"); + scrollbarThumb.className = "t3-ghostty-scrollbar-thumb"; + scrollbar.append(scrollbarThumb); + mount.replaceChildren(canvas, input, scrollbar); + + const context = canvas.getContext("2d", { alpha: false }); + if (!context) throw new Error("Canvas 2D is unavailable"); + const fontFamily = terminalFontFamily(options.font?.family); + const fontSize = terminalFontSize(options.font?.size); + try { + // Cell metrics must come from the faces that will render; measuring before + // the bundled webfonts load would size the grid from a fallback font. + await ensureTerminalSymbolsFont(); + await document.fonts.load(`${fontSize}px ${fontFamily}`); + } catch { + // Metrics fall back to whichever faces are already available. + } + const metrics = measureGhosttyCell(context, fontSize, fontFamily); + const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING); + const core = await GhosttyTerminalCore.create( + grid.cols, + grid.rows, + metrics.width, + metrics.height, + options.theme, + options.onData, + ); + const surface = new GhosttyTerminalSurface( + mount, + canvas, + input, + scrollbar, + scrollbarThumb, + context, + core, + metrics, + options, + ); + surface.fit(); + surface.requestRender(); + return surface; + } + + write(data: string): void { + if (this.disposed) return; + this.core.write(data); + // Restart the blink cycle from the visible phase so the cursor never sits + // invisible through a stream of output or a burst of typing echo. + this.cursorOn = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + resetAndWrite(data: string): void { + if (this.disposed) return; + this.core.resetAndWrite(data); + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + setTheme(theme: GhosttyTheme): void { + if (this.disposed) return; + this.theme = theme; + this.core.setTheme(theme); + this.forceFullRender = true; + this.requestRender(); + } + + async setFont(font: GhosttyTerminalFont): Promise { + if (this.disposed) return; + const fontFamily = terminalFontFamily(font.family); + const fontSize = terminalFontSize(font.size); + // The fields only change together with their metrics after the load, and + // the epoch lets the newest overlapping call win regardless of load order. + const epoch = ++this.fontEpoch; + try { + await document.fonts.load(`${fontSize}px ${fontFamily}`); + } catch { + // Metrics fall back to whichever faces are already available. + } + if (this.disposed || epoch !== this.fontEpoch) return; + this.fontFamily = fontFamily; + this.fontSize = fontSize; + this.applyFontMetrics(); + } + + private applyFontMetrics(): void { + this.metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); + this.core.resize(this.cols, this.rows, this.metrics.width, this.metrics.height); + // Cached IME textarea coordinates are stale in the new cell geometry. + this.inputLeft = -1; + this.inputTop = -1; + this.forceFullRender = true; + this.scrollbarDirty = true; + this.fit(); + this.requestRender(); + } + + private readonly onFontsLoaded = () => { + if (this.disposed) return; + // A face that finished loading after the initial measurement changes glyph + // advances; re-measure and refit so the grid matches what actually renders. + const metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); + if ( + metrics.width === this.metrics.width && + metrics.height === this.metrics.height && + metrics.baseline === this.metrics.baseline + ) { + return; + } + this.applyFontMetrics(); + }; + + fit(): boolean { + if (this.disposed) return false; + const width = this.mount.clientWidth; + const height = this.mount.clientHeight; + if (width <= 0 || height <= 0) return false; + const ratio = window.devicePixelRatio || 1; + const pixelWidth = Math.max(1, Math.round(width * ratio)); + const pixelHeight = Math.max(1, Math.round(height * ratio)); + let shouldRender = false; + // The DPR transform must be installed even when the target size happens to + // equal the canvas default 300x150 backing store, so the first fit always + // schedules a canvas configuration. + if ( + this.canvas.width !== pixelWidth || + this.canvas.height !== pixelHeight || + !this.canvasConfigured + ) { + this.canvas.width = pixelWidth; + this.canvas.height = pixelHeight; + this.context.setTransform(ratio, 0, 0, ratio, 0, 0); + this.canvasConfigured = true; + this.forceFullRender = true; + this.scrollbarDirty = true; + shouldRender = true; + } + const grid = terminalGridSize(width, height, this.metrics, CONTENT_PADDING); + this.mountHeight = height; + // onResize is the only PTY resize channel, so the first successful fit must + // notify even when the measured grid equals the 1x1 construction sentinel. + if (grid.cols !== this.cols || grid.rows !== this.rows || !this.resizeNotified) { + this.cols = grid.cols; + this.rows = grid.rows; + this.core.resize(grid.cols, grid.rows, this.metrics.width, this.metrics.height); + this.notifyResize(); + this.forceFullRender = true; + this.scrollbarDirty = true; + shouldRender = true; + } + // Rendering synchronously keeps the repaint inside the same frame as the + // layout change: ResizeObserver fires before paint, so the browser never + // composites the old backing store stretched into the new element box. + if (shouldRender) this.renderFrame(); + return true; + } + + /** + * The local grid reflows immediately, but the PTY only hears about settled + * dimensions: notifying on every drag step makes the shell reprint its + * prompt mid-drag, which reads as jitter. + */ + private notifyResize(): void { + this.resizeNotified = true; + if (this.resizeNotifyTimer !== null) window.clearTimeout(this.resizeNotifyTimer); + this.resizeNotifyTimer = window.setTimeout(() => { + this.resizeNotifyTimer = null; + if (!this.disposed) this.options.onResize(this.cols, this.rows); + }, 150); + } + + focus(): void { + this.input.focus({ preventScroll: true }); + } + + hasSelection(): boolean { + return this.core.selectionText().length > 0; + } + + getSelection(): string { + return this.core.selectionText(); + } + + getSelectionPosition(): GhosttySelectionPosition | null { + if (!this.selectionAnchorScreen || !this.selectionEndScreen || !this.hasSelection()) + return null; + const before = + this.selectionAnchorScreen.y < this.selectionEndScreen.y || + (this.selectionAnchorScreen.y === this.selectionEndScreen.y && + this.selectionAnchorScreen.x <= this.selectionEndScreen.x); + return before + ? { start: this.selectionAnchorScreen, end: this.selectionEndScreen } + : { start: this.selectionEndScreen, end: this.selectionAnchorScreen }; + } + + getSelectionEndClientRect(): { readonly right: number; readonly bottom: number } | null { + const position = this.getSelectionPosition(); + if (!position) return null; + const viewportEnd = this.core.screenPointToViewport(position.end.x, position.end.y); + if (!viewportEnd) return null; + const bounds = this.canvas.getBoundingClientRect(); + return { + right: bounds.left + CONTENT_PADDING + (viewportEnd.x + 1) * this.metrics.width, + bottom: bounds.top + this.originY + (viewportEnd.y + 1) * this.metrics.height, + }; + } + + clearSelection(): void { + this.core.clearSelection(); + this.selectionEnd = null; + this.selectionAnchorScreen = null; + this.selectionEndScreen = null; + this.selectionMode = "cell"; + this.selectionBase = null; + this.setSelectionAutoscroll(0); + this.options.onSelectionChange(); + // Selection highlights span rows Ghostty may not mark dirty for this change. + this.forceFullRender = true; + this.requestRender(); + } + + scrollToBottom(): void { + this.core.scrollToBottom(); + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + isAtBottom(): boolean { + return this.core.isViewportActive(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.resizeObserver.disconnect(); + document.fonts.removeEventListener("loadingdone", this.onFontsLoaded); + this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); + this.dprMedia = null; + if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer); + if (this.resizeNotifyTimer !== null) { + window.clearTimeout(this.resizeNotifyTimer); + this.resizeNotifyTimer = null; + // Flush the settled dimensions so the PTY keeps the final size even when + // the surface unmounts inside the debounce window. + this.options.onResize(this.cols, this.rows); + } + if (this.frame !== 0) window.cancelAnimationFrame(this.frame); + if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer); + if (this.compositionSuppressionTimer !== null) { + window.clearTimeout(this.compositionSuppressionTimer); + } + this.removeEvents(); + this.core.dispose(); + if ( + this.canvas.parentElement === this.mount || + this.input.parentElement === this.mount || + this.scrollbar.parentElement === this.mount + ) { + this.canvas.remove(); + this.input.remove(); + this.scrollbar.remove(); + } + } + + private readonly onKeyDown = (event: KeyboardEvent) => { + // Presses handled outside the terminal must also swallow their release: + // beforeKey runs side effects (keybindings, navigation sends), so it cannot + // be consulted again on keyup, and Kitty report-event-types sessions would + // otherwise receive a release for a press the shell never saw. + if (isTerminalAltGraphText(event) || !this.options.beforeKey(event)) { + this.suppressedKeyCodes.add(event.code); + return; + } + if (isTerminalCopyShortcut(event) && this.hasSelection()) { + event.preventDefault(); + this.suppressedKeyCodes.add(event.code); + this.options.onCopy(this.getSelection()); + return; + } + if (isTerminalPasteShortcut(event)) { + this.suppressedKeyCodes.add(event.code); + const clipboard = navigator.clipboard; + if (typeof clipboard?.readText === "function") { + // Race the async clipboard read against the browser's own paste event: + // the native event (dispatched synchronously with the default action) + // always claims the token first when it fires, and the read covers + // browsers whose paste shortcut produces no paste event. Not preventing + // the default keeps the native path alive when the read is denied. + const token = ++this.pasteShortcutToken; + void clipboard.readText().then( + (text) => { + if (this.disposed || this.pasteShortcutToken !== token) return; + this.pasteShortcutToken += 1; + if (text.length > 0) this.options.onData(this.core.encodePaste(text)); + }, + () => { + // Clipboard read denied; the native paste event remains the path. + }, + ); + } + return; + } + // keyCode 229 is Safari's only signal that this keydown opens an IME + // composition; encoding it would double the committed text. + if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) { + return; + } + const data = this.core.encodeKey(event); + if (data.length === 0) return; + this.suppressedKeyCodes.delete(event.code); + event.preventDefault(); + event.stopPropagation(); + this.options.onData(data); + }; + + private readonly onKeyUp = (event: KeyboardEvent) => { + if (this.suppressedKeyCodes.delete(event.code)) return; + if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) { + return; + } + // Ghostty's encoder only emits release codes when the terminal enabled the + // Kitty report-event-types flag, so legacy sessions send nothing here. + const data = this.core.encodeKey(event, "release"); + if (data.length === 0) return; + event.preventDefault(); + event.stopPropagation(); + this.options.onData(data); + }; + + private readonly onFocus = () => { + this.focused = true; + this.cursorOn = true; + this.requestRender(); + }; + + private readonly onBlur = () => { + this.focused = false; + // Suppressions survive blur deliberately: a shortcut that moves focus (for + // example terminal-toggle) must still swallow its own keyup if focus comes + // back before release. Stale entries are harmless — an encoding keydown + // always removes its code first. + // The steady unfocused hollow cursor must not inherit an off blink phase. + this.cursorOn = true; + this.requestRender(); + }; + + private readonly onDevicePixelRatioChange = () => { + this.watchDevicePixelRatio(); + this.fit(); + }; + + private watchDevicePixelRatio(): void { + this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); + // A resolution media query only fires once for the ratio it was created at, + // so re-arm it after every change (monitor moves, browser zoom). + this.dprMedia = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange); + } + + private readonly onPaste = (event: ClipboardEvent) => { + // Always suppress the browser's default insertion: content the textarea + // would receive (for example an html-only clipboard converted to text) + // leaks through onInput without bracketed-paste encoding. + event.preventDefault(); + const data = event.clipboardData?.getData("text/plain") ?? ""; + if (data.length === 0) return; + // The native paste won the race with actual text; a pending clipboard read + // must not double. An empty native paste leaves the read as the only path. + this.pasteShortcutToken += 1; + this.options.onData(this.core.encodePaste(data)); + }; + + private readonly onCompositionStart = () => { + this.clearCompositionInputSuppression(); + this.composing = true; + }; + + private readonly onCompositionEnd = (event: CompositionEvent) => { + this.composing = false; + const data = this.input.value || event.data; + if (data.length > 0) this.options.onData(data); + this.input.value = ""; + this.compositionInputToSuppress = data; + this.compositionSuppressionTimer = window.setTimeout(() => { + this.compositionInputToSuppress = null; + this.compositionSuppressionTimer = null; + }, 100); + }; + + private readonly onInput = (event: Event) => { + const inputEvent = event as InputEvent; + if (this.composing || inputEvent.isComposing) return; + const data = this.input.value || inputEvent.data || ""; + if (data === this.compositionInputToSuppress && isTerminalCompositionCommitInput(inputEvent)) { + this.clearCompositionInputSuppression(); + this.input.value = ""; + return; + } + this.clearCompositionInputSuppression(); + if (data.length > 0) this.options.onData(data); + this.input.value = ""; + }; + + private clearCompositionInputSuppression(): void { + if (this.compositionSuppressionTimer !== null) { + window.clearTimeout(this.compositionSuppressionTimer); + this.compositionSuppressionTimer = null; + } + this.compositionInputToSuppress = null; + } + + private readonly onPointerDown = (event: PointerEvent) => { + this.focus(); + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { + const button = ghosttyMouseButton(event.button); + if (button === null) return; + event.preventDefault(); + event.stopPropagation(); + this.mouseReportingPointerId = event.pointerId; + this.mouseReportingButton = button; + this.sendMouse("press", button, event); + this.canvas.setPointerCapture(event.pointerId); + return; + } + if (event.button !== 0) return; + if (isTerminalLinkPointerGesture(event)) { + event.preventDefault(); + event.stopPropagation(); + this.linkActivationPointerId = event.pointerId; + this.canvas.setPointerCapture(event.pointerId); + return; + } + const cell = this.cellAt(event.clientX, event.clientY); + this.selectionMoved = false; + this.selectionClickSequence = advanceTerminalSelectionClickSequence( + this.selectionClickSequence, + event, + ); + const clickCount = this.selectionClickSequence.count; + this.selectionMode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : "cell"; + const range = + this.selectionMode === "line" + ? this.core.selectLine(cell.x, cell.y) + : this.selectionMode === "word" + ? this.core.selectWord(cell.x, cell.y) + : null; + if (range) { + this.selectionBase = range.screen; + this.selectionEnd = range.viewport.end; + this.selectionAnchorScreen = range.screen.start; + this.selectionEndScreen = range.screen.end; + this.options.onSelectionChange(); + } else { + this.selectionMode = "cell"; + this.selectionBase = null; + this.selectionEnd = cell; + const screen = this.core.viewportPointToScreen(cell.x, cell.y); + this.selectionAnchorScreen = screen; + this.selectionEndScreen = screen; + if (screen) { + this.core.setSelection({ ...screen, tag: 2 }, { ...screen, tag: 2 }); + } else { + this.core.setSelection(cell, cell); + } + } + this.forceFullRender = true; + this.canvas.setPointerCapture(event.pointerId); + this.requestRender(); + }; + + private readonly onPointerMove = (event: PointerEvent) => { + if (this.linkActivationPointerId === event.pointerId) return; + // Hover motion is only reportable in any-event tracking (DEC 1003); normal and + // button-event tracking never report motion without a captured pressed button. + if ( + this.mouseReportingPointerId === event.pointerId || + shouldReportTerminalMouse(this.core.isMouseAnyEventTracking(), event) + ) { + event.preventDefault(); + this.canvas.style.cursor = "default"; + this.sendMouse("motion", this.buttonFromButtons(event.buttons), event); + return; + } + if (!this.selectionAnchorScreen || !this.canvas.hasPointerCapture(event.pointerId)) { + this.updateHoverCursor(event); + return; + } + this.selectionPointer = { x: event.clientX, y: event.clientY }; + const bounds = this.canvas.getBoundingClientRect(); + this.setSelectionAutoscroll( + event.clientY < bounds.top ? -1 : event.clientY > bounds.bottom ? 1 : 0, + ); + const cell = this.cellAt(event.clientX, event.clientY); + if (cell.x === this.selectionEnd?.x && cell.y === this.selectionEnd.y) return; + this.extendSelectionTo(event.clientX, event.clientY); + }; + + private extendSelectionTo(clientX: number, clientY: number): void { + const anchorScreen = this.selectionAnchorScreen; + if (anchorScreen === null) return; + const cell = this.cellAt(clientX, clientY); + this.selectionMoved = true; + this.selectionEnd = cell; + const range = + this.selectionMode === "line" + ? this.core.selectLine(cell.x, cell.y) + : this.selectionMode === "word" + ? this.core.selectWord(cell.x, cell.y) + : null; + const cellScreen = this.core.viewportPointToScreen(cell.x, cell.y); + if (cellScreen === null) return; + const base = this.selectionBase; + const beforeBase = + base !== null && + (cellScreen.y < base.start.y || + (cellScreen.y === base.start.y && cellScreen.x < base.start.x)); + const anchor = base === null ? anchorScreen : beforeBase ? base.end : base.start; + const end = range === null ? cellScreen : beforeBase ? range.screen.start : range.screen.end; + this.selectionAnchorScreen = anchor; + this.selectionEndScreen = end; + this.core.setSelection({ ...anchor, tag: 2 }, { ...end, tag: 2 }); + this.options.onSelectionChange(); + this.forceFullRender = true; + this.requestRender(); + } + + private setSelectionAutoscroll(delta: number): void { + this.selectionScrollDelta = delta; + if (delta === 0) { + if (this.selectionScrollTimer !== null) { + window.clearInterval(this.selectionScrollTimer); + this.selectionScrollTimer = null; + } + return; + } + if (this.selectionScrollTimer !== null) return; + // Dragging past the edge scrolls the viewport and keeps extending the + // selection into the newly revealed rows, like xterm's drag scroller. + this.selectionScrollTimer = window.setInterval(() => { + if (this.disposed || this.selectionScrollDelta === 0) return; + this.scrollViewport(this.selectionScrollDelta); + const pointer = this.selectionPointer; + if (pointer) this.extendSelectionTo(pointer.x, pointer.y); + }, 80); + } + + private updateHoverCursor(event: PointerEvent): void { + const overLink = + isTerminalLinkPointerGesture(event) && this.linkAt(event.clientX, event.clientY) !== null; + const cursor = overLink ? "pointer" : ""; + if (this.canvas.style.cursor !== cursor) this.canvas.style.cursor = cursor; + } + + private readonly onPointerUp = (event: PointerEvent) => { + this.setSelectionAutoscroll(0); + if (this.linkActivationPointerId === event.pointerId) { + event.preventDefault(); + event.stopPropagation(); + this.linkActivationPointerId = null; + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + if (event.type !== "pointercancel") { + const link = this.linkAt(event.clientX, event.clientY); + if (link) this.options.onLinkActivate(link, event); + } + return; + } + if (this.mouseReportingPointerId === event.pointerId) { + event.preventDefault(); + event.stopPropagation(); + this.sendMouse("release", this.mouseReportingButton, event); + this.mouseReportingPointerId = null; + this.mouseReportingButton = null; + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + return; + } + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + if (event.button !== 0) return; + if (!this.selectionMoved && this.selectionMode === "cell") { + this.clearSelection(); + } + this.options.onSelectionChange(); + }; + + private readonly onWheel = (event: WheelEvent) => { + if (event.deltaY === 0) return; + event.preventDefault(); + const delta = terminalWheelDeltaRows( + event, + this.metrics.height, + this.rows, + this.wheelRemainder, + ); + this.wheelRemainder = delta.remainder; + if (delta.rows === 0) return; + const magnitude = Math.abs(delta.rows); + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { + const button = delta.rows < 0 ? 4 : 5; + for (let index = 0; index < magnitude; index += 1) { + this.sendMouse("press", button, event); + } + return; + } + if (this.core.isAlternateScreen()) { + // The alternate screen has no scrollback: translate wheel motion into + // arrow keys so full-screen apps like vim and less scroll, matching xterm. + this.options.onData(terminalWheelArrowData(delta.rows, this.core.isApplicationCursorKeys())); + return; + } + this.scrollViewport(delta.rows); + }; + + private readonly onMouseDown = (event: MouseEvent) => { + if (event.button === 0) event.preventDefault(); + this.focus(); + }; + + private readonly onContextMenu = (event: MouseEvent) => { + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { + event.preventDefault(); + } + }; + + private readonly onScrollbarPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return; + const state = this.readScrollbarState(); + if (state === null) return; + const bounds = this.scrollbar.getBoundingClientRect(); + const geometry = terminalScrollbarGeometry(state, bounds.height); + if (geometry === null) return; + event.preventDefault(); + event.stopPropagation(); + this.scrollbarPointerId = event.pointerId; + this.scrollbarPointerOffset = + event.target === this.scrollbarThumb + ? event.clientY - bounds.top - geometry.thumbTop + : geometry.thumbHeight / 2; + this.scrollbar.setPointerCapture(event.pointerId); + this.scrollbarToPointer(event.clientY, bounds); + }; + + private readonly onScrollbarPointerMove = (event: PointerEvent) => { + if (event.pointerId !== this.scrollbarPointerId || this.scrollbarState === null) return; + event.preventDefault(); + this.scrollbarToPointer(event.clientY, this.scrollbar.getBoundingClientRect()); + }; + + private readonly onScrollbarPointerUp = (event: PointerEvent) => { + if (event.pointerId !== this.scrollbarPointerId) return; + event.preventDefault(); + this.scrollbarPointerId = null; + if (this.scrollbar.hasPointerCapture(event.pointerId)) { + this.scrollbar.releasePointerCapture(event.pointerId); + } + }; + + private readonly onScrollbarKeyDown = (event: KeyboardEvent) => { + const state = this.readScrollbarState(); + if (state === null) return; + let delta = 0; + switch (event.key) { + case "ArrowUp": + delta = -1; + break; + case "ArrowDown": + delta = 1; + break; + case "PageUp": + delta = -Math.max(1, state.len); + break; + case "PageDown": + delta = Math.max(1, state.len); + break; + case "Home": + delta = -state.offset; + break; + case "End": + delta = state.total - state.len - state.offset; + break; + default: + return; + } + event.preventDefault(); + event.stopPropagation(); + this.scrollViewport(delta); + }; + + private installEvents(): void { + this.input.addEventListener("keydown", this.onKeyDown); + this.input.addEventListener("keyup", this.onKeyUp); + this.input.addEventListener("focus", this.onFocus); + this.input.addEventListener("blur", this.onBlur); + this.input.addEventListener("input", this.onInput); + this.input.addEventListener("paste", this.onPaste); + this.input.addEventListener("compositionstart", this.onCompositionStart); + this.input.addEventListener("compositionend", this.onCompositionEnd); + this.canvas.addEventListener("pointerdown", this.onPointerDown); + this.canvas.addEventListener("pointermove", this.onPointerMove); + this.canvas.addEventListener("pointerup", this.onPointerUp); + this.canvas.addEventListener("pointercancel", this.onPointerUp); + this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); + this.canvas.addEventListener("mousedown", this.onMouseDown); + this.canvas.addEventListener("contextmenu", this.onContextMenu); + this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown); + this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove); + this.scrollbar.addEventListener("pointerup", this.onScrollbarPointerUp); + this.scrollbar.addEventListener("pointercancel", this.onScrollbarPointerUp); + this.scrollbar.addEventListener("keydown", this.onScrollbarKeyDown); + } + + private removeEvents(): void { + this.input.removeEventListener("keydown", this.onKeyDown); + this.input.removeEventListener("keyup", this.onKeyUp); + this.input.removeEventListener("focus", this.onFocus); + this.input.removeEventListener("blur", this.onBlur); + this.input.removeEventListener("input", this.onInput); + this.input.removeEventListener("paste", this.onPaste); + this.input.removeEventListener("compositionstart", this.onCompositionStart); + this.input.removeEventListener("compositionend", this.onCompositionEnd); + this.canvas.removeEventListener("pointerdown", this.onPointerDown); + this.canvas.removeEventListener("pointermove", this.onPointerMove); + this.canvas.removeEventListener("pointerup", this.onPointerUp); + this.canvas.removeEventListener("pointercancel", this.onPointerUp); + this.canvas.removeEventListener("wheel", this.onWheel); + this.canvas.removeEventListener("mousedown", this.onMouseDown); + this.canvas.removeEventListener("contextmenu", this.onContextMenu); + this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown); + this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove); + this.scrollbar.removeEventListener("pointerup", this.onScrollbarPointerUp); + this.scrollbar.removeEventListener("pointercancel", this.onScrollbarPointerUp); + this.scrollbar.removeEventListener("keydown", this.onScrollbarKeyDown); + } + + private scrollViewport(deltaRows: number): void { + let delta = Math.trunc(deltaRows); + const state = this.readScrollbarState(); + if (state !== null) { + const maxOffset = Math.max(0, state.total - state.len); + const offset = Math.max(0, Math.min(state.offset + delta, maxOffset)); + delta = offset - state.offset; + this.scrollbarState = { ...state, offset }; + } + if (delta === 0) return; + this.core.scroll(delta); + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + private scrollbarToPointer(clientY: number, bounds: DOMRect): void { + const state = this.scrollbarState; + if (state === null) return; + const offset = terminalScrollbarOffsetAtPointer( + state, + bounds.height, + clientY - bounds.top, + this.scrollbarPointerOffset, + ); + this.scrollViewport(offset - state.offset); + } + + private updateScrollbar(): void { + const state = this.readScrollbarState(); + const geometry = + state === null + ? null + : terminalScrollbarGeometry( + state, + Math.max(0, this.mount.clientHeight - CONTENT_PADDING * 2), + ); + this.scrollbar.hidden = geometry === null; + if (state === null || geometry === null) return; + this.scrollbar.setAttribute("aria-valuemin", "0"); + this.scrollbar.setAttribute("aria-valuemax", String(geometry.maxOffset)); + this.scrollbar.setAttribute( + "aria-valuenow", + String(Math.max(0, Math.min(state.offset, geometry.maxOffset))), + ); + this.scrollbarThumb.style.height = `${geometry.thumbHeight}px`; + this.scrollbarThumb.style.transform = `translateY(${geometry.thumbTop}px)`; + } + + private readScrollbarState(): GhosttyScrollbar | null { + const state = this.core.scrollbarState(); + this.scrollbarState = state; + return state; + } + + private requestRender(): void { + if (this.disposed || this.frame !== 0) return; + this.frame = window.requestAnimationFrame(() => { + this.frame = 0; + this.renderFrame(); + }); + } + + private renderFrame(): void { + if (this.disposed) return; + if (this.frame !== 0) { + window.cancelAnimationFrame(this.frame); + this.frame = 0; + } + this.snapshot = this.core.snapshot(); + if (!this.snapshot.cursorBlinking) this.cursorOn = true; + // The origin only moves together with a forced full repaint: partial + // dirty-row redraws must never composite rows at a shifted origin over + // rows painted at the previous one. Bottom anchoring starts once + // scrollback exists, i.e. when the prompt actually lives at the bottom. + const scrollState = this.readScrollbarState(); + const anchorBottom = scrollState !== null && scrollState.total > scrollState.len; + const nextOriginY = terminalContentOriginY( + this.mountHeight, + CONTENT_PADDING, + this.rows, + this.metrics.height, + anchorBottom, + ); + if (nextOriginY !== this.originY) { + this.originY = nextOriginY; + this.forceFullRender = true; + } + renderGhosttySnapshot({ + context: this.context, + snapshot: this.snapshot, + metrics: this.metrics, + fontSize: this.fontSize, + fontFamily: this.fontFamily, + padding: CONTENT_PADDING, + originY: this.originY, + forceFull: this.forceFullRender, + cursorOn: this.cursorOn, + previousCursorY: this.renderedCursorY, + focused: this.focused, + ...(this.theme.selectionBackground !== undefined + ? { selectionBackground: this.theme.selectionBackground } + : {}), + }); + this.positionInput(); + this.renderedCursorY = + this.cursorOn && this.snapshot.cursorVisible && this.snapshot.cursorY >= 0 + ? this.snapshot.cursorY + : null; + if (this.scrollbarDirty) { + this.scrollbarDirty = false; + this.updateScrollbar(); + } + this.forceFullRender = false; + this.scheduleCursorBlink(); + } + + private scheduleCursorBlink(): void { + if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer); + this.cursorTimer = null; + // An unfocused surface shows a steady hollow cursor instead of blinking. + if (!this.focused || !this.snapshot?.cursorBlinking || !this.snapshot.cursorVisible) return; + this.cursorTimer = window.setTimeout(() => { + this.cursorTimer = null; + this.cursorOn = !this.cursorOn; + this.requestRender(); + }, 500); + } + + private positionInput(): void { + const snapshot = this.snapshot; + if (!snapshot || !snapshot.cursorVisible || snapshot.cursorX < 0 || snapshot.cursorY < 0) { + return; + } + // The IME candidate window anchors to the textarea, so it must follow the + // terminal cursor for composition to appear where the user is typing. + const left = CONTENT_PADDING + snapshot.cursorX * this.metrics.width; + const top = this.originY + snapshot.cursorY * this.metrics.height; + if (left === this.inputLeft && top === this.inputTop) return; + this.inputLeft = left; + this.inputTop = top; + this.input.style.left = `${left}px`; + this.input.style.top = `${top}px`; + this.input.style.height = `${this.metrics.height}px`; + } + + private cellAt(clientX: number, clientY: number): { x: number; y: number } { + const bounds = this.canvas.getBoundingClientRect(); + return { + x: Math.max( + 0, + Math.min( + this.cols - 1, + Math.floor((clientX - bounds.left - CONTENT_PADDING) / this.metrics.width), + ), + ), + y: Math.max( + 0, + Math.min( + this.rows - 1, + Math.floor((clientY - bounds.top - this.originY) / this.metrics.height), + ), + ), + }; + } + + private linkAt(clientX: number, clientY: number): string | null { + if (!this.snapshot) return null; + const cell = this.cellAt(clientX, clientY); + const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y); + if (explicitHyperlink) return explicitHyperlink; + return terminalLinkAtPosition(this.snapshot.rowData, cell.y, cell.x); + } + + private sendMouse( + action: "press" | "release" | "motion", + button: number | null, + event: MouseEvent, + ): void { + const bounds = this.canvas.getBoundingClientRect(); + const data = this.core.encodeMouse({ + action, + button, + mods: + (event.shiftKey ? 1 : 0) | + (event.ctrlKey ? 1 << 1 : 0) | + (event.altKey ? 1 << 2 : 0) | + (event.metaKey ? 1 << 3 : 0), + x: Math.max(0, event.clientX - bounds.left), + y: Math.max(0, event.clientY - bounds.top), + screenWidth: bounds.width, + screenHeight: bounds.height, + cellWidth: this.metrics.width, + cellHeight: this.metrics.height, + paddingLeft: CONTENT_PADDING, + paddingRight: CONTENT_PADDING, + paddingTop: this.originY, + paddingBottom: Math.max(0, bounds.height - this.originY - this.rows * this.metrics.height), + anyButtonPressed: event.buttons !== 0, + }); + if (data.length > 0) this.options.onData(data); + } + + private buttonFromButtons(buttons: number): number | null { + if ((buttons & 1) !== 0) return 1; + if ((buttons & 4) !== 0) return 3; + if ((buttons & 2) !== 0) return 2; + if ((buttons & 8) !== 0) return 4; + if ((buttons & 16) !== 0) return 5; + return null; + } +} diff --git a/apps/web/src/terminal/ghostty/vendor/ghostty-vt.wasm b/apps/web/src/terminal/ghostty/vendor/ghostty-vt.wasm new file mode 100644 index 00000000000..a8e2405adef Binary files /dev/null and b/apps/web/src/terminal/ghostty/vendor/ghostty-vt.wasm differ diff --git a/apps/web/src/terminal/ghostty/vendor/ghostty-write-pty.wasm b/apps/web/src/terminal/ghostty/vendor/ghostty-write-pty.wasm new file mode 100644 index 00000000000..66835717dd8 Binary files /dev/null and b/apps/web/src/terminal/ghostty/vendor/ghostty-write-pty.wasm differ diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e1d590ecdf2..24d76c2d9f3 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -126,6 +126,7 @@ const allowedHosts = [".ts.net", ...configuredAllowedHosts]; export default defineConfig(() => { return { + assetsInclude: ["**/*.wasm"], plugins: [ tanstackRouter(), react(), diff --git a/docs/architecture/terminal-renderers.md b/docs/architecture/terminal-renderers.md new file mode 100644 index 00000000000..483dd164403 --- /dev/null +++ b/docs/architecture/terminal-renderers.md @@ -0,0 +1,42 @@ +# Terminal renderers + +Terminal sessions remain server-owned PTYs. Clients receive the existing raw byte stream and send +input and resize events over the existing terminal contracts; renderer choices never cross the +wire. + +## Ghostty alignment + +Android and web use the official `libghostty-vt` C ABI for parsing, terminal state, grapheme +boundaries, keyboard encoding, selection, and scrollback: + +- Android links the native shared library and converts render state into a compact JNI snapshot. +- Web loads a separately cached WebAssembly build and reads render state into a Canvas 2D surface. +- Both artifacts are built from the revision in + `apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION`. + +The platform adapters deliberately own only platform behavior. Android owns its Kotlin Canvas and +touch integration. Web owns browser font shaping, the hidden IME textarea, clipboard and DOM input, +and its Canvas renderer. The web adapter also delegates application mouse encoding, word and line +selection, and OSC 8 hyperlink metadata to the official ABI. Browser conventions remain available: +holding Shift bypasses application mouse capture, and the platform link modifier opens hyperlinks. +React does not participate in terminal frames. + +The web runtime is singleton-scoped per browser tab so split terminals share one compiled module +and memory. Each visible terminal owns and frees its own terminal, render state, row iterator, cell +iterator, key and mouse encoder, and input event handles. Restoring captured scrollback temporarily +detaches the PTY callback so historical device queries cannot emit replies into the current shell. + +## Updating Ghostty + +Update and rebuild Android first, because mobile's `VERSION` file is the single source of truth for +the upstream pin (the upstream `LICENSE` lives beside it). Then run: + +```sh +pnpm --dir apps/web build:ghostty-wasm +``` + +Commit the regenerated web `wasm` artifacts. The build embeds the pinned revision into the binary as +semver build metadata, and the focused web ABI test reads it back through `ghostty_build_info` and +compares it against mobile's `VERSION` — so the web vendor directory holds only the artifacts, drift +cannot hide, and there is no second pin to keep in sync. The same test enforces the artifact budget +and exercises repeated create/write/free cycles with multi-codepoint graphemes. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5c6c87895c..85afdd75d7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -570,12 +570,6 @@ importers: '@tanstack/react-router': specifier: ^1.160.2 version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@xterm/addon-fit': - specifier: ^0.11.0 - version: 0.11.0 - '@xterm/xterm': - specifier: ^6.0.0 - version: 6.0.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -5156,12 +5150,6 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} - '@xterm/addon-fit@0.11.0': - resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} - - '@xterm/xterm@6.0.0': - resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} - '@yuuang/ffi-rs-android-arm64@1.3.2': resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==} engines: {node: '>= 12'} @@ -15283,10 +15271,6 @@ snapshots: '@xmldom/xmldom@0.9.10': {} - '@xterm/addon-fit@0.11.0': {} - - '@xterm/xterm@6.0.0': {} - '@yuuang/ffi-rs-android-arm64@1.3.2': optional: true