Unified · Split
| @@ -57,7 +57,7 @@ describe("ElectronProtocol", () => { · 56 unmodified lines | ||
| 57 | 57 | assert.equal(yield* Effect.promise(() => response.text()), "ok"); |
| 58 | 58 | assert.include( |
| 59 | 59 | response.headers.get("content-security-policy") ?? "", |
| 60 | − "script-src 'self' 'unsafe-inline' https://clerk.t3.codes https://challenges.cloudflare.com", | |
| 60 | + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://clerk.t3.codes https://challenges.cloudflare.com", | |
| 61 | 61 | ); |
| 62 | 62 | assert.include( |
| 63 | 63 | response.headers.get("content-security-policy") ?? "", |
| @@ -212,6 +212,7 @@ describe("ElectronProtocol", () => { · 148 unmodified lines | ||
| 212 | 212 | assert.deepEqual(directives["script-src"], [ |
| 213 | 213 | "'self'", |
| 214 | 214 | "'unsafe-inline'", |
| 215 | + "'wasm-unsafe-eval'", | |
| 215 | 216 | "https://clerk.t3.codes", |
| 216 | 217 | "https://challenges.cloudflare.com", |
| 217 | 218 | ]); |
| @@ -71,6 +71,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat · 70 unmodified lines | ||
| 71 | 71 | const scriptSources = [ |
| 72 | 72 | "'self'", |
| 73 | 73 | "'unsafe-inline'", |
| 74 | + "'wasm-unsafe-eval'", | |
| 74 | 75 | ...(clerkOrigin ? [clerkOrigin] : []), |
| 75 | 76 | "https://challenges.cloudflare.com", |
| 76 | 77 | ]; |
| @@ -18,13 +18,14 @@ Ghostty's MIT license applies to the vendored framework. Keep this notice in syn · 17 unmodified lines | ||
| 18 | 18 | ## Ghostty / libghostty-vt |
| 19 | 19 | |
| 20 | 20 | The Android terminal renderer vendors upstream `libghostty-vt` shared libraries and C headers. |
| 21 | +The web terminal vendors a WebAssembly build from the same revision and uses the same C ABI. | |
| 21 | 22 | |
| 22 | 23 | - Upstream project: https://github.com/ghostty-org/ghostty |
| 23 | 24 | - Vendored revision: `9f62873bf195e4d8a762d768a1405a5f2f7b1697` |
| 24 | 25 | - License: MIT |
| 25 | 26 | |
| 26 | −Ghostty's MIT license applies to the vendored Android libraries. Keep this notice in sync when | |
| 27 | −updating `Vendor/libghostty-vt`. | |
| 27 | +Ghostty's MIT license applies to the vendored Android and web libraries. Keep this notice and both | |
| 28 | +artifacts in sync when updating `Vendor/libghostty-vt`. | |
| 28 | 29 | |
| 29 | 30 | ## MesloLGS NF (Android terminal font) |
| 30 | 31 | |
| @@ -4,6 +4,7 @@ · 3 unmodified lines | ||
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| 7 | + "build:ghostty-wasm": "bash scripts/build-libghostty-wasm.sh", | |
| 7 | 8 | "dev": "vp dev", |
| 8 | 9 | "build": "vp build", |
| 9 | 10 | "preview": "vp preview", |
| @@ -31,8 +32,6 @@ · 21 unmodified lines | ||
| 31 | 32 | "@t3tools/shared": "workspace:*", |
| 32 | 33 | "@tanstack/react-pacer": "^0.19.4", |
| 33 | 34 | "@tanstack/react-router": "^1.160.2", |
| 34 | − "@xterm/addon-fit": "^0.11.0", | |
| 35 | − "@xterm/xterm": "^6.0.0", | |
| 36 | 35 | "class-variance-authority": "^0.7.1", |
| 37 | 36 | "effect": "catalog:", |
| 38 | 37 | "jose": "catalog:", |
| @@ -0,0 +1,123 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | + | |
| 3 | +set -euo pipefail | |
| 4 | + | |
| 5 | +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| 6 | +WEB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" | |
| 7 | +REPO_DIR="$(cd "${WEB_DIR}/../.." && pwd)" | |
| 8 | +MOBILE_VENDOR_DIR="${REPO_DIR}/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt" | |
| 9 | +VENDOR_DIR="${WEB_DIR}/src/terminal/ghostty/vendor" | |
| 10 | + | |
| 11 | +GHOSTTY_REVISION="$(tr -d '[:space:]' < "${MOBILE_VENDOR_DIR}/VERSION")" | |
| 12 | +GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${HOME}/.cache/t3code/ghostty-${GHOSTTY_REVISION:0:8}}" | |
| 13 | +GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}" | |
| 14 | +GHOSTTY_ZIG="${GHOSTTY_ZIG:-}" | |
| 15 | + | |
| 16 | +log() { | |
| 17 | + printf '[libghostty-vt-wasm] %s\n' "$*" | |
| 18 | +} | |
| 19 | + | |
| 20 | +die() { | |
| 21 | + printf '[libghostty-vt-wasm] error: %s\n' "$*" >&2 | |
| 22 | + exit 1 | |
| 23 | +} | |
| 24 | + | |
| 25 | +require_cmd() { | |
| 26 | + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" | |
| 27 | +} | |
| 28 | + | |
| 29 | +ensure_zig() { | |
| 30 | + if [[ -n "${GHOSTTY_ZIG}" ]]; then | |
| 31 | + [[ -x "${GHOSTTY_ZIG}" ]] || die "GHOSTTY_ZIG is not executable: ${GHOSTTY_ZIG}" | |
| 32 | + return | |
| 33 | + fi | |
| 34 | + if command -v zig >/dev/null 2>&1 && [[ "$(zig version)" == "${GHOSTTY_ZIG_VERSION}" ]]; then | |
| 35 | + GHOSTTY_ZIG="$(command -v zig)" | |
| 36 | + return | |
| 37 | + fi | |
| 38 | + | |
| 39 | + local host_os host_arch cache_dir | |
| 40 | + host_os="$(uname -s | tr '[:upper:]' '[:lower:]')" | |
| 41 | + host_arch="$(uname -m)" | |
| 42 | + case "${host_os}" in | |
| 43 | + darwin) host_os="macos" ;; | |
| 44 | + linux) ;; | |
| 45 | + *) die "unsupported host OS for Zig download: ${host_os}" ;; | |
| 46 | + esac | |
| 47 | + case "${host_arch}" in | |
| 48 | + arm64) host_arch="aarch64" ;; | |
| 49 | + aarch64 | x86_64) ;; | |
| 50 | + *) die "unsupported host architecture: ${host_arch}" ;; | |
| 51 | + esac | |
| 52 | + | |
| 53 | + cache_dir="${HOME}/.cache/t3code/zig-${GHOSTTY_ZIG_VERSION}" | |
| 54 | + GHOSTTY_ZIG="${cache_dir}/zig" | |
| 55 | + if [[ -x "${GHOSTTY_ZIG}" ]]; then | |
| 56 | + return | |
| 57 | + fi | |
| 58 | + | |
| 59 | + require_cmd curl | |
| 60 | + require_cmd tar | |
| 61 | + mkdir -p "${cache_dir}" | |
| 62 | + log "downloading Zig ${GHOSTTY_ZIG_VERSION}" | |
| 63 | + curl -fsSL \ | |
| 64 | + "https://ziglang.org/download/${GHOSTTY_ZIG_VERSION}/zig-${host_arch}-${host_os}-${GHOSTTY_ZIG_VERSION}.tar.xz" \ | |
| 65 | + | tar -xJ --strip-components=1 -C "${cache_dir}" | |
| 66 | +} | |
| 67 | + | |
| 68 | +ensure_ghostty_source() { | |
| 69 | + require_cmd git | |
| 70 | + if [[ ! -d "${GHOSTTY_SOURCE_DIR}/.git" ]]; then | |
| 71 | + mkdir -p "$(dirname "${GHOSTTY_SOURCE_DIR}")" | |
| 72 | + log "cloning Ghostty ${GHOSTTY_REVISION}" | |
| 73 | + git clone --filter=blob:none --no-checkout https://github.com/ghostty-org/ghostty.git \ | |
| 74 | + "${GHOSTTY_SOURCE_DIR}" | |
| 75 | + fi | |
| 76 | + | |
| 77 | + # A cached checkout may still be on a previously pinned revision; converge on | |
| 78 | + # the pinned one instead of failing the rebuild. | |
| 79 | + local actual_revision | |
| 80 | + actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD 2>/dev/null || echo none)" | |
| 81 | + if [[ "${actual_revision}" != "${GHOSTTY_REVISION}" ]]; then | |
| 82 | + log "checking out Ghostty ${GHOSTTY_REVISION}" | |
| 83 | + git -C "${GHOSTTY_SOURCE_DIR}" fetch --depth=1 origin "${GHOSTTY_REVISION}" | |
| 84 | + git -C "${GHOSTTY_SOURCE_DIR}" checkout --detach "${GHOSTTY_REVISION}" | |
| 85 | + fi | |
| 86 | + | |
| 87 | + actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD)" | |
| 88 | + [[ "${actual_revision}" == "${GHOSTTY_REVISION}" ]] || \ | |
| 89 | + die "expected Ghostty ${GHOSTTY_REVISION}, found ${actual_revision}" | |
| 90 | +} | |
| 91 | + | |
| 92 | +ensure_zig | |
| 93 | +ensure_ghostty_source | |
| 94 | + | |
| 95 | +build_root="$(mktemp -d)" | |
| 96 | +trap 'rm -rf "${build_root}"' EXIT | |
| 97 | + | |
| 98 | +log "building ${GHOSTTY_REVISION} for wasm32-freestanding" | |
| 99 | +( | |
| 100 | + cd "${GHOSTTY_SOURCE_DIR}" | |
| 101 | + # The pinned revision rides along as semver build metadata so the artifact | |
| 102 | + # identifies its own provenance through ghostty_build_info(); mobile's | |
| 103 | + # VERSION file stays the single source of truth for the pin. | |
| 104 | + "${GHOSTTY_ZIG}" build \ | |
| 105 | + -Demit-lib-vt \ | |
| 106 | + -Dtarget=wasm32-freestanding \ | |
| 107 | + -Doptimize=ReleaseSmall \ | |
| 108 | + -Dstrip=true \ | |
| 109 | + -Dlib-version-string="0.1.0-dev+${GHOSTTY_REVISION}" \ | |
| 110 | + -p "${build_root}" | |
| 111 | +) | |
| 112 | + | |
| 113 | +mkdir -p "${VENDOR_DIR}" | |
| 114 | +cp "${build_root}/bin/ghostty-vt.wasm" "${VENDOR_DIR}/ghostty-vt.wasm" | |
| 115 | +"${GHOSTTY_ZIG}" build-exe \ | |
| 116 | + "${SCRIPT_DIR}/ghostty-write-pty.zig" \ | |
| 117 | + -target wasm32-freestanding \ | |
| 118 | + -O ReleaseSmall \ | |
| 119 | + -fno-entry \ | |
| 120 | + -rdynamic \ | |
| 121 | + -femit-bin="${VENDOR_DIR}/ghostty-write-pty.wasm" | |
| 122 | +chmod 0644 "${VENDOR_DIR}/ghostty-write-pty.wasm" | |
| 123 | +log "wrote ${VENDOR_DIR}/ghostty-vt.wasm" | |
| @@ -0,0 +1,5 @@ | ||
| 1 | +extern "env" fn t3_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void; | |
| 2 | + | |
| 3 | +export fn ghostty_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void { | |
| 4 | + t3_write_pty(terminal, userdata, data, len); | |
| 5 | +} | |
| @@ -704,6 +704,19 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 703 unmodified lines | ||
| 704 | 704 | () => drawerTerminalSessions.map((session) => session.target.terminalId), |
| 705 | 705 | [drawerTerminalSessions], |
| 706 | 706 | ); |
| 707 | + // Every client-side id source participates in allocation: the server list | |
| 708 | + // lags fresh opens, and panel terminals are filtered out of the drawer's | |
| 709 | + // sessions — an id collision attaches two viewports to one PTY session. | |
| 710 | + const allocatableTerminalIds = useMemo( | |
| 711 | + () => [ | |
| 712 | + ...new Set([ | |
| 713 | + ...serverOrderedTerminalIds, | |
| 714 | + ...terminalUiState.terminalIds, | |
| 715 | + ...panelTerminalIds, | |
| 716 | + ]), | |
| 717 | + ], | |
| 718 | + [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], | |
| 719 | + ); | |
| 707 | 720 | const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); |
| 708 | 721 | const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); |
| 709 | 722 | const storeSplitTerminalVertical = useTerminalUiStateStore( |
| @@ -773,7 +786,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 63 unmodified lines | ||
| 773 | 786 | if (!cwd) { |
| 774 | 787 | return; |
| 775 | 788 | } |
| 776 | − const terminalId = nextTerminalId(serverOrderedTerminalIds); | |
| 789 | + const terminalId = nextTerminalId(allocatableTerminalIds); | |
| 777 | 790 | storeSplitTerminal(threadRef, terminalId); |
| 778 | 791 | bumpFocusRequestId(); |
| 779 | 792 | void openTerminal({ |
| @@ -787,11 +800,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 7 unmodified lines | ||
| 787 | 800 | }, |
| 788 | 801 | }); |
| 789 | 802 | }, [ |
| 803 | + allocatableTerminalIds, | |
| 790 | 804 | bumpFocusRequestId, |
| 791 | 805 | cwd, |
| 792 | 806 | effectiveWorktreePath, |
| 793 | 807 | runtimeEnv, |
| 794 | − serverOrderedTerminalIds, | |
| 795 | 808 | storeSplitTerminal, |
| 796 | 809 | threadId, |
| 797 | 810 | threadRef, |
| @@ -801,7 +814,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 3 unmodified lines | ||
| 801 | 814 | if (!cwd) { |
| 802 | 815 | return; |
| 803 | 816 | } |
| 804 | − const terminalId = nextTerminalId(serverOrderedTerminalIds); | |
| 817 | + const terminalId = nextTerminalId(allocatableTerminalIds); | |
| 805 | 818 | storeSplitTerminalVertical(threadRef, terminalId); |
| 806 | 819 | bumpFocusRequestId(); |
| 807 | 820 | void openTerminal({ |
| @@ -815,12 +828,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 7 unmodified lines | ||
| 815 | 828 | }, |
| 816 | 829 | }); |
| 817 | 830 | }, [ |
| 831 | + allocatableTerminalIds, | |
| 818 | 832 | bumpFocusRequestId, |
| 819 | 833 | cwd, |
| 820 | 834 | effectiveWorktreePath, |
| 821 | 835 | openTerminal, |
| 822 | 836 | runtimeEnv, |
| 823 | − serverOrderedTerminalIds, | |
| 824 | 837 | storeSplitTerminalVertical, |
| 825 | 838 | threadId, |
| 826 | 839 | threadRef, |
| @@ -830,7 +843,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 3 unmodified lines | ||
| 830 | 843 | if (!cwd) { |
| 831 | 844 | return; |
| 832 | 845 | } |
| 833 | − const terminalId = nextTerminalId(serverOrderedTerminalIds); | |
| 846 | + const terminalId = nextTerminalId(allocatableTerminalIds); | |
| 834 | 847 | storeNewTerminal(threadRef, terminalId); |
| 835 | 848 | bumpFocusRequestId(); |
| 836 | 849 | void openTerminal({ |
| @@ -847,8 +860,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 10 unmodified lines | ||
| 847 | 860 | bumpFocusRequestId, |
| 848 | 861 | cwd, |
| 849 | 862 | effectiveWorktreePath, |
| 863 | + allocatableTerminalIds, | |
| 850 | 864 | runtimeEnv, |
| 851 | − serverOrderedTerminalIds, | |
| 852 | 865 | storeNewTerminal, |
| 853 | 866 | threadId, |
| 854 | 867 | threadRef, |
| @@ -1521,6 +1534,10 @@ function ChatViewContent(props: ChatViewProps) { · 666 unmodified lines | ||
| 1521 | 1534 | ), |
| 1522 | 1535 | [rightPanelState.surfaces], |
| 1523 | 1536 | ); |
| 1537 | + const allocatableActiveTerminalIds = useMemo( | |
| 1538 | + () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])], | |
| 1539 | + [activeKnownTerminalIds, panelTerminalIds], | |
| 1540 | + ); | |
| 1524 | 1541 | const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); |
| 1525 | 1542 | const rightPanelOpen = rightPanelState.isOpen; |
| 1526 | 1543 | const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; |
| @@ -2628,7 +2645,7 @@ function ChatViewContent(props: ChatViewProps) { · 1101 unmodified lines | ||
| 2628 | 2645 | if (!cwdForOpen) { |
| 2629 | 2646 | return; |
| 2630 | 2647 | } |
| 2631 | − const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); | |
| 2648 | + const terminalId = nextTerminalId(allocatableActiveTerminalIds); | |
| 2632 | 2649 | storeEnsureTerminal(activeThreadRef, terminalId, { open: true }); |
| 2633 | 2650 | void openTerminal({ |
| 2634 | 2651 | environmentId, |
| @@ -2647,15 +2664,14 @@ function ChatViewContent(props: ChatViewProps) { · 12 unmodified lines | ||
| 2647 | 2664 | } |
| 2648 | 2665 | setTerminalOpen(nextOpen); |
| 2649 | 2666 | }, [ |
| 2650 | − activeKnownTerminalIds, | |
| 2651 | 2667 | activeProject, |
| 2652 | 2668 | activeThreadId, |
| 2653 | 2669 | activeThreadRef, |
| 2654 | 2670 | activeThreadWorktreePath, |
| 2671 | + allocatableActiveTerminalIds, | |
| 2655 | 2672 | environmentId, |
| 2656 | 2673 | gitCwd, |
| 2657 | 2674 | openTerminal, |
| 2658 | − panelTerminalIds, | |
| 2659 | 2675 | setTerminalOpen, |
| 2660 | 2676 | storeEnsureTerminal, |
| 2661 | 2677 | terminalUiState.terminalIds.length, |
| @@ -2670,7 +2686,7 @@ function ChatViewContent(props: ChatViewProps) { · 8 unmodified lines | ||
| 2670 | 2686 | if (!cwdForOpen) { |
| 2671 | 2687 | return; |
| 2672 | 2688 | } |
| 2673 | − const terminalId = nextTerminalId(activeKnownTerminalIds); | |
| 2689 | + const terminalId = nextTerminalId(allocatableActiveTerminalIds); | |
| 2674 | 2690 | if (direction === "vertical") { |
| 2675 | 2691 | storeSplitTerminalVertical(activeThreadRef, terminalId); |
| 2676 | 2692 | } else { |
| @@ -2693,8 +2709,8 @@ function ChatViewContent(props: ChatViewProps) { · 16 unmodified lines | ||
| 2693 | 2709 | }, |
| 2694 | 2710 | [ |
| 2695 | 2711 | activeProject, |
| 2696 | − activeKnownTerminalIds, | |
| 2697 | 2712 | activeThreadId, |
| 2713 | + allocatableActiveTerminalIds, | |
| 2698 | 2714 | activeThreadRef, |
| 2699 | 2715 | openTerminal, |
| 2700 | 2716 | activeThreadWorktreePath, |
| @@ -2713,7 +2729,7 @@ function ChatViewContent(props: ChatViewProps) { · 12 unmodified lines | ||
| 2713 | 2729 | if (!cwdForOpen) { |
| 2714 | 2730 | return; |
| 2715 | 2731 | } |
| 2716 | − const terminalId = nextTerminalId(activeKnownTerminalIds); | |
| 2732 | + const terminalId = nextTerminalId(allocatableActiveTerminalIds); | |
| 2717 | 2733 | storeNewTerminal(activeThreadRef, terminalId); |
| 2718 | 2734 | setTerminalFocusRequestId((value) => value + 1); |
| 2719 | 2735 | void openTerminal({ |
| @@ -2731,8 +2747,8 @@ function ChatViewContent(props: ChatViewProps) { · 11 unmodified lines | ||
| 2731 | 2747 | }); |
| 2732 | 2748 | }, [ |
| 2733 | 2749 | activeProject, |
| 2734 | − activeKnownTerminalIds, | |
| 2735 | 2750 | activeThreadId, |
| 2751 | + allocatableActiveTerminalIds, | |
| 2736 | 2752 | activeThreadRef, |
| 2737 | 2753 | openTerminal, |
| 2738 | 2754 | activeThreadWorktreePath, |
| @@ -2818,7 +2834,7 @@ function ChatViewContent(props: ChatViewProps) { · 79 unmodified lines | ||
| 2818 | 2834 | ...(options?.env ? { extraEnv: options.env } : {}), |
| 2819 | 2835 | }); |
| 2820 | 2836 | const targetTerminalId = shouldCreateNewTerminal |
| 2821 | − ? nextTerminalId(activeKnownTerminalIds) | |
| 2837 | + ? nextTerminalId(allocatableActiveTerminalIds) | |
| 2822 | 2838 | : baseTerminalId; |
| 2823 | 2839 | const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal |
| 2824 | 2840 | ? { |
| @@ -2886,6 +2902,7 @@ function ChatViewContent(props: ChatViewProps) { · 61 unmodified lines | ||
| 2886 | 2902 | environmentId, |
| 2887 | 2903 | openTerminal, |
| 2888 | 2904 | activeKnownTerminalIds, |
| 2905 | + allocatableActiveTerminalIds, | |
| 2889 | 2906 | runningTerminalIds, |
| 2890 | 2907 | terminalUiState.activeTerminalId, |
| 2891 | 2908 | writeTerminal, |
| @@ -3144,7 +3161,7 @@ function ChatViewContent(props: ChatViewProps) { · 252 unmodified lines | ||
| 3144 | 3161 | const addTerminalSurface = useCallback(() => { |
| 3145 | 3162 | if (!activeThreadRef || !activeThreadId || !activeProject) return; |
| 3146 | 3163 | const cwd = gitCwd ?? activeProject.workspaceRoot; |
| 3147 | − const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); | |
| 3164 | + const terminalId = nextTerminalId(allocatableActiveTerminalIds); | |
| 3148 | 3165 | useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); |
| 3149 | 3166 | setTerminalFocusRequestId((value) => value + 1); |
| 3150 | 3167 | void openTerminal({ |
| @@ -3161,14 +3178,13 @@ function ChatViewContent(props: ChatViewProps) { · 10 unmodified lines | ||
| 3161 | 3178 | }, |
| 3162 | 3179 | }); |
| 3163 | 3180 | }, [ |
| 3164 | − activeKnownTerminalIds, | |
| 3165 | 3181 | activeProject, |
| 3166 | 3182 | activeThreadId, |
| 3167 | 3183 | activeThreadRef, |
| 3168 | 3184 | activeThreadWorktreePath, |
| 3185 | + allocatableActiveTerminalIds, | |
| 3169 | 3186 | gitCwd, |
| 3170 | 3187 | openTerminal, |
| 3171 | − panelTerminalIds, | |
| 3172 | 3188 | ]); |
| 3173 | 3189 | const splitPanelTerminal = useCallback( |
| 3174 | 3190 | (direction: "horizontal" | "vertical" = "horizontal") => { |
| @@ -3181,7 +3197,7 @@ function ChatViewContent(props: ChatViewProps) { · 6 unmodified lines | ||
| 3181 | 3197 | ) { |
| 3182 | 3198 | return; |
| 3183 | 3199 | } |
| 3184 | − const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); | |
| 3200 | + const terminalId = nextTerminalId(allocatableActiveTerminalIds); | |
| 3185 | 3201 | const cwd = gitCwd ?? activeProject.workspaceRoot; |
| 3186 | 3202 | useRightPanelStore |
| 3187 | 3203 | .getState() |
| @@ -3202,15 +3218,14 @@ function ChatViewContent(props: ChatViewProps) { · 14 unmodified lines | ||
| 3202 | 3218 | }); |
| 3203 | 3219 | }, |
| 3204 | 3220 | [ |
| 3205 | − activeKnownTerminalIds, | |
| 3206 | 3221 | activeProject, |
| 3207 | 3222 | activeRightPanelSurface, |
| 3208 | 3223 | activeThreadId, |
| 3209 | 3224 | activeThreadRef, |
| 3210 | 3225 | activeThreadWorktreePath, |
| 3226 | + allocatableActiveTerminalIds, | |
| 3211 | 3227 | gitCwd, |
| 3212 | 3228 | openTerminal, |
| 3213 | − panelTerminalIds, | |
| 3214 | 3229 | ], |
| 3215 | 3230 | ); |
| 3216 | 3231 | const splitPanelTerminalVertical = useCallback(() => { |
| @@ -2,8 +2,10 @@ import { describe, expect, it } from "vite-plus/test"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | resolveTerminalSelectionActionPosition, |
| 5 | + shouldHandleTerminalExit, | |
| 5 | 6 | shouldHandleTerminalSelectionMouseUp, |
| 6 | 7 | terminalSelectionActionDelayForClickCount, |
| 8 | + terminalSelectionLineRange, | |
| 7 | 9 | } from "./ThreadTerminalDrawer"; |
| 8 | 10 | |
| 9 | 11 | describe("resolveTerminalSelectionActionPosition", () => { |
| @@ -72,4 +74,19 @@ describe("resolveTerminalSelectionActionPosition", () => { · 62 unmodified lines | ||
| 72 | 74 | expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false); |
| 73 | 75 | expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false); |
| 74 | 76 | }); |
| 77 | + | |
| 78 | + it("uses Ghostty's physical screen range for visually wrapped selections", () => { | |
| 79 | + expect( | |
| 80 | + terminalSelectionLineRange({ | |
| 81 | + start: { y: 4 }, | |
| 82 | + end: { y: 6 }, | |
| 83 | + }), | |
| 84 | + ).toEqual({ lineStart: 5, lineEnd: 7 }); | |
| 85 | + }); | |
| 86 | + | |
| 87 | + it("handles an exit that lands while the terminal surface is still loading", () => { | |
| 88 | + expect(shouldHandleTerminalExit("exited", "running", false)).toBe(true); | |
| 89 | + expect(shouldHandleTerminalExit("exited", "exited", false)).toBe(false); | |
| 90 | + expect(shouldHandleTerminalExit("closed", "running", true)).toBe(false); | |
| 91 | + }); | |
| 75 | 92 | }); |
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | −import { FitAddon } from "@xterm/addon-fit"; | |
| 3 | 2 | import { |
| 4 | 3 | isAtomCommandInterrupted, |
| 5 | 4 | squashAtomCommandFailure, |
| 6 | 5 | } from "@t3tools/client-runtime/state/runtime"; |
| 6 | +import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; | |
| 7 | 7 | import { |
| 8 | 8 | Plus, |
| 9 | 9 | SquareSplitHorizontal, |
| @@ -18,7 +18,6 @@ import { · 8 unmodified lines | ||
| 18 | 18 | type ThreadId, |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | 20 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 21 | −import { Terminal, type ITheme } from "@xterm/xterm"; | |
| 22 | 21 | import { |
| 23 | 22 | type PointerEvent as ReactPointerEvent, |
| 24 | 23 | type ReactNode, |
| @@ -34,15 +33,13 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; · 9 unmodified lines | ||
| 34 | 33 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 35 | 34 | import { cn } from "~/lib/utils"; |
| 36 | 35 | import { type TerminalContextSelection } from "~/lib/terminalContext"; |
| 37 | −import { useOpenInPreferredEditor } from "../editorPreferences"; | |
| 38 | 36 | import { |
| 39 | − collectWrappedTerminalLinkLine, | |
| 40 | − extractTerminalLinks, | |
| 41 | − isTerminalLinkActivation, | |
| 42 | − resolvePathLinkTarget, | |
| 43 | − resolveWrappedTerminalLinkRange, | |
| 44 | − wrappedTerminalLinkRangeIntersectsBufferLine, | |
| 45 | −} from "../terminal-links"; | |
| 37 | + GhosttyTerminalSurface, | |
| 38 | + type GhosttyTerminalSurfaceOptions, | |
| 39 | +} from "~/terminal/ghostty/surface"; | |
| 40 | +import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; | |
| 41 | +import { useOpenInPreferredEditor } from "../editorPreferences"; | |
| 42 | +import { isTerminalLinkActivation, resolvePathLinkTarget } from "../terminal-links"; | |
| 46 | 43 | import { |
| 47 | 44 | isDiffToggleShortcut, |
| 48 | 45 | isTerminalClearShortcut, |
| @@ -82,24 +79,34 @@ function clampDrawerHeight(height: number): number { · 33 unmodified lines | ||
| 82 | 79 | return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight); |
| 83 | 80 | } |
| 84 | 81 | |
| 85 | −function writeSystemMessage(terminal: Terminal, message: string): void { | |
| 82 | +function writeSystemMessage(terminal: GhosttyTerminalSurface, message: string): void { | |
| 86 | 83 | terminal.write(`\r\n[terminal] ${message}\r\n`); |
| 87 | 84 | } |
| 88 | 85 | |
| 89 | −function writeTerminalBuffer(terminal: Terminal, buffer: string): void { | |
| 90 | − terminal.write("\u001bc"); | |
| 91 | − if (buffer.length > 0) { | |
| 92 | − terminal.write(buffer); | |
| 93 | − } | |
| 86 | +function writeTerminalBuffer(terminal: GhosttyTerminalSurface, buffer: string): void { | |
| 87 | + terminal.resetAndWrite(buffer); | |
| 94 | 88 | } |
| 95 | 89 | |
| 96 | −function fitTerminalSafely(fitAddon: FitAddon): boolean { | |
| 97 | − try { | |
| 98 | − fitAddon.fit(); | |
| 99 | − return true; | |
| 100 | − } catch { | |
| 101 | − return false; | |
| 102 | − } | |
| 90 | +function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor { | |
| 91 | + if (typeof document === "undefined") return fallback; | |
| 92 | + | |
| 93 | + const canvas = document.createElement("canvas"); | |
| 94 | + canvas.width = 1; | |
| 95 | + canvas.height = 1; | |
| 96 | + const context = canvas.getContext("2d", { willReadFrequently: true }); | |
| 97 | + if (!context) return fallback; | |
| 98 | + | |
| 99 | + context.clearRect(0, 0, 1, 1); | |
| 100 | + context.fillStyle = value; | |
| 101 | + context.fillRect(0, 0, 1, 1); | |
| 102 | + const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data; | |
| 103 | + if (alpha === 0) return fallback; | |
| 104 | + | |
| 105 | + return { | |
| 106 | + r: red ?? fallback.r, | |
| 107 | + g: green ?? fallback.g, | |
| 108 | + b: blue ?? fallback.b, | |
| 109 | + }; | |
| 103 | 110 | } |
| 104 | 111 | |
| 105 | 112 | function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string { |
| @@ -124,7 +131,7 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri · 18 unmodified lines | ||
| 124 | 131 | return value ?? fallback; |
| 125 | 132 | } |
| 126 | 133 | |
| 127 | −function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { | |
| 134 | +function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { | |
| 128 | 135 | const isDark = document.documentElement.classList.contains("dark"); |
| 129 | 136 | const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; |
| 130 | 137 | const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; |
| @@ -143,86 +150,22 @@ function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { · 12 unmodified lines | ||
| 143 | 150 | normalizeComputedColor(bodyStyles.color, fallbackForeground), |
| 144 | 151 | ); |
| 145 | 152 | |
| 146 | − if (isDark) { | |
| 147 | − return { | |
| 153 | + return { | |
| 154 | + background: parseTerminalColor( | |
| 148 | 155 | background, |
| 156 | + isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 }, | |
| 157 | + ), | |
| 158 | + foreground: parseTerminalColor( | |
| 149 | 159 | foreground, |
| 150 | − cursor: "rgb(180, 203, 255)", | |
| 151 | − selectionBackground: "rgba(180, 203, 255, 0.25)", | |
| 152 | − scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)", | |
| 153 | − scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)", | |
| 154 | − scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)", | |
| 155 | − black: "rgb(24, 30, 38)", | |
| 156 | − red: "rgb(255, 122, 142)", | |
| 157 | − green: "rgb(134, 231, 149)", | |
| 158 | − yellow: "rgb(244, 205, 114)", | |
| 159 | − blue: "rgb(137, 190, 255)", | |
| 160 | − magenta: "rgb(208, 176, 255)", | |
| 161 | − cyan: "rgb(124, 232, 237)", | |
| 162 | − white: "rgb(210, 218, 230)", | |
| 163 | − brightBlack: "rgb(110, 120, 136)", | |
| 164 | − brightRed: "rgb(255, 168, 180)", | |
| 165 | − brightGreen: "rgb(176, 245, 186)", | |
| 166 | − brightYellow: "rgb(255, 224, 149)", | |
| 167 | − brightBlue: "rgb(174, 210, 255)", | |
| 168 | − brightMagenta: "rgb(229, 203, 255)", | |
| 169 | − brightCyan: "rgb(167, 244, 247)", | |
| 170 | − brightWhite: "rgb(244, 247, 252)", | |
| 171 | − }; | |
| 172 | − } | |
| 173 | − | |
| 174 | − return { | |
| 175 | − background, | |
| 176 | − foreground, | |
| 177 | − cursor: "rgb(38, 56, 78)", | |
| 178 | − selectionBackground: "rgba(37, 63, 99, 0.2)", | |
| 179 | − scrollbarSliderBackground: "rgba(0, 0, 0, 0.15)", | |
| 180 | − scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)", | |
| 181 | − scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.3)", | |
| 182 | − black: "rgb(44, 53, 66)", | |
| 183 | − red: "rgb(191, 70, 87)", | |
| 184 | − green: "rgb(60, 126, 86)", | |
| 185 | − yellow: "rgb(146, 112, 35)", | |
| 186 | − blue: "rgb(72, 102, 163)", | |
| 187 | − magenta: "rgb(132, 86, 149)", | |
| 188 | − cyan: "rgb(53, 127, 141)", | |
| 189 | − white: "rgb(210, 215, 223)", | |
| 190 | − brightBlack: "rgb(112, 123, 140)", | |
| 191 | − brightRed: "rgb(212, 95, 112)", | |
| 192 | − brightGreen: "rgb(85, 148, 111)", | |
| 193 | − brightYellow: "rgb(173, 133, 45)", | |
| 194 | − brightBlue: "rgb(91, 124, 194)", | |
| 195 | − brightMagenta: "rgb(153, 107, 172)", | |
| 196 | − brightCyan: "rgb(70, 149, 164)", | |
| 197 | − brightWhite: "rgb(236, 240, 246)", | |
| 160 | + isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 }, | |
| 161 | + ), | |
| 162 | + cursor: isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, | |
| 163 | + // Matches the xterm selection overlays this renderer replaced; the text | |
| 164 | + // color underneath is left unchanged for contrast in both themes. | |
| 165 | + selectionBackground: isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)", | |
| 198 | 166 | }; |
| 199 | 167 | } |
| 200 | 168 | |
| 201 | −function getTerminalSelectionRect(mountElement: HTMLElement): DOMRect | null { | |
| 202 | − const selection = window.getSelection(); | |
| 203 | − if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { | |
| 204 | − return null; | |
| 205 | − } | |
| 206 | − | |
| 207 | − const range = selection.getRangeAt(0); | |
| 208 | − const commonAncestor = range.commonAncestorContainer; | |
| 209 | − const selectionRoot = | |
| 210 | − commonAncestor instanceof Element ? commonAncestor : commonAncestor.parentElement; | |
| 211 | − if (!(selectionRoot instanceof Element) || !mountElement.contains(selectionRoot)) { | |
| 212 | − return null; | |
| 213 | − } | |
| 214 | − | |
| 215 | − const rects = Array.from(range.getClientRects()).filter( | |
| 216 | − (rect) => rect.width > 0 || rect.height > 0, | |
| 217 | − ); | |
| 218 | − if (rects.length > 0) { | |
| 219 | − return rects[rects.length - 1] ?? null; | |
| 220 | − } | |
| 221 | − | |
| 222 | − const boundingRect = range.getBoundingClientRect(); | |
| 223 | − return boundingRect.width > 0 || boundingRect.height > 0 ? boundingRect : null; | |
| 224 | −} | |
| 225 | − | |
| 226 | 169 | export function resolveTerminalSelectionActionPosition(options: { |
| 227 | 170 | bounds: { left: number; top: number; width: number; height: number }; |
| 228 | 171 | selectionRect: { right: number; bottom: number } | null; |
| @@ -269,6 +212,27 @@ export function shouldHandleTerminalSelectionMouseUp( · 40 unmodified lines | ||
| 269 | 212 | return selectionGestureActive && button === 0; |
| 270 | 213 | } |
| 271 | 214 | |
| 215 | +export function terminalSelectionLineRange(position: { | |
| 216 | + start: { y: number }; | |
| 217 | + end: { y: number }; | |
| 218 | +}): { lineStart: number; lineEnd: number } { | |
| 219 | + const lineStart = position.start.y + 1; | |
| 220 | + return { | |
| 221 | + lineStart, | |
| 222 | + lineEnd: Math.max(lineStart, position.end.y + 1), | |
| 223 | + }; | |
| 224 | +} | |
| 225 | + | |
| 226 | +export function shouldHandleTerminalExit( | |
| 227 | + current: TerminalSessionState["status"], | |
| 228 | + synchronized: TerminalSessionState["status"], | |
| 229 | + alreadyHandled: boolean, | |
| 230 | +): boolean { | |
| 231 | + return ( | |
| 232 | + (current === "closed" || current === "exited") && current !== synchronized && !alreadyHandled | |
| 233 | + ); | |
| 234 | +} | |
| 235 | + | |
| 272 | 236 | interface TerminalViewportProps { |
| 273 | 237 | threadRef: ScopedThreadRef; |
| 274 | 238 | threadId: ThreadId; |
| @@ -309,8 +273,7 @@ export function TerminalViewport({ · 34 unmodified lines | ||
| 309 | 273 | keybindings, |
| 310 | 274 | }: TerminalViewportProps) { |
| 311 | 275 | const containerRef = useRef<HTMLDivElement>(null); |
| 312 | − const terminalRef = useRef<Terminal | null>(null); | |
| 313 | − const fitAddonRef = useRef<FitAddon | null>(null); | |
| 276 | + const terminalRef = useRef<GhosttyTerminalSurface | null>(null); | |
| 314 | 277 | const environmentId = threadRef.environmentId; |
| 315 | 278 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); |
| 316 | 279 | const openInPreferredEditor = useOpenInPreferredEditor( |
| @@ -367,6 +330,24 @@ export function TerminalViewport({ · 50 unmodified lines | ||
| 367 | 330 | const terminalBuffer = terminalSession.buffer; |
| 368 | 331 | const terminalError = terminalSession.error; |
| 369 | 332 | const terminalStatus = terminalSession.status; |
| 333 | + const synchronizedStatusRef = useRef<TerminalSessionState["status"]>("closed"); | |
| 334 | + const synchronizeTerminalStatus = useEffectEvent( | |
| 335 | + (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"]) => { | |
| 336 | + const synchronized = synchronizedStatusRef.current; | |
| 337 | + if (status === "running") { | |
| 338 | + hasHandledExitRef.current = false; | |
| 339 | + } else if (shouldHandleTerminalExit(status, synchronized, hasHandledExitRef.current)) { | |
| 340 | + hasHandledExitRef.current = true; | |
| 341 | + writeSystemMessage(terminal, status === "closed" ? "Terminal closed" : "Process exited"); | |
| 342 | + window.setTimeout(() => { | |
| 343 | + if (hasHandledExitRef.current) { | |
| 344 | + handleSessionExited(); | |
| 345 | + } | |
| 346 | + }, 0); | |
| 347 | + } | |
| 348 | + synchronizedStatusRef.current = status; | |
| 349 | + }, | |
| 350 | + ); | |
| 370 | 351 | const terminalVersion = terminalSession.version; |
| 371 | 352 | const previousSessionRef = useRef({ |
| 372 | 353 | buffer: terminalBuffer, |
| @@ -374,6 +355,13 @@ export function TerminalViewport({ · 1 unmodified line | ||
| 374 | 355 | status: terminalStatus, |
| 375 | 356 | version: terminalVersion, |
| 376 | 357 | }); |
| 358 | + const latestSessionRef = useRef(previousSessionRef.current); | |
| 359 | + latestSessionRef.current = { | |
| 360 | + buffer: terminalBuffer, | |
| 361 | + error: terminalError, | |
| 362 | + status: terminalStatus, | |
| 363 | + version: terminalVersion, | |
| 364 | + }; | |
| 377 | 365 | |
| 378 | 366 | useEffect(() => { |
| 379 | 367 | keybindingsRef.current = keybindings; |
| @@ -384,356 +372,353 @@ export function TerminalViewport({ · 4 unmodified lines | ||
| 384 | 372 | if (!mount) return; |
| 385 | 373 | |
| 386 | 374 | const localApi = readLocalApi(); |
| 387 | − | |
| 388 | − const fitAddon = new FitAddon(); | |
| 389 | − const terminal = new Terminal({ | |
| 390 | − cursorBlink: true, | |
| 391 | − lineHeight: 1, | |
| 392 | − fontSize: 12, | |
| 393 | − scrollback: 5_000, | |
| 394 | − fontFamily: | |
| 395 | − '"SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace', | |
| 396 | − theme: terminalThemeFromApp(mount), | |
| 397 | − }); | |
| 398 | − terminal.loadAddon(fitAddon); | |
| 399 | − terminal.open(mount); | |
| 400 | − fitTerminalSafely(fitAddon); | |
| 401 | − | |
| 402 | − terminalRef.current = terminal; | |
| 403 | − fitAddonRef.current = fitAddon; | |
| 404 | − previousSessionRef.current = { | |
| 405 | − buffer: "", | |
| 406 | − status: "closed", | |
| 407 | − error: null, | |
| 408 | − version: 0, | |
| 409 | − }; | |
| 410 | − | |
| 411 | − const clearSelectionAction = () => { | |
| 412 | − selectionActionRequestIdRef.current += 1; | |
| 413 | − if (selectionActionTimerRef.current !== null) { | |
| 414 | − window.clearTimeout(selectionActionTimerRef.current); | |
| 415 | − selectionActionTimerRef.current = null; | |
| 416 | − } | |
| 417 | − }; | |
| 418 | − | |
| 419 | − const readSelectionAction = (): { | |
| 420 | − position: { x: number; y: number }; | |
| 421 | − clipboardText: string; | |
| 422 | − selection: TerminalContextSelection; | |
| 423 | − } | null => { | |
| 424 | − const activeTerminal = terminalRef.current; | |
| 425 | − const mountElement = containerRef.current; | |
| 426 | − if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { | |
| 427 | − return null; | |
| 428 | − } | |
| 429 | − const selectionText = activeTerminal.getSelection(); | |
| 430 | − const selectionPosition = activeTerminal.getSelectionPosition(); | |
| 431 | − const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); | |
| 432 | − if (!selectionPosition || normalizedText.length === 0) { | |
| 375 | + let cancelled = false; | |
| 376 | + let teardown: (() => void) | null = null; | |
| 377 | + let setupTerminal: GhosttyTerminalSurface | null = null; | |
| 378 | + let setupCleanups: Array<() => void> = []; | |
| 379 | + | |
| 380 | + const setup = async (): Promise<(() => void) | null> => { | |
| 381 | + const terminalOptions: GhosttyTerminalSurfaceOptions = { | |
| 382 | + theme: terminalThemeFromApp(mount), | |
| 383 | + onData: (data) => handleData(data), | |
| 384 | + onResize: (cols, rows) => void resizeTerminal(cols, rows), | |
| 385 | + onSelectionChange: () => handleSelectionChange(), | |
| 386 | + onCopy: (text) => handleCopy(text), | |
| 387 | + beforeKey: (event) => handleBeforeKey(event), | |
| 388 | + onLinkActivate: (text, event) => handleLinkActivate(text, event), | |
| 389 | + }; | |
| 390 | + const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions); | |
| 391 | + if (cancelled) { | |
| 392 | + terminal.dispose(); | |
| 433 | 393 | return null; |
| 434 | 394 | } |
| 435 | − const lineStart = selectionPosition.start.y + 1; | |
| 436 | − const lineCount = normalizedText.split("\n").length; | |
| 437 | − const lineEnd = Math.max(lineStart, lineStart + lineCount - 1); | |
| 438 | − const bounds = mountElement.getBoundingClientRect(); | |
| 439 | − const selectionRect = getTerminalSelectionRect(mountElement); | |
| 440 | − const position = resolveTerminalSelectionActionPosition({ | |
| 441 | − bounds, | |
| 442 | − selectionRect: | |
| 443 | − selectionRect === null | |
| 444 | − ? null | |
| 445 | − : { right: selectionRect.right, bottom: selectionRect.bottom }, | |
| 446 | − pointer: selectionPointerRef.current, | |
| 447 | − }); | |
| 448 | − return { | |
| 449 | − position, | |
| 450 | − clipboardText: selectionText, | |
| 451 | − selection: { | |
| 452 | − terminalId, | |
| 453 | − terminalLabel: readTerminalLabel(), | |
| 454 | − lineStart, | |
| 455 | − lineEnd, | |
| 456 | − text: normalizedText, | |
| 457 | − }, | |
| 395 | + // The theme observer is not installed yet, so re-read the theme in case | |
| 396 | + // the app toggled light/dark while the WASM surface was loading. | |
| 397 | + terminal.setTheme(terminalThemeFromApp(mount)); | |
| 398 | + setupTerminal = terminal; | |
| 399 | + terminalRef.current = terminal; | |
| 400 | + const latestSession = latestSessionRef.current; | |
| 401 | + previousSessionRef.current = latestSession; | |
| 402 | + if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer); | |
| 403 | + if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error); | |
| 404 | + // Attaching to a session that already exited must still run exit handling | |
| 405 | + // once, so mount synchronization starts from the empty "closed" state. | |
| 406 | + // (A session that is "closed" at mount is indistinguishable from one that | |
| 407 | + // never started, so only "exited" triggers the message — as with xterm.) | |
| 408 | + synchronizedStatusRef.current = "closed"; | |
| 409 | + synchronizeTerminalStatus(terminal, latestSession.status); | |
| 410 | + if (autoFocus) window.requestAnimationFrame(() => terminal.focus()); | |
| 411 | + | |
| 412 | + const clearSelectionAction = () => { | |
| 413 | + selectionActionRequestIdRef.current += 1; | |
| 414 | + if (selectionActionTimerRef.current !== null) { | |
| 415 | + window.clearTimeout(selectionActionTimerRef.current); | |
| 416 | + selectionActionTimerRef.current = null; | |
| 417 | + } | |
| 458 | 418 | }; |
| 459 | − }; | |
| 419 | + setupCleanups.push(clearSelectionAction); | |
| 460 | 420 | |
| 461 | − const showSelectionAction = async () => { | |
| 462 | − if (!localApi) { | |
| 463 | − clearSelectionAction(); | |
| 464 | − return; | |
| 465 | − } | |
| 466 | − if (selectionActionMenuOpenRef.current) { | |
| 467 | − return; | |
| 468 | − } | |
| 469 | − const nextAction = readSelectionAction(); | |
| 470 | − if (!nextAction) { | |
| 471 | − clearSelectionAction(); | |
| 472 | − return; | |
| 473 | − } | |
| 474 | − const requestId = ++selectionActionRequestIdRef.current; | |
| 475 | − selectionActionMenuOpenRef.current = true; | |
| 476 | − const clicked = await localApi.contextMenu | |
| 477 | − .show( | |
| 478 | − [ | |
| 479 | − { id: "add-to-chat", label: "Add to chat" }, | |
| 480 | − { id: "copy", label: "Copy" }, | |
| 481 | − ], | |
| 482 | − nextAction.position, | |
| 483 | − ) | |
| 484 | − .finally(() => { | |
| 485 | − selectionActionMenuOpenRef.current = false; | |
| 421 | + const readSelectionAction = (): { | |
| 422 | + position: { x: number; y: number }; | |
| 423 | + clipboardText: string; | |
| 424 | + selection: TerminalContextSelection; | |
| 425 | + } | null => { | |
| 426 | + const activeTerminal = terminalRef.current; | |
| 427 | + const mountElement = containerRef.current; | |
| 428 | + if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { | |
| 429 | + return null; | |
| 430 | + } | |
| 431 | + const selectionText = activeTerminal.getSelection(); | |
| 432 | + const selectionPosition = activeTerminal.getSelectionPosition(); | |
| 433 | + const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); | |
| 434 | + if (!selectionPosition || normalizedText.length === 0) { | |
| 435 | + return null; | |
| 436 | + } | |
| 437 | + const { lineStart, lineEnd } = terminalSelectionLineRange(selectionPosition); | |
| 438 | + const bounds = mountElement.getBoundingClientRect(); | |
| 439 | + const position = resolveTerminalSelectionActionPosition({ | |
| 440 | + bounds, | |
| 441 | + selectionRect: activeTerminal.getSelectionEndClientRect(), | |
| 442 | + pointer: selectionPointerRef.current, | |
| 486 | 443 | }); |
| 487 | − if (requestId !== selectionActionRequestIdRef.current || clicked === null) { | |
| 488 | − return; | |
| 489 | − } | |
| 490 | − switch (clicked) { | |
| 491 | − case "add-to-chat": | |
| 492 | − handleAddTerminalContext(nextAction.selection); | |
| 493 | − terminalRef.current?.clearSelection(); | |
| 494 | − terminalRef.current?.focus(); | |
| 444 | + return { | |
| 445 | + position, | |
| 446 | + clipboardText: selectionText, | |
| 447 | + selection: { | |
| 448 | + terminalId, | |
| 449 | + terminalLabel: readTerminalLabel(), | |
| 450 | + lineStart, | |
| 451 | + lineEnd, | |
| 452 | + text: normalizedText, | |
| 453 | + }, | |
| 454 | + }; | |
| 455 | + }; | |
| 456 | + | |
| 457 | + const showSelectionAction = async () => { | |
| 458 | + if (!localApi) { | |
| 459 | + clearSelectionAction(); | |
| 495 | 460 | return; |
| 496 | − case "copy": | |
| 497 | − try { | |
| 498 | − await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); | |
| 499 | − } catch (error) { | |
| 500 | − if (requestId !== selectionActionRequestIdRef.current) { | |
| 501 | − return; | |
| 461 | + } | |
| 462 | + if (selectionActionMenuOpenRef.current) { | |
| 463 | + return; | |
| 464 | + } | |
| 465 | + const nextAction = readSelectionAction(); | |
| 466 | + if (!nextAction) { | |
| 467 | + clearSelectionAction(); | |
| 468 | + return; | |
| 469 | + } | |
| 470 | + const requestId = ++selectionActionRequestIdRef.current; | |
| 471 | + selectionActionMenuOpenRef.current = true; | |
| 472 | + const clicked = await localApi.contextMenu | |
| 473 | + .show( | |
| 474 | + [ | |
| 475 | + { id: "add-to-chat", label: "Add to chat" }, | |
| 476 | + { id: "copy", label: "Copy" }, | |
| 477 | + ], | |
| 478 | + nextAction.position, | |
| 479 | + ) | |
| 480 | + .finally(() => { | |
| 481 | + selectionActionMenuOpenRef.current = false; | |
| 482 | + }); | |
| 483 | + if (requestId !== selectionActionRequestIdRef.current || clicked === null) { | |
| 484 | + return; | |
| 485 | + } | |
| 486 | + switch (clicked) { | |
| 487 | + case "add-to-chat": | |
| 488 | + handleAddTerminalContext(nextAction.selection); | |
| 489 | + terminalRef.current?.clearSelection(); | |
| 490 | + terminalRef.current?.focus(); | |
| 491 | + return; | |
| 492 | + case "copy": | |
| 493 | + try { | |
| 494 | + await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); | |
| 495 | + } catch (error) { | |
| 496 | + if (requestId !== selectionActionRequestIdRef.current) { | |
| 497 | + return; | |
| 498 | + } | |
| 499 | + const activeTerminal = terminalRef.current; | |
| 500 | + if (activeTerminal) { | |
| 501 | + writeSystemMessage( | |
| 502 | + activeTerminal, | |
| 503 | + error instanceof Error ? error.message : "Unable to copy terminal selection", | |
| 504 | + ); | |
| 505 | + } | |
| 502 | 506 | } |
| 503 | − const activeTerminal = terminalRef.current; | |
| 504 | − if (activeTerminal) { | |
| 505 | − writeSystemMessage( | |
| 506 | − activeTerminal, | |
| 507 | − error instanceof Error ? error.message : "Unable to copy terminal selection", | |
| 508 | − ); | |
| 507 | + if (requestId === selectionActionRequestIdRef.current) { | |
| 508 | + terminalRef.current?.focus(); | |
| 509 | 509 | } |
| 510 | − } | |
| 511 | − if (requestId === selectionActionRequestIdRef.current) { | |
| 512 | − terminalRef.current?.focus(); | |
| 513 | − } | |
| 514 | − return; | |
| 515 | − } | |
| 516 | − }; | |
| 510 | + return; | |
| 511 | + } | |
| 512 | + }; | |
| 517 | 513 | |
| 518 | − const sendTerminalInput = async (data: string, fallbackError: string) => { | |
| 519 | − const activeTerminal = terminalRef.current; | |
| 520 | − if (!activeTerminal) return; | |
| 521 | − const result = await writeTerminal(data); | |
| 522 | − if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 523 | − const error = squashAtomCommandFailure(result); | |
| 524 | − writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); | |
| 525 | − } | |
| 526 | − }; | |
| 514 | + const sendTerminalInput = async (data: string, fallbackError: string) => { | |
| 515 | + const activeTerminal = terminalRef.current; | |
| 516 | + if (!activeTerminal) return; | |
| 517 | + const result = await writeTerminal(data); | |
| 518 | + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 519 | + const error = squashAtomCommandFailure(result); | |
| 520 | + writeSystemMessage( | |
| 521 | + activeTerminal, | |
| 522 | + error instanceof Error ? error.message : fallbackError, | |
| 523 | + ); | |
| 524 | + } | |
| 525 | + }; | |
| 527 | 526 | |
| 528 | − terminal.attachCustomKeyEventHandler((event) => { | |
| 529 | − const currentKeybindings = keybindingsRef.current; | |
| 530 | − const options = { context: { terminalFocus: true, terminalOpen: true } }; | |
| 531 | − if ( | |
| 532 | − isTerminalToggleShortcut(event, currentKeybindings, options) || | |
| 533 | − isTerminalSplitShortcut(event, currentKeybindings, options) || | |
| 534 | − isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || | |
| 535 | − isTerminalNewShortcut(event, currentKeybindings, options) || | |
| 536 | − isTerminalCloseShortcut(event, currentKeybindings, options) || | |
| 537 | − isDiffToggleShortcut(event, currentKeybindings, options) | |
| 538 | − ) { | |
| 539 | − return false; | |
| 540 | − } | |
| 527 | + function handleBeforeKey(event: KeyboardEvent): boolean { | |
| 528 | + const currentKeybindings = keybindingsRef.current; | |
| 529 | + const options = { context: { terminalFocus: true, terminalOpen: true } }; | |
| 530 | + if ( | |
| 531 | + isTerminalToggleShortcut(event, currentKeybindings, options) || | |
| 532 | + isTerminalSplitShortcut(event, currentKeybindings, options) || | |
| 533 | + isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || | |
| 534 | + isTerminalNewShortcut(event, currentKeybindings, options) || | |
| 535 | + isTerminalCloseShortcut(event, currentKeybindings, options) || | |
| 536 | + isDiffToggleShortcut(event, currentKeybindings, options) | |
| 537 | + ) { | |
| 538 | + return false; | |
| 539 | + } | |
| 541 | 540 | |
| 542 | − const navigationData = terminalNavigationShortcutData(event); | |
| 543 | − if (navigationData !== null) { | |
| 544 | − event.preventDefault(); | |
| 545 | − event.stopPropagation(); | |
| 546 | − void sendTerminalInput(navigationData, "Failed to move cursor"); | |
| 547 | − return false; | |
| 548 | − } | |
| 541 | + const navigationData = terminalNavigationShortcutData(event); | |
| 542 | + if (navigationData !== null) { | |
| 543 | + event.preventDefault(); | |
| 544 | + event.stopPropagation(); | |
| 545 | + void sendTerminalInput(navigationData, "Failed to move cursor"); | |
| 546 | + return false; | |
| 547 | + } | |
| 548 | + | |
| 549 | + const deleteData = terminalDeleteShortcutData(event); | |
| 550 | + if (deleteData !== null) { | |
| 551 | + event.preventDefault(); | |
| 552 | + event.stopPropagation(); | |
| 553 | + void sendTerminalInput(deleteData, "Failed to delete terminal input"); | |
| 554 | + return false; | |
| 555 | + } | |
| 549 | 556 | |
| 550 | − const deleteData = terminalDeleteShortcutData(event); | |
| 551 | − if (deleteData !== null) { | |
| 557 | + if (!isTerminalClearShortcut(event)) return true; | |
| 552 | 558 | event.preventDefault(); |
| 553 | 559 | event.stopPropagation(); |
| 554 | − void sendTerminalInput(deleteData, "Failed to delete terminal input"); | |
| 560 | + void sendTerminalInput("\u000c", "Failed to clear terminal"); | |
| 555 | 561 | return false; |
| 556 | 562 | } |
| 557 | 563 | |
| 558 | − if (!isTerminalClearShortcut(event)) return true; | |
| 559 | − event.preventDefault(); | |
| 560 | − event.stopPropagation(); | |
| 561 | − void sendTerminalInput("\u000c", "Failed to clear terminal"); | |
| 562 | − return false; | |
| 563 | − }); | |
| 564 | − | |
| 565 | − const terminalLinksDisposable = terminal.registerLinkProvider({ | |
| 566 | − provideLinks: (bufferLineNumber, callback) => { | |
| 567 | − const activeTerminal = terminalRef.current; | |
| 568 | − if (!activeTerminal) { | |
| 569 | − callback(undefined); | |
| 564 | + function handleLinkActivate(text: string, event: MouseEvent): void { | |
| 565 | + if (!isTerminalLinkActivation(event)) return; | |
| 566 | + const latestTerminal = terminalRef.current; | |
| 567 | + if (!latestTerminal) return; | |
| 568 | + if (/^https?:\/\//u.test(text)) { | |
| 569 | + if (!localApi) { | |
| 570 | + writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); | |
| 571 | + return; | |
| 572 | + } | |
| 573 | + const fallbackToBrowser = () => { | |
| 574 | + void localApi.shell.openExternal(text).catch((error: unknown) => { | |
| 575 | + writeSystemMessage( | |
| 576 | + latestTerminal, | |
| 577 | + error instanceof Error ? error.message : "Unable to open link", | |
| 578 | + ); | |
| 579 | + }); | |
| 580 | + }; | |
| 581 | + void openTerminalLinkInPreview({ | |
| 582 | + url: text, | |
| 583 | + position: { x: event.clientX, y: event.clientY }, | |
| 584 | + threadRef, | |
| 585 | + openPreview, | |
| 586 | + localApi, | |
| 587 | + fallbackToBrowser, | |
| 588 | + }); | |
| 570 | 589 | return; |
| 571 | 590 | } |
| 591 | + const target = resolvePathLinkTarget(text, cwd); | |
| 592 | + void (async () => { | |
| 593 | + const result = await openTerminalPath(target); | |
| 594 | + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { | |
| 595 | + return; | |
| 596 | + } | |
| 597 | + const error = squashAtomCommandFailure(result); | |
| 598 | + writeSystemMessage( | |
| 599 | + latestTerminal, | |
| 600 | + error instanceof Error ? error.message : "Unable to open path", | |
| 601 | + ); | |
| 602 | + })(); | |
| 603 | + } | |
| 572 | 604 | |
| 573 | − const wrappedLine = collectWrappedTerminalLinkLine(bufferLineNumber, (bufferLineIndex) => | |
| 574 | − activeTerminal.buffer.active.getLine(bufferLineIndex), | |
| 575 | − ); | |
| 576 | − if (!wrappedLine) { | |
| 577 | − callback(undefined); | |
| 578 | − return; | |
| 579 | − } | |
| 605 | + function handleCopy(text: string): void { | |
| 606 | + void writeTextToClipboard(text, "terminal selection").catch((error: unknown) => { | |
| 607 | + const activeTerminal = terminalRef.current; | |
| 608 | + if (!activeTerminal) return; | |
| 609 | + writeSystemMessage( | |
| 610 | + activeTerminal, | |
| 611 | + error instanceof Error ? error.message : "Unable to copy terminal selection", | |
| 612 | + ); | |
| 613 | + }); | |
| 614 | + } | |
| 580 | 615 | |
| 581 | − const links = extractTerminalLinks(wrappedLine.text) | |
| 582 | − .map((match) => ({ | |
| 583 | − match, | |
| 584 | − range: resolveWrappedTerminalLinkRange(wrappedLine, match), | |
| 585 | − })) | |
| 586 | − .filter(({ range }) => | |
| 587 | − wrappedTerminalLinkRangeIntersectsBufferLine(range, bufferLineNumber), | |
| 616 | + function handleData(data: string): void { | |
| 617 | + void (async () => { | |
| 618 | + const result = await writeTerminal(data); | |
| 619 | + if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; | |
| 620 | + const error = squashAtomCommandFailure(result); | |
| 621 | + writeSystemMessage( | |
| 622 | + terminal, | |
| 623 | + error instanceof Error ? error.message : "Terminal write failed", | |
| 588 | 624 | ); |
| 589 | − if (links.length === 0) { | |
| 590 | − callback(undefined); | |
| 625 | + })(); | |
| 626 | + } | |
| 627 | + | |
| 628 | + function handleSelectionChange(): void { | |
| 629 | + if (terminalRef.current?.hasSelection()) { | |
| 591 | 630 | return; |
| 592 | 631 | } |
| 632 | + clearSelectionAction(); | |
| 633 | + } | |
| 593 | 634 | |
| 594 | − callback( | |
| 595 | − links.map(({ match, range }) => ({ | |
| 596 | − text: match.text, | |
| 597 | − range, | |
| 598 | − activate: (event: MouseEvent) => { | |
| 599 | − if (!isTerminalLinkActivation(event)) return; | |
| 600 | − | |
| 601 | − const latestTerminal = terminalRef.current; | |
| 602 | − if (!latestTerminal) return; | |
| 603 | − | |
| 604 | − if (match.kind === "url") { | |
| 605 | − if (!localApi) { | |
| 606 | − writeSystemMessage( | |
| 607 | − latestTerminal, | |
| 608 | − "Opening links is unavailable in this browser.", | |
| 609 | − ); | |
| 610 | − return; | |
| 611 | − } | |
| 612 | − const fallbackToBrowser = () => { | |
| 613 | − void localApi.shell.openExternal(match.text).catch((error: unknown) => { | |
| 614 | − writeSystemMessage( | |
| 615 | − latestTerminal, | |
| 616 | − error instanceof Error ? error.message : "Unable to open link", | |
| 617 | − ); | |
| 618 | − }); | |
| 619 | − }; | |
| 620 | − void openTerminalLinkInPreview({ | |
| 621 | − url: match.text, | |
| 622 | − position: { x: event.clientX, y: event.clientY }, | |
| 623 | − threadRef, | |
| 624 | − openPreview, | |
| 625 | − localApi, | |
| 626 | − fallbackToBrowser, | |
| 627 | − }); | |
| 628 | − return; | |
| 629 | − } | |
| 630 | − | |
| 631 | − const target = resolvePathLinkTarget(match.text, cwd); | |
| 632 | − void (async () => { | |
| 633 | − const result = await openTerminalPath(target); | |
| 634 | − if (result._tag === "Success" || isAtomCommandInterrupted(result)) { | |
| 635 | − return; | |
| 636 | − } | |
| 637 | − const error = squashAtomCommandFailure(result); | |
| 638 | − writeSystemMessage( | |
| 639 | − latestTerminal, | |
| 640 | − error instanceof Error ? error.message : "Unable to open path", | |
| 641 | − ); | |
| 642 | − })(); | |
| 643 | − }, | |
| 644 | − })), | |
| 635 | + const handleMouseUp = (event: MouseEvent) => { | |
| 636 | + const shouldHandle = shouldHandleTerminalSelectionMouseUp( | |
| 637 | + selectionGestureActiveRef.current, | |
| 638 | + event.button, | |
| 645 | 639 | ); |
| 646 | − }, | |
| 647 | − }); | |
| 648 | − | |
| 649 | − const inputDisposable = terminal.onData((data) => { | |
| 650 | − void (async () => { | |
| 651 | − const result = await writeTerminal(data); | |
| 652 | − if (result._tag === "Success" || isAtomCommandInterrupted(result)) { | |
| 640 | + selectionGestureActiveRef.current = false; | |
| 641 | + if (!shouldHandle) { | |
| 653 | 642 | return; |
| 654 | 643 | } |
| 655 | − const error = squashAtomCommandFailure(result); | |
| 656 | − writeSystemMessage( | |
| 657 | − terminal, | |
| 658 | − error instanceof Error ? error.message : "Terminal write failed", | |
| 659 | − ); | |
| 660 | − })(); | |
| 661 | − }); | |
| 644 | + selectionPointerRef.current = { x: event.clientX, y: event.clientY }; | |
| 645 | + const delay = terminalSelectionActionDelayForClickCount(event.detail); | |
| 646 | + selectionActionTimerRef.current = window.setTimeout(() => { | |
| 647 | + selectionActionTimerRef.current = null; | |
| 648 | + window.requestAnimationFrame(() => { | |
| 649 | + void showSelectionAction(); | |
| 650 | + }); | |
| 651 | + }, delay); | |
| 652 | + }; | |
| 653 | + const handlePointerDown = (event: PointerEvent) => { | |
| 654 | + clearSelectionAction(); | |
| 655 | + selectionGestureActiveRef.current = event.button === 0; | |
| 656 | + }; | |
| 657 | + window.addEventListener("mouseup", handleMouseUp); | |
| 658 | + mount.addEventListener("pointerdown", handlePointerDown); | |
| 659 | + setupCleanups.push(() => { | |
| 660 | + window.removeEventListener("mouseup", handleMouseUp); | |
| 661 | + mount.removeEventListener("pointerdown", handlePointerDown); | |
| 662 | + }); | |
| 662 | 663 | |
| 663 | − const selectionDisposable = terminal.onSelectionChange(() => { | |
| 664 | − if (terminalRef.current?.hasSelection()) { | |
| 665 | − return; | |
| 666 | − } | |
| 667 | − clearSelectionAction(); | |
| 668 | − }); | |
| 664 | + const themeObserver = new MutationObserver(() => { | |
| 665 | + const activeTerminal = terminalRef.current; | |
| 666 | + if (!activeTerminal) return; | |
| 667 | + activeTerminal.setTheme(terminalThemeFromApp(containerRef.current)); | |
| 668 | + }); | |
| 669 | + themeObserver.observe(document.documentElement, { | |
| 670 | + attributes: true, | |
| 671 | + attributeFilter: ["class", "style"], | |
| 672 | + }); | |
| 673 | + setupCleanups.push(() => themeObserver.disconnect()); | |
| 669 | 674 | |
| 670 | − const handleMouseUp = (event: MouseEvent) => { | |
| 671 | − const shouldHandle = shouldHandleTerminalSelectionMouseUp( | |
| 672 | − selectionGestureActiveRef.current, | |
| 673 | − event.button, | |
| 674 | − ); | |
| 675 | − selectionGestureActiveRef.current = false; | |
| 676 | − if (!shouldHandle) { | |
| 677 | − return; | |
| 678 | − } | |
| 679 | − selectionPointerRef.current = { x: event.clientX, y: event.clientY }; | |
| 680 | − const delay = terminalSelectionActionDelayForClickCount(event.detail); | |
| 681 | − selectionActionTimerRef.current = window.setTimeout(() => { | |
| 682 | − selectionActionTimerRef.current = null; | |
| 683 | − window.requestAnimationFrame(() => { | |
| 684 | − void showSelectionAction(); | |
| 685 | − }); | |
| 686 | − }, delay); | |
| 687 | − }; | |
| 688 | − const handlePointerDown = (event: PointerEvent) => { | |
| 689 | − clearSelectionAction(); | |
| 690 | − selectionGestureActiveRef.current = event.button === 0; | |
| 675 | + const fitTimer = window.setTimeout(() => { | |
| 676 | + const activeTerminal = terminalRef.current; | |
| 677 | + if (!activeTerminal) return; | |
| 678 | + const wasAtBottom = activeTerminal.isAtBottom(); | |
| 679 | + activeTerminal.fit(); | |
| 680 | + if (wasAtBottom) { | |
| 681 | + activeTerminal.scrollToBottom(); | |
| 682 | + } | |
| 683 | + }, 30); | |
| 684 | + setupCleanups.push(() => window.clearTimeout(fitTimer)); | |
| 685 | + | |
| 686 | + const cleanups = setupCleanups; | |
| 687 | + setupCleanups = []; | |
| 688 | + setupTerminal = null; | |
| 689 | + return () => { | |
| 690 | + for (const cleanup of cleanups.toReversed()) cleanup(); | |
| 691 | + if (terminalRef.current === terminal) terminalRef.current = null; | |
| 692 | + terminal.dispose(); | |
| 693 | + }; | |
| 691 | 694 | }; |
| 692 | − window.addEventListener("mouseup", handleMouseUp); | |
| 693 | − mount.addEventListener("pointerdown", handlePointerDown); | |
| 694 | − | |
| 695 | − const themeObserver = new MutationObserver(() => { | |
| 696 | − const activeTerminal = terminalRef.current; | |
| 697 | − if (!activeTerminal) return; | |
| 698 | − activeTerminal.options.theme = terminalThemeFromApp(containerRef.current); | |
| 699 | − activeTerminal.refresh(0, activeTerminal.rows - 1); | |
| 700 | − }); | |
| 701 | − themeObserver.observe(document.documentElement, { | |
| 702 | − attributes: true, | |
| 703 | − attributeFilter: ["class", "style"], | |
| 704 | − }); | |
| 705 | 695 | |
| 706 | − const fitTimer = window.setTimeout(() => { | |
| 707 | − const activeTerminal = terminalRef.current; | |
| 708 | − const activeFitAddon = fitAddonRef.current; | |
| 709 | − if (!activeTerminal || !activeFitAddon) return; | |
| 710 | − const wasAtBottom = | |
| 711 | − activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; | |
| 712 | − fitTerminalSafely(activeFitAddon); | |
| 713 | − if (wasAtBottom) { | |
| 714 | − activeTerminal.scrollToBottom(); | |
| 715 | − } | |
| 716 | − void resizeTerminal(activeTerminal.cols, activeTerminal.rows); | |
| 717 | − }, 30); | |
| 696 | + void setup() | |
| 697 | + .then((nextTeardown) => { | |
| 698 | + if (cancelled) { | |
| 699 | + nextTeardown?.(); | |
| 700 | + return; | |
| 701 | + } | |
| 702 | + teardown = nextTeardown; | |
| 703 | + }) | |
| 704 | + .catch((error: unknown) => { | |
| 705 | + for (const cleanup of setupCleanups.toReversed()) cleanup(); | |
| 706 | + setupCleanups = []; | |
| 707 | + if (terminalRef.current === setupTerminal) terminalRef.current = null; | |
| 708 | + setupTerminal?.dispose(); | |
| 709 | + setupTerminal = null; | |
| 710 | + if (cancelled) return; | |
| 711 | + const message = | |
| 712 | + error instanceof Error ? error.message : "Unable to initialize libghostty-vt"; | |
| 713 | + mount.textContent = `${message} — close and reopen the terminal to retry.`; | |
| 714 | + }); | |
| 718 | 715 | |
| 719 | 716 | return () => { |
| 720 | − window.clearTimeout(fitTimer); | |
| 721 | − inputDisposable.dispose(); | |
| 722 | − selectionDisposable.dispose(); | |
| 723 | − terminalLinksDisposable.dispose(); | |
| 724 | − if (selectionActionTimerRef.current !== null) { | |
| 725 | − window.clearTimeout(selectionActionTimerRef.current); | |
| 726 | − } | |
| 727 | − window.removeEventListener("mouseup", handleMouseUp); | |
| 728 | − mount.removeEventListener("pointerdown", handlePointerDown); | |
| 729 | − themeObserver.disconnect(); | |
| 730 | − terminalRef.current = null; | |
| 731 | − fitAddonRef.current = null; | |
| 732 | − terminal.dispose(); | |
| 717 | + cancelled = true; | |
| 718 | + teardown?.(); | |
| 733 | 719 | }; |
| 734 | 720 | // autoFocus is intentionally omitted; |
| 735 | 721 | // it is only read at mount time and must not trigger terminal teardown/recreation. |
| 736 | − // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 737 | 722 | }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); |
| 738 | 723 | |
| 739 | 724 | useEffect(() => { |
| @@ -750,6 +735,7 @@ export function TerminalViewport({ · 10 unmodified lines | ||
| 750 | 735 | } |
| 751 | 736 | |
| 752 | 737 | const previous = previousSessionRef.current; |
| 738 | + synchronizeTerminalStatus(terminal, current.status); | |
| 753 | 739 | if (current.version === previous.version) { |
| 754 | 740 | return; |
| 755 | 741 | } |
| @@ -768,25 +754,6 @@ export function TerminalViewport({ · 12 unmodified lines | ||
| 768 | 754 | writeSystemMessage(terminal, current.error); |
| 769 | 755 | } |
| 770 | 756 | |
| 771 | − if (current.status === "running") { | |
| 772 | − hasHandledExitRef.current = false; | |
| 773 | − } else if ( | |
| 774 | − (current.status === "closed" || current.status === "exited") && | |
| 775 | − current.status !== previous.status && | |
| 776 | − !hasHandledExitRef.current | |
| 777 | − ) { | |
| 778 | − hasHandledExitRef.current = true; | |
| 779 | − writeSystemMessage( | |
| 780 | − terminal, | |
| 781 | − current.status === "closed" ? "Terminal closed" : "Process exited", | |
| 782 | − ); | |
| 783 | − window.setTimeout(() => { | |
| 784 | − if (hasHandledExitRef.current) { | |
| 785 | − handleSessionExited(); | |
| 786 | − } | |
| 787 | − }, 0); | |
| 788 | − } | |
| 789 | − | |
| 790 | 757 | if (previous.version === 0 && autoFocus) { |
| 791 | 758 | window.requestAnimationFrame(() => { |
| 792 | 759 | terminal.focus(); |
| @@ -809,15 +776,15 @@ export function TerminalViewport({ · 16 unmodified lines | ||
| 809 | 776 | |
| 810 | 777 | useEffect(() => { |
| 811 | 778 | const terminal = terminalRef.current; |
| 812 | − const fitAddon = fitAddonRef.current; | |
| 813 | − if (!terminal || !fitAddon) return; | |
| 814 | − const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; | |
| 779 | + if (!terminal) return; | |
| 780 | + const wasAtBottom = terminal.isAtBottom(); | |
| 781 | + // The surface reports grid changes through onResize, which is the single | |
| 782 | + // channel for PTY resize RPCs; fitting here only refreshes the layout. | |
| 815 | 783 | const frame = window.requestAnimationFrame(() => { |
| 816 | − fitTerminalSafely(fitAddon); | |
| 784 | + terminal.fit(); | |
| 817 | 785 | if (wasAtBottom) { |
| 818 | 786 | terminal.scrollToBottom(); |
| 819 | 787 | } |
| 820 | − void resizeTerminal(terminal.cols, terminal.rows); | |
| 821 | 788 | }); |
| 822 | 789 | return () => { |
| 823 | 790 | window.cancelAnimationFrame(frame); |
| @@ -1276,7 +1243,7 @@ export default function ThreadTerminalDrawer({ · 452 unmodified lines | ||
| 1276 | 1243 | |
| 1277 | 1244 | {!hasTerminalSidebar && ( |
| 1278 | 1245 | <div className="pointer-events-none absolute right-2 top-2 z-20"> |
| 1279 | − <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background/70"> | |
| 1246 | + <div className="pointer-events-auto inline-flex items-center overflow-hidden rounded-md border border-border/80 bg-background shadow-xs"> | |
| 1280 | 1247 | <TerminalActionButton |
| 1281 | 1248 | className={`p-1 text-foreground/90 transition-colors ${ |
| 1282 | 1249 | hasReachedSplitLimit |
| @@ -1049,6 +1049,36 @@ code { · 1048 unmodified lines | ||
| 1049 | 1049 | background: var(--app-scrollbar-thumb-hover); |
| 1050 | 1050 | } |
| 1051 | 1051 | |
| 1052 | +.t3-ghostty-canvas { | |
| 1053 | + cursor: text; | |
| 1054 | +} | |
| 1055 | + | |
| 1056 | +.t3-ghostty-scrollbar { | |
| 1057 | + position: absolute; | |
| 1058 | + z-index: 1; | |
| 1059 | + top: 4px; | |
| 1060 | + right: 1px; | |
| 1061 | + bottom: 4px; | |
| 1062 | + width: var(--app-scrollbar-width); | |
| 1063 | + cursor: default; | |
| 1064 | + touch-action: none; | |
| 1065 | +} | |
| 1066 | + | |
| 1067 | +.t3-ghostty-scrollbar-thumb { | |
| 1068 | + position: absolute; | |
| 1069 | + top: 0; | |
| 1070 | + right: 1px; | |
| 1071 | + left: 1px; | |
| 1072 | + border-radius: 3px; | |
| 1073 | + background: var(--app-scrollbar-thumb); | |
| 1074 | + transition: background-color 120ms ease-out; | |
| 1075 | +} | |
| 1076 | + | |
| 1077 | +.t3-ghostty-scrollbar:hover .t3-ghostty-scrollbar-thumb, | |
| 1078 | +.t3-ghostty-scrollbar:focus-visible .t3-ghostty-scrollbar-thumb { | |
| 1079 | + background: var(--app-scrollbar-thumb-hover); | |
| 1080 | +} | |
| 1081 | + | |
| 1052 | 1082 | .model-picker-list::-webkit-scrollbar-track { |
| 1053 | 1083 | margin-block: 0.5rem; |
| 1054 | 1084 | } |
| @@ -1101,20 +1131,6 @@ code { · 46 unmodified lines | ||
| 1101 | 1131 | display: none; |
| 1102 | 1132 | } |
| 1103 | 1133 | |
| 1104 | −/* Terminal drawer scrollbar parity with chat */ | |
| 1105 | −.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical { | |
| 1106 | − width: 6px !important; | |
| 1107 | −} | |
| 1108 | − | |
| 1109 | −.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar > .slider { | |
| 1110 | − border-radius: 3px; | |
| 1111 | −} | |
| 1112 | − | |
| 1113 | −.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical > .slider { | |
| 1114 | − width: 6px !important; | |
| 1115 | − left: 0 !important; | |
| 1116 | −} | |
| 1117 | − | |
| 1118 | 1134 | /* Reasoning select -- clickable label surface */ |
| 1119 | 1135 | label:has(> select#reasoning-effort) { |
| 1120 | 1136 | position: relative; |
| @@ -8,7 +8,6 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" · 7 unmodified lines | ||
| 8 | 8 | import "@fontsource-variable/dm-sans/index.css"; |
| 9 | 9 | import "@fontsource/jetbrains-mono/400.css"; |
| 10 | 10 | import "@fontsource/jetbrains-mono/500.css"; |
| 11 | −import "@xterm/xterm/css/xterm.css"; | |
| 12 | 11 | import "./index.css"; |
| 13 | 12 | |
| 14 | 13 | import { isElectron } from "./env"; |
| @@ -0,0 +1,19 @@ | ||
| 1 | +# Ghostty web terminal | |
| 2 | + | |
| 3 | +This directory is the browser adapter for the same official `libghostty-vt` C ABI used by Android. | |
| 4 | +It is intentionally not an xterm compatibility layer. | |
| 5 | + | |
| 6 | +- `runtime.ts` owns the singleton WebAssembly instance and runtime ABI layouts. | |
| 7 | +- `ghostty-write-pty.wasm` is a 112-byte callback trampoline for terminal-generated PTY replies. | |
| 8 | +- `core.ts` owns per-terminal Ghostty handles and translates the C ABI into render snapshots. | |
| 9 | +- `renderer.ts` batches backgrounds and style runs into a Canvas 2D frame. | |
| 10 | +- `surface.ts` owns browser input, IME, selection, scrolling, sizing, links, and cursor blinking. | |
| 11 | +- `fonts/` vendors the symbols-only Nerd Font (MIT) the surface registers lazily, so | |
| 12 | + prompt glyphs render without a locally installed Nerd Font. | |
| 13 | +- `vendor/` holds only the artifacts, reproducibly generated by | |
| 14 | + `apps/web/scripts/build-libghostty-wasm.sh`. The upstream pin and license live once, at | |
| 15 | + `apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/`; the wasm embeds the pinned revision | |
| 16 | + in its build info and the ABI test verifies it against mobile's `VERSION`. | |
| 17 | + | |
| 18 | +Keep browser behavior here and terminal transport in the existing client runtime. Do not add React | |
| 19 | +state to the render loop. Both WASM artifacts are ordinary read-only assets, not executables. | |
| @@ -0,0 +1,1189 @@ | ||
| 1 | +import { | |
| 2 | + type GhosttyKeyboardLayoutMap, | |
| 3 | + ghosttyKeyForCode, | |
| 4 | + ghosttyUnshiftedCodepoint, | |
| 5 | + loadGhosttyKeyboardLayoutMap, | |
| 6 | +} from "./keyCodes"; | |
| 7 | +import { GhosttyRuntime, loadGhosttyRuntime } from "./runtime"; | |
| 8 | + | |
| 9 | +const GHOSTTY_SUCCESS = 0; | |
| 10 | +const GHOSTTY_OUT_OF_SPACE = -3; | |
| 11 | +const MAX_SCROLLBACK_ROWS = 10_000; | |
| 12 | +// wasm32 C ABI layout for GhosttyTerminalSelectionFormatOptions at the | |
| 13 | +// libghostty-vt revision pinned alongside this module. | |
| 14 | +const SELECTION_FORMAT_OPTIONS_SIZE = 16; | |
| 15 | + | |
| 16 | +const RENDER_DATA = { | |
| 17 | + cols: 1, | |
| 18 | + rows: 2, | |
| 19 | + dirty: 3, | |
| 20 | + rowIterator: 4, | |
| 21 | + background: 5, | |
| 22 | + foreground: 6, | |
| 23 | + cursor: 7, | |
| 24 | + cursorHasValue: 8, | |
| 25 | + cursorStyle: 10, | |
| 26 | + cursorVisible: 11, | |
| 27 | + cursorBlinking: 12, | |
| 28 | + cursorInViewport: 14, | |
| 29 | + cursorX: 15, | |
| 30 | + cursorY: 16, | |
| 31 | +} as const; | |
| 32 | + | |
| 33 | +const ROW_DATA = { | |
| 34 | + dirty: 1, | |
| 35 | + raw: 2, | |
| 36 | + cells: 3, | |
| 37 | +} as const; | |
| 38 | + | |
| 39 | +const CELL_DATA = { | |
| 40 | + raw: 1, | |
| 41 | + style: 2, | |
| 42 | + graphemesLength: 3, | |
| 43 | + graphemes: 4, | |
| 44 | + background: 5, | |
| 45 | + foreground: 6, | |
| 46 | + selected: 7, | |
| 47 | +} as const; | |
| 48 | + | |
| 49 | +const RAW_CELL_DATA = { | |
| 50 | + wide: 3, | |
| 51 | +} as const; | |
| 52 | + | |
| 53 | +export const GHOSTTY_CELL_WIDE = { | |
| 54 | + narrow: 0, | |
| 55 | + wide: 1, | |
| 56 | + spacerTail: 2, | |
| 57 | + spacerHead: 3, | |
| 58 | +} as const; | |
| 59 | + | |
| 60 | +export interface GhosttyColor { | |
| 61 | + readonly r: number; | |
| 62 | + readonly g: number; | |
| 63 | + readonly b: number; | |
| 64 | +} | |
| 65 | + | |
| 66 | +export interface GhosttyTheme { | |
| 67 | + readonly foreground: GhosttyColor; | |
| 68 | + readonly background: GhosttyColor; | |
| 69 | + readonly cursor: GhosttyColor; | |
| 70 | + /** CSS color the renderer overlays on selected cells; not sent to Ghostty. */ | |
| 71 | + readonly selectionBackground?: string; | |
| 72 | +} | |
| 73 | + | |
| 74 | +export interface GhosttyCell { | |
| 75 | + readonly text: string; | |
| 76 | + readonly wide: number; | |
| 77 | + readonly foreground: GhosttyColor; | |
| 78 | + readonly background: GhosttyColor; | |
| 79 | + readonly bold: boolean; | |
| 80 | + readonly italic: boolean; | |
| 81 | + readonly invisible: boolean; | |
| 82 | + readonly strikethrough: boolean; | |
| 83 | + readonly overline: boolean; | |
| 84 | + readonly underline: boolean; | |
| 85 | + readonly selected: boolean; | |
| 86 | +} | |
| 87 | + | |
| 88 | +export interface GhosttyRow { | |
| 89 | + readonly cells: readonly GhosttyCell[]; | |
| 90 | + readonly text: string; | |
| 91 | + readonly isWrapContinuation: boolean; | |
| 92 | + /** Whether this row soft-wraps onto the next row. */ | |
| 93 | + readonly wrapsToNext: boolean; | |
| 94 | +} | |
| 95 | + | |
| 96 | +export interface GhosttySnapshot { | |
| 97 | + readonly cols: number; | |
| 98 | + readonly rows: number; | |
| 99 | + readonly foreground: GhosttyColor; | |
| 100 | + readonly background: GhosttyColor; | |
| 101 | + readonly cursor: GhosttyColor; | |
| 102 | + readonly cursorX: number; | |
| 103 | + readonly cursorY: number; | |
| 104 | + readonly cursorVisible: boolean; | |
| 105 | + readonly cursorBlinking: boolean; | |
| 106 | + readonly cursorStyle: number; | |
| 107 | + readonly dirtyRows: ReadonlySet<number>; | |
| 108 | + readonly rowData: readonly GhosttyRow[]; | |
| 109 | +} | |
| 110 | + | |
| 111 | +export interface GhosttySelectionRange { | |
| 112 | + readonly viewport: { | |
| 113 | + readonly start: { readonly x: number; readonly y: number }; | |
| 114 | + readonly end: { readonly x: number; readonly y: number }; | |
| 115 | + }; | |
| 116 | + readonly screen: { | |
| 117 | + readonly start: { readonly x: number; readonly y: number }; | |
| 118 | + readonly end: { readonly x: number; readonly y: number }; | |
| 119 | + }; | |
| 120 | +} | |
| 121 | + | |
| 122 | +export interface GhosttyScrollbar { | |
| 123 | + readonly total: number; | |
| 124 | + readonly offset: number; | |
| 125 | + readonly len: number; | |
| 126 | +} | |
| 127 | + | |
| 128 | +/** Grid position tagged with its Ghostty coordinate space: 1 viewport, 2 screen. */ | |
| 129 | +export interface GhosttyPointInput { | |
| 130 | + readonly x: number; | |
| 131 | + readonly y: number; | |
| 132 | + readonly tag?: 1 | 2; | |
| 133 | +} | |
| 134 | + | |
| 135 | +export interface GhosttyMouseInput { | |
| 136 | + readonly action: "press" | "release" | "motion"; | |
| 137 | + readonly button: number | null; | |
| 138 | + readonly mods: number; | |
| 139 | + readonly x: number; | |
| 140 | + readonly y: number; | |
| 141 | + readonly screenWidth: number; | |
| 142 | + readonly screenHeight: number; | |
| 143 | + readonly cellWidth: number; | |
| 144 | + readonly cellHeight: number; | |
| 145 | + readonly paddingLeft: number; | |
| 146 | + readonly paddingRight: number; | |
| 147 | + readonly paddingTop: number; | |
| 148 | + readonly paddingBottom: number; | |
| 149 | + readonly anyButtonPressed: boolean; | |
| 150 | +} | |
| 151 | + | |
| 152 | +const decoder = new TextDecoder(); | |
| 153 | +const encoder = new TextEncoder(); | |
| 154 | + | |
| 155 | +function blend(foreground: GhosttyColor, background: GhosttyColor): GhosttyColor { | |
| 156 | + const channel = (front: number, back: number) => Math.floor((front * 155 + back * 100) / 255); | |
| 157 | + return { | |
| 158 | + r: channel(foreground.r, background.r), | |
| 159 | + g: channel(foreground.g, background.g), | |
| 160 | + b: channel(foreground.b, background.b), | |
| 161 | + }; | |
| 162 | +} | |
| 163 | + | |
| 164 | +function sameColor(left: GhosttyColor, right: GhosttyColor): boolean { | |
| 165 | + return left.r === right.r && left.g === right.g && left.b === right.b; | |
| 166 | +} | |
| 167 | + | |
| 168 | +export class GhosttyTerminalCore { | |
| 169 | + private readonly runtime: GhosttyRuntime; | |
| 170 | + private terminalSlot = 0; | |
| 171 | + private terminal = 0; | |
| 172 | + private renderStateSlot = 0; | |
| 173 | + private renderState = 0; | |
| 174 | + private rowIteratorSlot = 0; | |
| 175 | + private rowCellsSlot = 0; | |
| 176 | + private keyEncoderSlot = 0; | |
| 177 | + private keyEncoder = 0; | |
| 178 | + private keyEventSlot = 0; | |
| 179 | + private keyEvent = 0; | |
| 180 | + private mouseEncoderSlot = 0; | |
| 181 | + private mouseEncoder = 0; | |
| 182 | + private mouseEventSlot = 0; | |
| 183 | + private mouseEvent = 0; | |
| 184 | + private ptyWriterId = 0; | |
| 185 | + private ptyWriter: ((data: string) => void) | null = null; | |
| 186 | + private scratch = 0; | |
| 187 | + private style = 0; | |
| 188 | + private scrollbar = 0; | |
| 189 | + private rows: GhosttyRow[] = []; | |
| 190 | + private disposed = false; | |
| 191 | + private keyboardLayoutMap: GhosttyKeyboardLayoutMap | undefined; | |
| 192 | + | |
| 193 | + private constructor(runtime: GhosttyRuntime) { | |
| 194 | + this.runtime = runtime; | |
| 195 | + void loadGhosttyKeyboardLayoutMap().then((layoutMap) => { | |
| 196 | + if (!this.disposed) this.keyboardLayoutMap = layoutMap; | |
| 197 | + }); | |
| 198 | + } | |
| 199 | + | |
| 200 | + static async create( | |
| 201 | + cols: number, | |
| 202 | + rows: number, | |
| 203 | + cellWidth: number, | |
| 204 | + cellHeight: number, | |
| 205 | + theme: GhosttyTheme, | |
| 206 | + onPtyData: (data: string) => void, | |
| 207 | + ): Promise<GhosttyTerminalCore> { | |
| 208 | + const core = new GhosttyTerminalCore(await loadGhosttyRuntime()); | |
| 209 | + try { | |
| 210 | + core.initialize(cols, rows, cellWidth, cellHeight, theme, onPtyData); | |
| 211 | + return core; | |
| 212 | + } catch (error) { | |
| 213 | + core.dispose(); | |
| 214 | + throw error; | |
| 215 | + } | |
| 216 | + } | |
| 217 | + | |
| 218 | + private initialize( | |
| 219 | + cols: number, | |
| 220 | + rows: number, | |
| 221 | + cellWidth: number, | |
| 222 | + cellHeight: number, | |
| 223 | + theme: GhosttyTheme, | |
| 224 | + onPtyData: (data: string) => void, | |
| 225 | + ): void { | |
| 226 | + const optionsSize = this.runtime.layout("GhosttyTerminalOptions").size; | |
| 227 | + const options = this.runtime.alloc(optionsSize); | |
| 228 | + this.runtime.setField(options, "GhosttyTerminalOptions", "cols", cols); | |
| 229 | + this.runtime.setField(options, "GhosttyTerminalOptions", "rows", rows); | |
| 230 | + this.runtime.setField(options, "GhosttyTerminalOptions", "max_scrollback", MAX_SCROLLBACK_ROWS); | |
| 231 | + this.terminalSlot = this.runtime.allocOpaque(); | |
| 232 | + const terminalResult = this.runtime.call("ghostty_terminal_new", 0, this.terminalSlot, options); | |
| 233 | + this.runtime.free(options, optionsSize); | |
| 234 | + this.assertSuccess("ghostty_terminal_new", terminalResult); | |
| 235 | + this.terminal = this.runtime.readPointer(this.terminalSlot); | |
| 236 | + this.ptyWriter = onPtyData; | |
| 237 | + this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, onPtyData); | |
| 238 | + | |
| 239 | + this.renderStateSlot = this.runtime.allocOpaque(); | |
| 240 | + this.assertSuccess( | |
| 241 | + "ghostty_render_state_new", | |
| 242 | + this.runtime.call("ghostty_render_state_new", 0, this.renderStateSlot), | |
| 243 | + ); | |
| 244 | + this.renderState = this.runtime.readPointer(this.renderStateSlot); | |
| 245 | + | |
| 246 | + this.rowIteratorSlot = this.runtime.allocOpaque(); | |
| 247 | + this.assertSuccess( | |
| 248 | + "ghostty_render_state_row_iterator_new", | |
| 249 | + this.runtime.call("ghostty_render_state_row_iterator_new", 0, this.rowIteratorSlot), | |
| 250 | + ); | |
| 251 | + this.rowCellsSlot = this.runtime.allocOpaque(); | |
| 252 | + this.assertSuccess( | |
| 253 | + "ghostty_render_state_row_cells_new", | |
| 254 | + this.runtime.call("ghostty_render_state_row_cells_new", 0, this.rowCellsSlot), | |
| 255 | + ); | |
| 256 | + | |
| 257 | + this.keyEncoderSlot = this.runtime.allocOpaque(); | |
| 258 | + this.assertSuccess( | |
| 259 | + "ghostty_key_encoder_new", | |
| 260 | + this.runtime.call("ghostty_key_encoder_new", 0, this.keyEncoderSlot), | |
| 261 | + ); | |
| 262 | + this.keyEncoder = this.runtime.readPointer(this.keyEncoderSlot); | |
| 263 | + this.keyEventSlot = this.runtime.allocOpaque(); | |
| 264 | + this.assertSuccess( | |
| 265 | + "ghostty_key_event_new", | |
| 266 | + this.runtime.call("ghostty_key_event_new", 0, this.keyEventSlot), | |
| 267 | + ); | |
| 268 | + this.keyEvent = this.runtime.readPointer(this.keyEventSlot); | |
| 269 | + | |
| 270 | + this.mouseEncoderSlot = this.runtime.allocOpaque(); | |
| 271 | + this.assertSuccess( | |
| 272 | + "ghostty_mouse_encoder_new", | |
| 273 | + this.runtime.call("ghostty_mouse_encoder_new", 0, this.mouseEncoderSlot), | |
| 274 | + ); | |
| 275 | + this.mouseEncoder = this.runtime.readPointer(this.mouseEncoderSlot); | |
| 276 | + this.mouseEventSlot = this.runtime.allocOpaque(); | |
| 277 | + this.assertSuccess( | |
| 278 | + "ghostty_mouse_event_new", | |
| 279 | + this.runtime.call("ghostty_mouse_event_new", 0, this.mouseEventSlot), | |
| 280 | + ); | |
| 281 | + this.mouseEvent = this.runtime.readPointer(this.mouseEventSlot); | |
| 282 | + | |
| 283 | + this.scratch = this.runtime.alloc(16); | |
| 284 | + const styleSize = this.runtime.layout("GhosttyStyle").size; | |
| 285 | + this.style = this.runtime.alloc(styleSize); | |
| 286 | + this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); | |
| 287 | + this.scrollbar = this.runtime.alloc(this.runtime.layout("GhosttyTerminalScrollbar").size); | |
| 288 | + this.setTheme(theme); | |
| 289 | + this.resize(cols, rows, cellWidth, cellHeight); | |
| 290 | + } | |
| 291 | + | |
| 292 | + write(data: string | Uint8Array): void { | |
| 293 | + this.ensureActive(); | |
| 294 | + const bytes = typeof data === "string" ? encoder.encode(data) : data; | |
| 295 | + if (bytes.length === 0) return; | |
| 296 | + const pointer = this.runtime.alloc(bytes.length); | |
| 297 | + this.runtime.bytes(pointer, bytes.length).set(bytes); | |
| 298 | + this.runtime.call("ghostty_terminal_vt_write", this.terminal, pointer, bytes.length); | |
| 299 | + this.runtime.free(pointer, bytes.length); | |
| 300 | + } | |
| 301 | + | |
| 302 | + resetAndWrite(data: string): void { | |
| 303 | + this.ensureActive(); | |
| 304 | + this.runtime.call("ghostty_terminal_reset", this.terminal); | |
| 305 | + this.rows = []; | |
| 306 | + if (data.length === 0) return; | |
| 307 | + const writer = this.ptyWriter; | |
| 308 | + if (this.ptyWriterId !== 0) { | |
| 309 | + this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId); | |
| 310 | + this.ptyWriterId = 0; | |
| 311 | + } | |
| 312 | + try { | |
| 313 | + this.write(data); | |
| 314 | + } finally { | |
| 315 | + if (writer !== null && !this.disposed) { | |
| 316 | + this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, writer); | |
| 317 | + } | |
| 318 | + } | |
| 319 | + } | |
| 320 | + | |
| 321 | + resize(cols: number, rows: number, cellWidth: number, cellHeight: number): void { | |
| 322 | + this.ensureActive(); | |
| 323 | + this.assertSuccess( | |
| 324 | + "ghostty_terminal_resize", | |
| 325 | + this.runtime.call( | |
| 326 | + "ghostty_terminal_resize", | |
| 327 | + this.terminal, | |
| 328 | + Math.max(1, Math.min(65_535, cols)), | |
| 329 | + Math.max(1, Math.min(65_535, rows)), | |
| 330 | + Math.max(1, Math.round(cellWidth)), | |
| 331 | + Math.max(1, Math.round(cellHeight)), | |
| 332 | + ), | |
| 333 | + ); | |
| 334 | + } | |
| 335 | + | |
| 336 | + setTheme(theme: GhosttyTheme): void { | |
| 337 | + this.ensureActive(); | |
| 338 | + const color = this.runtime.alloc(3); | |
| 339 | + for (const [option, value] of [ | |
| 340 | + [11, theme.foreground], | |
| 341 | + [12, theme.background], | |
| 342 | + [13, theme.cursor], | |
| 343 | + ] as const) { | |
| 344 | + this.runtime.bytes(color, 3).set([value.r, value.g, value.b]); | |
| 345 | + this.runtime.call("ghostty_terminal_set", this.terminal, option, color); | |
| 346 | + } | |
| 347 | + this.runtime.free(color, 3); | |
| 348 | + } | |
| 349 | + | |
| 350 | + scroll(deltaRows: number): void { | |
| 351 | + this.ensureActive(); | |
| 352 | + const layout = this.runtime.layout("GhosttyTerminalScrollViewport"); | |
| 353 | + const scroll = this.runtime.alloc(layout.size); | |
| 354 | + this.runtime.setField(scroll, "GhosttyTerminalScrollViewport", "tag", 2); | |
| 355 | + const value = layout.fields.value!; | |
| 356 | + this.runtime.view(scroll + value.offset, value.size).setInt32(0, deltaRows, true); | |
| 357 | + this.runtime.call("ghostty_terminal_scroll_viewport", this.terminal, scroll); | |
| 358 | + this.runtime.free(scroll, layout.size); | |
| 359 | + } | |
| 360 | + | |
| 361 | + scrollToBottom(): void { | |
| 362 | + this.ensureActive(); | |
| 363 | + const layout = this.runtime.layout("GhosttyTerminalScrollViewport"); | |
| 364 | + const scroll = this.runtime.alloc(layout.size); | |
| 365 | + this.runtime.setField(scroll, "GhosttyTerminalScrollViewport", "tag", 1); | |
| 366 | + this.runtime.call("ghostty_terminal_scroll_viewport", this.terminal, scroll); | |
| 367 | + this.runtime.free(scroll, layout.size); | |
| 368 | + } | |
| 369 | + | |
| 370 | + isViewportActive(): boolean { | |
| 371 | + this.ensureActive(); | |
| 372 | + this.runtime.bytes(this.scratch, 1)[0] = 0; | |
| 373 | + return ( | |
| 374 | + this.runtime.call("ghostty_terminal_get", this.terminal, 32, this.scratch) === | |
| 375 | + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 | |
| 376 | + ); | |
| 377 | + } | |
| 378 | + | |
| 379 | + scrollbarState(): GhosttyScrollbar | null { | |
| 380 | + this.ensureActive(); | |
| 381 | + const layout = this.runtime.layout("GhosttyTerminalScrollbar"); | |
| 382 | + this.runtime.bytes(this.scrollbar, layout.size).fill(0); | |
| 383 | + if ( | |
| 384 | + this.runtime.call("ghostty_terminal_get", this.terminal, 9, this.scrollbar) !== | |
| 385 | + GHOSTTY_SUCCESS | |
| 386 | + ) { | |
| 387 | + return null; | |
| 388 | + } | |
| 389 | + return { | |
| 390 | + total: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "total"), | |
| 391 | + offset: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "offset"), | |
| 392 | + len: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "len"), | |
| 393 | + }; | |
| 394 | + } | |
| 395 | + | |
| 396 | + isMouseTracking(): boolean { | |
| 397 | + this.ensureActive(); | |
| 398 | + this.runtime.bytes(this.scratch, 1)[0] = 0; | |
| 399 | + return ( | |
| 400 | + this.runtime.call("ghostty_terminal_get", this.terminal, 11, this.scratch) === | |
| 401 | + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 | |
| 402 | + ); | |
| 403 | + } | |
| 404 | + | |
| 405 | + isMouseAnyEventTracking(): boolean { | |
| 406 | + this.ensureActive(); | |
| 407 | + this.runtime.bytes(this.scratch, 1)[0] = 0; | |
| 408 | + return ( | |
| 409 | + this.runtime.call("ghostty_terminal_mode_get", this.terminal, 1003, this.scratch) === | |
| 410 | + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 | |
| 411 | + ); | |
| 412 | + } | |
| 413 | + | |
| 414 | + isAlternateScreen(): boolean { | |
| 415 | + this.ensureActive(); | |
| 416 | + this.runtime.bytes(this.scratch, 4).fill(0); | |
| 417 | + return ( | |
| 418 | + this.runtime.call("ghostty_terminal_get", this.terminal, 6, this.scratch) === | |
| 419 | + GHOSTTY_SUCCESS && this.runtime.view(this.scratch, 4).getUint32(0, true) === 1 | |
| 420 | + ); | |
| 421 | + } | |
| 422 | + | |
| 423 | + isApplicationCursorKeys(): boolean { | |
| 424 | + this.ensureActive(); | |
| 425 | + this.runtime.bytes(this.scratch, 1)[0] = 0; | |
| 426 | + return ( | |
| 427 | + this.runtime.call("ghostty_terminal_mode_get", this.terminal, 1, this.scratch) === | |
| 428 | + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 | |
| 429 | + ); | |
| 430 | + } | |
| 431 | + | |
| 432 | + encodeKey(event: KeyboardEvent, action: "press" | "release" = "press"): string { | |
| 433 | + this.ensureActive(); | |
| 434 | + this.runtime.call("ghostty_key_encoder_setopt_from_terminal", this.keyEncoder, this.terminal); | |
| 435 | + this.runtime.call( | |
| 436 | + "ghostty_key_event_set_action", | |
| 437 | + this.keyEvent, | |
| 438 | + action === "release" ? 0 : event.repeat ? 2 : 1, | |
| 439 | + ); | |
| 440 | + this.runtime.call("ghostty_key_event_set_key", this.keyEvent, ghosttyKeyForCode(event.code)); | |
| 441 | + const mods = | |
| 442 | + (event.shiftKey ? 1 : 0) | | |
| 443 | + (event.ctrlKey ? 1 << 1 : 0) | | |
| 444 | + (event.altKey ? 1 << 2 : 0) | | |
| 445 | + (event.metaKey ? 1 << 3 : 0) | | |
| 446 | + (event.getModifierState("CapsLock") ? 1 << 4 : 0) | | |
| 447 | + (event.getModifierState("NumLock") ? 1 << 5 : 0); | |
| 448 | + this.runtime.call("ghostty_key_event_set_mods", this.keyEvent, mods); | |
| 449 | + this.runtime.call("ghostty_key_event_set_consumed_mods", this.keyEvent, 0); | |
| 450 | + this.runtime.call("ghostty_key_event_set_composing", this.keyEvent, event.isComposing ? 1 : 0); | |
| 451 | + this.runtime.call( | |
| 452 | + "ghostty_key_event_set_unshifted_codepoint", | |
| 453 | + this.keyEvent, | |
| 454 | + ghosttyUnshiftedCodepoint(event, this.keyboardLayoutMap), | |
| 455 | + ); | |
| 456 | + | |
| 457 | + const text = event.key.length === 1 ? event.key : ""; | |
| 458 | + const textBytes = encoder.encode(text); | |
| 459 | + const textPointer = textBytes.length === 0 ? 0 : this.runtime.alloc(textBytes.length); | |
| 460 | + if (textPointer !== 0) this.runtime.bytes(textPointer, textBytes.length).set(textBytes); | |
| 461 | + this.runtime.call("ghostty_key_event_set_utf8", this.keyEvent, textPointer, textBytes.length); | |
| 462 | + | |
| 463 | + const written = this.runtime.call("ghostty_wasm_alloc_usize"); | |
| 464 | + const encoded = this.encodeOutput(written, (output, outputSize) => | |
| 465 | + this.runtime.call( | |
| 466 | + "ghostty_key_encoder_encode", | |
| 467 | + this.keyEncoder, | |
| 468 | + this.keyEvent, | |
| 469 | + output, | |
| 470 | + outputSize, | |
| 471 | + written, | |
| 472 | + ), | |
| 473 | + ); | |
| 474 | + this.runtime.call("ghostty_wasm_free_usize", written); | |
| 475 | + if (textPointer !== 0) this.runtime.free(textPointer, textBytes.length); | |
| 476 | + return encoded; | |
| 477 | + } | |
| 478 | + | |
| 479 | + encodePaste(data: string): string { | |
| 480 | + this.ensureActive(); | |
| 481 | + const input = encoder.encode(data); | |
| 482 | + if (input.length === 0) return ""; | |
| 483 | + const inputPointer = this.runtime.alloc(input.length); | |
| 484 | + this.runtime.bytes(inputPointer, input.length).set(input); | |
| 485 | + this.runtime.bytes(this.scratch, 1)[0] = 0; | |
| 486 | + const bracketed = | |
| 487 | + this.runtime.call("ghostty_terminal_mode_get", this.terminal, 2004, this.scratch) === | |
| 488 | + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0; | |
| 489 | + const written = this.runtime.call("ghostty_wasm_alloc_usize"); | |
| 490 | + let encoded = ""; | |
| 491 | + const sizeResult = this.runtime.call( | |
| 492 | + "ghostty_paste_encode", | |
| 493 | + inputPointer, | |
| 494 | + input.length, | |
| 495 | + bracketed ? 1 : 0, | |
| 496 | + 0, | |
| 497 | + 0, | |
| 498 | + written, | |
| 499 | + ); | |
| 500 | + const outputSize = this.runtime.view(written, 4).getUint32(0, true); | |
| 501 | + if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { | |
| 502 | + const output = this.runtime.alloc(outputSize); | |
| 503 | + const result = this.runtime.call( | |
| 504 | + "ghostty_paste_encode", | |
| 505 | + inputPointer, | |
| 506 | + input.length, | |
| 507 | + bracketed ? 1 : 0, | |
| 508 | + output, | |
| 509 | + outputSize, | |
| 510 | + written, | |
| 511 | + ); | |
| 512 | + const outputLength = this.runtime.view(written, 4).getUint32(0, true); | |
| 513 | + encoded = | |
| 514 | + result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : ""; | |
| 515 | + this.runtime.free(output, outputSize); | |
| 516 | + } | |
| 517 | + this.runtime.call("ghostty_wasm_free_usize", written); | |
| 518 | + this.runtime.free(inputPointer, input.length); | |
| 519 | + return encoded; | |
| 520 | + } | |
| 521 | + | |
| 522 | + encodeMouse(input: GhosttyMouseInput): string { | |
| 523 | + this.ensureActive(); | |
| 524 | + this.runtime.call( | |
| 525 | + "ghostty_mouse_encoder_setopt_from_terminal", | |
| 526 | + this.mouseEncoder, | |
| 527 | + this.terminal, | |
| 528 | + ); | |
| 529 | + | |
| 530 | + const sizeLayout = this.runtime.layout("GhosttyMouseEncoderSize"); | |
| 531 | + const size = this.runtime.alloc(sizeLayout.size); | |
| 532 | + for (const [field, value] of [ | |
| 533 | + ["size", sizeLayout.size], | |
| 534 | + ["screen_width", input.screenWidth], | |
| 535 | + ["screen_height", input.screenHeight], | |
| 536 | + ["cell_width", input.cellWidth], | |
| 537 | + ["cell_height", input.cellHeight], | |
| 538 | + ["padding_top", input.paddingTop], | |
| 539 | + ["padding_bottom", input.paddingBottom], | |
| 540 | + ["padding_right", input.paddingRight], | |
| 541 | + ["padding_left", input.paddingLeft], | |
| 542 | + ] as const) { | |
| 543 | + this.runtime.setField(size, "GhosttyMouseEncoderSize", field, Math.max(0, Math.round(value))); | |
| 544 | + } | |
| 545 | + this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 2, size); | |
| 546 | + this.runtime.free(size, sizeLayout.size); | |
| 547 | + | |
| 548 | + this.runtime.bytes(this.scratch, 1)[0] = input.anyButtonPressed ? 1 : 0; | |
| 549 | + this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 3, this.scratch); | |
| 550 | + this.runtime.bytes(this.scratch, 1)[0] = 1; | |
| 551 | + this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 4, this.scratch); | |
| 552 | + | |
| 553 | + this.runtime.call( | |
| 554 | + "ghostty_mouse_event_set_action", | |
| 555 | + this.mouseEvent, | |
| 556 | + input.action === "press" ? 0 : input.action === "release" ? 1 : 2, | |
| 557 | + ); | |
| 558 | + if (input.button === null) { | |
| 559 | + this.runtime.call("ghostty_mouse_event_clear_button", this.mouseEvent); | |
| 560 | + } else { | |
| 561 | + this.runtime.call("ghostty_mouse_event_set_button", this.mouseEvent, input.button); | |
| 562 | + } | |
| 563 | + this.runtime.call("ghostty_mouse_event_set_mods", this.mouseEvent, input.mods); | |
| 564 | + const positionLayout = this.runtime.layout("GhosttyMousePosition"); | |
| 565 | + const position = this.runtime.alloc(positionLayout.size); | |
| 566 | + const positionView = this.runtime.view(position, positionLayout.size); | |
| 567 | + positionView.setFloat32(positionLayout.fields.x!.offset, input.x, true); | |
| 568 | + positionView.setFloat32(positionLayout.fields.y!.offset, input.y, true); | |
| 569 | + this.runtime.call("ghostty_mouse_event_set_position", this.mouseEvent, position); | |
| 570 | + this.runtime.free(position, positionLayout.size); | |
| 571 | + | |
| 572 | + const written = this.runtime.call("ghostty_wasm_alloc_usize"); | |
| 573 | + const encoded = this.encodeOutput(written, (output, outputSize) => | |
| 574 | + this.runtime.call( | |
| 575 | + "ghostty_mouse_encoder_encode", | |
| 576 | + this.mouseEncoder, | |
| 577 | + this.mouseEvent, | |
| 578 | + output, | |
| 579 | + outputSize, | |
| 580 | + written, | |
| 581 | + ), | |
| 582 | + ); | |
| 583 | + this.runtime.call("ghostty_wasm_free_usize", written); | |
| 584 | + return encoded; | |
| 585 | + } | |
| 586 | + | |
| 587 | + setSelection(anchor: GhosttyPointInput, end: GhosttyPointInput): void { | |
| 588 | + this.ensureActive(); | |
| 589 | + const selectionLayout = this.runtime.layout("GhosttySelection"); | |
| 590 | + const gridRefSize = this.runtime.layout("GhosttyGridRef").size; | |
| 591 | + const selection = this.runtime.alloc(selectionLayout.size); | |
| 592 | + let start = 0; | |
| 593 | + let endRef = 0; | |
| 594 | + try { | |
| 595 | + this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size); | |
| 596 | + start = this.gridRef(anchor.x, anchor.y, anchor.tag ?? 1); | |
| 597 | + endRef = this.gridRef(end.x, end.y, end.tag ?? 1); | |
| 598 | + const startField = selectionLayout.fields.start!; | |
| 599 | + const endField = selectionLayout.fields.end!; | |
| 600 | + this.runtime | |
| 601 | + .bytes(selection + startField.offset, startField.size) | |
| 602 | + .set(this.runtime.bytes(start, startField.size)); | |
| 603 | + this.runtime | |
| 604 | + .bytes(selection + endField.offset, endField.size) | |
| 605 | + .set(this.runtime.bytes(endRef, endField.size)); | |
| 606 | + this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); | |
| 607 | + } finally { | |
| 608 | + this.runtime.free(start, gridRefSize); | |
| 609 | + this.runtime.free(endRef, gridRefSize); | |
| 610 | + this.runtime.free(selection, selectionLayout.size); | |
| 611 | + } | |
| 612 | + } | |
| 613 | + | |
| 614 | + selectAll(): void { | |
| 615 | + this.ensureActive(); | |
| 616 | + const layout = this.runtime.layout("GhosttySelection"); | |
| 617 | + const selection = this.runtime.alloc(layout.size); | |
| 618 | + this.runtime.setField(selection, "GhosttySelection", "size", layout.size); | |
| 619 | + if ( | |
| 620 | + this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) === GHOSTTY_SUCCESS | |
| 621 | + ) { | |
| 622 | + this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); | |
| 623 | + } | |
| 624 | + this.runtime.free(selection, layout.size); | |
| 625 | + } | |
| 626 | + | |
| 627 | + selectWord(col: number, row: number): GhosttySelectionRange | null { | |
| 628 | + return this.selectAt( | |
| 629 | + "GhosttyTerminalSelectWordOptions", | |
| 630 | + "ghostty_terminal_select_word", | |
| 631 | + col, | |
| 632 | + row, | |
| 633 | + ); | |
| 634 | + } | |
| 635 | + | |
| 636 | + selectLine(col: number, row: number): GhosttySelectionRange | null { | |
| 637 | + return this.selectAt( | |
| 638 | + "GhosttyTerminalSelectLineOptions", | |
| 639 | + "ghostty_terminal_select_line", | |
| 640 | + col, | |
| 641 | + row, | |
| 642 | + ); | |
| 643 | + } | |
| 644 | + | |
| 645 | + hyperlinkAt(col: number, row: number): string | null { | |
| 646 | + this.ensureActive(); | |
| 647 | + const ref = this.gridRef(col, row); | |
| 648 | + const written = this.runtime.call("ghostty_wasm_alloc_usize"); | |
| 649 | + const sizeResult = this.runtime.call("ghostty_grid_ref_hyperlink_uri", ref, 0, 0, written); | |
| 650 | + const outputSize = this.runtime.view(written, 4).getUint32(0, true); | |
| 651 | + let hyperlink: string | null = null; | |
| 652 | + if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { | |
| 653 | + const output = this.runtime.alloc(outputSize); | |
| 654 | + const result = this.runtime.call( | |
| 655 | + "ghostty_grid_ref_hyperlink_uri", | |
| 656 | + ref, | |
| 657 | + output, | |
| 658 | + outputSize, | |
| 659 | + written, | |
| 660 | + ); | |
| 661 | + const outputLength = this.runtime.view(written, 4).getUint32(0, true); | |
| 662 | + if (result === GHOSTTY_SUCCESS && outputLength > 0) { | |
| 663 | + hyperlink = decoder.decode(this.runtime.bytes(output, outputLength)); | |
| 664 | + } | |
| 665 | + this.runtime.free(output, outputSize); | |
| 666 | + } | |
| 667 | + this.runtime.call("ghostty_wasm_free_usize", written); | |
| 668 | + this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); | |
| 669 | + return hyperlink; | |
| 670 | + } | |
| 671 | + | |
| 672 | + clearSelection(): void { | |
| 673 | + this.ensureActive(); | |
| 674 | + this.runtime.call("ghostty_terminal_set", this.terminal, 21, 0); | |
| 675 | + } | |
| 676 | + | |
| 677 | + snapshot(): GhosttySnapshot { | |
| 678 | + this.ensureActive(); | |
| 679 | + this.assertSuccess( | |
| 680 | + "ghostty_render_state_update", | |
| 681 | + this.runtime.call("ghostty_render_state_update", this.renderState, this.terminal), | |
| 682 | + ); | |
| 683 | + const cols = this.getU16(RENDER_DATA.cols); | |
| 684 | + const rowCount = this.getU16(RENDER_DATA.rows); | |
| 685 | + const dirty = this.getU32(RENDER_DATA.dirty); | |
| 686 | + const foreground = this.getColor(RENDER_DATA.foreground, { r: 229, g: 231, b: 235 }); | |
| 687 | + const background = this.getColor(RENDER_DATA.background, { r: 0, g: 0, b: 0 }); | |
| 688 | + const cursorHasValue = this.getBool(RENDER_DATA.cursorHasValue); | |
| 689 | + const cursor = cursorHasValue ? this.getColor(RENDER_DATA.cursor, foreground) : foreground; | |
| 690 | + const cursorInViewport = this.getBool(RENDER_DATA.cursorInViewport); | |
| 691 | + const cursorVisible = this.getBool(RENDER_DATA.cursorVisible) && cursorInViewport; | |
| 692 | + const cursorX = cursorInViewport ? this.getU16(RENDER_DATA.cursorX) : -1; | |
| 693 | + const cursorY = cursorInViewport ? this.getU16(RENDER_DATA.cursorY) : -1; | |
| 694 | + | |
| 695 | + if (this.rows.length !== rowCount || this.rows.some((row) => row.cells.length !== cols)) { | |
| 696 | + this.rows = Array.from({ length: rowCount }, () => ({ | |
| 697 | + cells: Array.from({ length: cols }, () => this.emptyCell(foreground, background)), | |
| 698 | + text: "", | |
| 699 | + isWrapContinuation: false, | |
| 700 | + wrapsToNext: false, | |
| 701 | + })); | |
| 702 | + } | |
| 703 | + | |
| 704 | + const dirtyRows = new Set<number>(); | |
| 705 | + if (dirty !== 0) { | |
| 706 | + this.assertSuccess( | |
| 707 | + "ghostty_render_state_get(row iterator)", | |
| 708 | + this.runtime.call( | |
| 709 | + "ghostty_render_state_get", | |
| 710 | + this.renderState, | |
| 711 | + RENDER_DATA.rowIterator, | |
| 712 | + this.rowIteratorSlot, | |
| 713 | + ), | |
| 714 | + ); | |
| 715 | + const iterator = this.runtime.readPointer(this.rowIteratorSlot); | |
| 716 | + let rowIndex = 0; | |
| 717 | + while ( | |
| 718 | + rowIndex < rowCount && | |
| 719 | + this.runtime.call("ghostty_render_state_row_iterator_next", iterator) !== 0 | |
| 720 | + ) { | |
| 721 | + const rowDirty = dirty === 2 || this.getRowBool(iterator, ROW_DATA.dirty); | |
| 722 | + if (rowDirty) { | |
| 723 | + this.rows[rowIndex] = this.readRow(iterator, cols, foreground, background); | |
| 724 | + dirtyRows.add(rowIndex); | |
| 725 | + this.runtime.bytes(this.scratch, 1)[0] = 0; | |
| 726 | + this.runtime.call("ghostty_render_state_row_set", iterator, 0, this.scratch); | |
| 727 | + } | |
| 728 | + rowIndex += 1; | |
| 729 | + } | |
| 730 | + this.runtime.view(this.scratch, 4).setUint32(0, 0, true); | |
| 731 | + this.runtime.call("ghostty_render_state_set", this.renderState, 0, this.scratch); | |
| 732 | + } | |
| 733 | + | |
| 734 | + return { | |
| 735 | + cols, | |
| 736 | + rows: rowCount, | |
| 737 | + foreground, | |
| 738 | + background, | |
| 739 | + cursor, | |
| 740 | + cursorX, | |
| 741 | + cursorY, | |
| 742 | + cursorVisible, | |
| 743 | + cursorBlinking: this.getBool(RENDER_DATA.cursorBlinking), | |
| 744 | + cursorStyle: this.getU32(RENDER_DATA.cursorStyle), | |
| 745 | + dirtyRows, | |
| 746 | + rowData: this.rows, | |
| 747 | + }; | |
| 748 | + } | |
| 749 | + | |
| 750 | + selectionText(): string { | |
| 751 | + this.ensureActive(); | |
| 752 | + const options = this.runtime.alloc(SELECTION_FORMAT_OPTIONS_SIZE); | |
| 753 | + const optionsView = this.runtime.view(options, SELECTION_FORMAT_OPTIONS_SIZE); | |
| 754 | + optionsView.setUint32(0, SELECTION_FORMAT_OPTIONS_SIZE, true); | |
| 755 | + optionsView.setUint32(4, 0, true); | |
| 756 | + optionsView.setUint8(8, 1); | |
| 757 | + optionsView.setUint8(9, 1); | |
| 758 | + optionsView.setUint32(12, 0, true); | |
| 759 | + const written = this.runtime.call("ghostty_wasm_alloc_usize"); | |
| 760 | + const sizeResult = this.runtime.call( | |
| 761 | + "ghostty_terminal_selection_format_buf", | |
| 762 | + this.terminal, | |
| 763 | + options, | |
| 764 | + 0, | |
| 765 | + 0, | |
| 766 | + written, | |
| 767 | + ); | |
| 768 | + const outputSize = this.runtime.view(written, 4).getUint32(0, true); | |
| 769 | + let text = ""; | |
| 770 | + if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { | |
| 771 | + const output = this.runtime.alloc(outputSize); | |
| 772 | + const result = this.runtime.call( | |
| 773 | + "ghostty_terminal_selection_format_buf", | |
| 774 | + this.terminal, | |
| 775 | + options, | |
| 776 | + output, | |
| 777 | + outputSize, | |
| 778 | + written, | |
| 779 | + ); | |
| 780 | + const outputLength = this.runtime.view(written, 4).getUint32(0, true); | |
| 781 | + if (result === GHOSTTY_SUCCESS) { | |
| 782 | + text = decoder.decode(this.runtime.bytes(output, outputLength)); | |
| 783 | + } | |
| 784 | + this.runtime.free(output, outputSize); | |
| 785 | + } | |
| 786 | + this.runtime.call("ghostty_wasm_free_usize", written); | |
| 787 | + this.runtime.free(options, SELECTION_FORMAT_OPTIONS_SIZE); | |
| 788 | + return text; | |
| 789 | + } | |
| 790 | + | |
| 791 | + viewportPointToScreen(col: number, row: number): { x: number; y: number } | null { | |
| 792 | + return this.convertPoint(col, row, 1, 2); | |
| 793 | + } | |
| 794 | + | |
| 795 | + screenPointToViewport(col: number, row: number): { x: number; y: number } | null { | |
| 796 | + return this.convertPoint(col, row, 2, 1); | |
| 797 | + } | |
| 798 | + | |
| 799 | + private convertPoint( | |
| 800 | + col: number, | |
| 801 | + row: number, | |
| 802 | + fromTag: 1 | 2, | |
| 803 | + toTag: 1 | 2, | |
| 804 | + ): { x: number; y: number } | null { | |
| 805 | + this.ensureActive(); | |
| 806 | + const ref = this.gridRef(col, row, fromTag); | |
| 807 | + const point = this.pointFromGridRef(ref, toTag); | |
| 808 | + this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); | |
| 809 | + return point; | |
| 810 | + } | |
| 811 | + | |
| 812 | + dispose(): void { | |
| 813 | + if (this.disposed) return; | |
| 814 | + this.disposed = true; | |
| 815 | + if (this.mouseEvent) this.runtime.call("ghostty_mouse_event_free", this.mouseEvent); | |
| 816 | + if (this.mouseEncoder) this.runtime.call("ghostty_mouse_encoder_free", this.mouseEncoder); | |
| 817 | + if (this.keyEvent) this.runtime.call("ghostty_key_event_free", this.keyEvent); | |
| 818 | + if (this.keyEncoder) this.runtime.call("ghostty_key_encoder_free", this.keyEncoder); | |
| 819 | + if (this.rowCellsSlot) { | |
| 820 | + const cells = this.runtime.readPointer(this.rowCellsSlot); | |
| 821 | + if (cells) this.runtime.call("ghostty_render_state_row_cells_free", cells); | |
| 822 | + } | |
| 823 | + if (this.rowIteratorSlot) { | |
| 824 | + const iterator = this.runtime.readPointer(this.rowIteratorSlot); | |
| 825 | + if (iterator) this.runtime.call("ghostty_render_state_row_iterator_free", iterator); | |
| 826 | + } | |
| 827 | + if (this.renderState) this.runtime.call("ghostty_render_state_free", this.renderState); | |
| 828 | + if (this.terminal) { | |
| 829 | + if (this.ptyWriterId) this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId); | |
| 830 | + this.runtime.call("ghostty_terminal_free", this.terminal); | |
| 831 | + } | |
| 832 | + if (this.style) this.runtime.free(this.style, this.runtime.layout("GhosttyStyle").size); | |
| 833 | + if (this.scrollbar) { | |
| 834 | + this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size); | |
| 835 | + } | |
| 836 | + if (this.scratch) this.runtime.free(this.scratch, 16); | |
| 837 | + for (const slot of [ | |
| 838 | + this.mouseEventSlot, | |
| 839 | + this.mouseEncoderSlot, | |
| 840 | + this.keyEventSlot, | |
| 841 | + this.keyEncoderSlot, | |
| 842 | + this.rowCellsSlot, | |
| 843 | + this.rowIteratorSlot, | |
| 844 | + this.renderStateSlot, | |
| 845 | + this.terminalSlot, | |
| 846 | + ]) { | |
| 847 | + this.runtime.freeOpaque(slot); | |
| 848 | + } | |
| 849 | + } | |
| 850 | + | |
| 851 | + private encodeOutput( | |
| 852 | + written: number, | |
| 853 | + encode: (output: number, outputSize: number) => number, | |
| 854 | + ): string { | |
| 855 | + const sizeResult = encode(0, 0); | |
| 856 | + const outputSize = this.runtime.view(written, 4).getUint32(0, true); | |
| 857 | + if (sizeResult === GHOSTTY_SUCCESS && outputSize === 0) return ""; | |
| 858 | + if (sizeResult !== GHOSTTY_OUT_OF_SPACE || outputSize === 0) return ""; | |
| 859 | + | |
| 860 | + const output = this.runtime.alloc(outputSize); | |
| 861 | + const result = encode(output, outputSize); | |
| 862 | + const outputLength = this.runtime.view(written, 4).getUint32(0, true); | |
| 863 | + const encoded = | |
| 864 | + result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : ""; | |
| 865 | + this.runtime.free(output, outputSize); | |
| 866 | + return encoded; | |
| 867 | + } | |
| 868 | + | |
| 869 | + private readRow( | |
| 870 | + iterator: number, | |
| 871 | + cols: number, | |
| 872 | + defaultForeground: GhosttyColor, | |
| 873 | + defaultBackground: GhosttyColor, | |
| 874 | + ): GhosttyRow { | |
| 875 | + this.assertSuccess( | |
| 876 | + "ghostty_render_state_row_get(raw)", | |
| 877 | + this.runtime.call("ghostty_render_state_row_get", iterator, ROW_DATA.raw, this.scratch), | |
| 878 | + ); | |
| 879 | + const rawRow = this.runtime.view(this.scratch, 8).getBigUint64(0, true); | |
| 880 | + this.runtime.bytes(this.scratch + 8, 1)[0] = 0; | |
| 881 | + this.assertSuccess( | |
| 882 | + "ghostty_row_get(wrap continuation)", | |
| 883 | + this.runtime.call("ghostty_row_get", rawRow, 2, this.scratch + 8), | |
| 884 | + ); | |
| 885 | + const isWrapContinuation = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; | |
| 886 | + this.runtime.bytes(this.scratch + 8, 1)[0] = 0; | |
| 887 | + this.assertSuccess( | |
| 888 | + "ghostty_row_get(wrap)", | |
| 889 | + this.runtime.call("ghostty_row_get", rawRow, 1, this.scratch + 8), | |
| 890 | + ); | |
| 891 | + const wrapsToNext = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; | |
| 892 | + | |
| 893 | + this.assertSuccess( | |
| 894 | + "ghostty_render_state_row_get(cells)", | |
| 895 | + this.runtime.call( | |
| 896 | + "ghostty_render_state_row_get", | |
| 897 | + iterator, | |
| 898 | + ROW_DATA.cells, | |
| 899 | + this.rowCellsSlot, | |
| 900 | + ), | |
| 901 | + ); | |
| 902 | + const cellsIterator = this.runtime.readPointer(this.rowCellsSlot); | |
| 903 | + const cells: GhosttyCell[] = []; | |
| 904 | + while ( | |
| 905 | + cells.length < cols && | |
| 906 | + this.runtime.call("ghostty_render_state_row_cells_next", cellsIterator) !== 0 | |
| 907 | + ) { | |
| 908 | + let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground); | |
| 909 | + let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground); | |
| 910 | + const styleSize = this.runtime.layout("GhosttyStyle").size; | |
| 911 | + this.runtime.bytes(this.style, styleSize).fill(0); | |
| 912 | + this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); | |
| 913 | + this.runtime.call( | |
| 914 | + "ghostty_render_state_row_cells_get", | |
| 915 | + cellsIterator, | |
| 916 | + CELL_DATA.style, | |
| 917 | + this.style, | |
| 918 | + ); | |
| 919 | + const inverse = this.runtime.readField(this.style, "GhosttyStyle", "inverse") !== 0; | |
| 920 | + if (inverse) [foreground, background] = [background, foreground]; | |
| 921 | + if (this.runtime.readField(this.style, "GhosttyStyle", "faint") !== 0) { | |
| 922 | + foreground = blend(foreground, background); | |
| 923 | + } | |
| 924 | + const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength); | |
| 925 | + let text = ""; | |
| 926 | + if (graphemeLength > 0) { | |
| 927 | + const bufferSize = graphemeLength * 4; | |
| 928 | + const codepoints = this.runtime.alloc(bufferSize); | |
| 929 | + if ( | |
| 930 | + this.runtime.call( | |
| 931 | + "ghostty_render_state_row_cells_get", | |
| 932 | + cellsIterator, | |
| 933 | + CELL_DATA.graphemes, | |
| 934 | + codepoints, | |
| 935 | + ) === GHOSTTY_SUCCESS | |
| 936 | + ) { | |
| 937 | + // Read through a DataView: the byte-array allocator guarantees no | |
| 938 | + // 4-byte alignment, which a Uint32Array view would require. | |
| 939 | + const codepointView = this.runtime.view(codepoints, bufferSize); | |
| 940 | + const codes: number[] = []; | |
| 941 | + for (let index = 0; index < graphemeLength; index += 1) { | |
| 942 | + codes.push(codepointView.getUint32(index * 4, true)); | |
| 943 | + } | |
| 944 | + text = String.fromCodePoint(...codes); | |
| 945 | + } | |
| 946 | + this.runtime.free(codepoints, bufferSize); | |
| 947 | + } | |
| 948 | + let wide = 0; | |
| 949 | + if (text.length === 0 && cells.at(-1)?.text.length) { | |
| 950 | + this.assertSuccess( | |
| 951 | + "ghostty_render_state_row_cells_get(raw)", | |
| 952 | + this.runtime.call( | |
| 953 | + "ghostty_render_state_row_cells_get", | |
| 954 | + cellsIterator, | |
| 955 | + CELL_DATA.raw, | |
| 956 | + this.scratch, | |
| 957 | + ), | |
| 958 | + ); | |
| 959 | + const rawCell = this.runtime.view(this.scratch, 8).getBigUint64(0, true); | |
| 960 | + this.runtime.view(this.scratch + 8, 4).setUint32(0, 0, true); | |
| 961 | + this.assertSuccess( | |
| 962 | + "ghostty_cell_get(wide)", | |
| 963 | + this.runtime.call("ghostty_cell_get", rawCell, RAW_CELL_DATA.wide, this.scratch + 8), | |
| 964 | + ); | |
| 965 | + wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true); | |
| 966 | + } | |
| 967 | + cells.push({ | |
| 968 | + text, | |
| 969 | + wide, | |
| 970 | + foreground, | |
| 971 | + background, | |
| 972 | + bold: this.runtime.readField(this.style, "GhosttyStyle", "bold") !== 0, | |
| 973 | + italic: this.runtime.readField(this.style, "GhosttyStyle", "italic") !== 0, | |
| 974 | + invisible: this.runtime.readField(this.style, "GhosttyStyle", "invisible") !== 0, | |
| 975 | + strikethrough: this.runtime.readField(this.style, "GhosttyStyle", "strikethrough") !== 0, | |
| 976 | + overline: this.runtime.readField(this.style, "GhosttyStyle", "overline") !== 0, | |
| 977 | + underline: this.runtime.readField(this.style, "GhosttyStyle", "underline") !== 0, | |
| 978 | + selected: this.getCellBool(cellsIterator, CELL_DATA.selected), | |
| 979 | + }); | |
| 980 | + } | |
| 981 | + while (cells.length < cols) cells.push(this.emptyCell(defaultForeground, defaultBackground)); | |
| 982 | + return { | |
| 983 | + cells, | |
| 984 | + text: cells | |
| 985 | + .map((cell) => cell.text || " ") | |
| 986 | + .join("") | |
| 987 | + .trimEnd(), | |
| 988 | + isWrapContinuation, | |
| 989 | + wrapsToNext, | |
| 990 | + }; | |
| 991 | + } | |
| 992 | + | |
| 993 | + private gridRef(col: number, row: number, tag: 1 | 2 = 1): number { | |
| 994 | + const pointLayout = this.runtime.layout("GhosttyPoint"); | |
| 995 | + const point = this.runtime.alloc(pointLayout.size); | |
| 996 | + this.runtime.setField(point, "GhosttyPoint", "tag", tag); | |
| 997 | + const pointValue = pointLayout.fields.value!; | |
| 998 | + const valueOffset = pointValue.offset; | |
| 999 | + const view = this.runtime.view(point + valueOffset, pointValue.size); | |
| 1000 | + view.setUint16(0, Math.max(0, col), true); | |
| 1001 | + view.setUint32(4, Math.max(0, row), true); | |
| 1002 | + const gridRefSize = this.runtime.layout("GhosttyGridRef").size; | |
| 1003 | + const gridRef = this.runtime.alloc(gridRefSize); | |
| 1004 | + this.runtime.setField(gridRef, "GhosttyGridRef", "size", gridRefSize); | |
| 1005 | + const result = this.runtime.call("ghostty_terminal_grid_ref", this.terminal, point, gridRef); | |
| 1006 | + this.runtime.free(point, pointLayout.size); | |
| 1007 | + if (result !== GHOSTTY_SUCCESS) { | |
| 1008 | + this.runtime.free(gridRef, gridRefSize); | |
| 1009 | + this.assertSuccess("ghostty_terminal_grid_ref", result); | |
| 1010 | + } | |
| 1011 | + return gridRef; | |
| 1012 | + } | |
| 1013 | + | |
| 1014 | + private selectAt( | |
| 1015 | + optionsName: "GhosttyTerminalSelectWordOptions" | "GhosttyTerminalSelectLineOptions", | |
| 1016 | + operation: "ghostty_terminal_select_word" | "ghostty_terminal_select_line", | |
| 1017 | + col: number, | |
| 1018 | + row: number, | |
| 1019 | + ): GhosttySelectionRange | null { | |
| 1020 | + this.ensureActive(); | |
| 1021 | + const optionsLayout = this.runtime.layout(optionsName); | |
| 1022 | + const selectionLayout = this.runtime.layout("GhosttySelection"); | |
| 1023 | + const options = this.runtime.alloc(optionsLayout.size); | |
| 1024 | + let ref = 0; | |
| 1025 | + let selection = 0; | |
| 1026 | + let range: GhosttySelectionRange | null = null; | |
| 1027 | + try { | |
| 1028 | + this.runtime.setField(options, optionsName, "size", optionsLayout.size); | |
| 1029 | + ref = this.gridRef(col, row); | |
| 1030 | + const refField = optionsLayout.fields.ref!; | |
| 1031 | + this.runtime | |
| 1032 | + .bytes(options + refField.offset, refField.size) | |
| 1033 | + .set(this.runtime.bytes(ref, refField.size)); | |
| 1034 | + selection = this.runtime.alloc(selectionLayout.size); | |
| 1035 | + this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size); | |
| 1036 | + const result = this.runtime.call(operation, this.terminal, options, selection); | |
| 1037 | + if (result === GHOSTTY_SUCCESS) { | |
| 1038 | + const start = selection + selectionLayout.fields.start!.offset; | |
| 1039 | + const end = selection + selectionLayout.fields.end!.offset; | |
| 1040 | + const viewportStart = this.pointFromGridRef(start, 1); | |
| 1041 | + const viewportEnd = this.pointFromGridRef(end, 1); | |
| 1042 | + const screenStart = this.pointFromGridRef(start, 2); | |
| 1043 | + const screenEnd = this.pointFromGridRef(end, 2); | |
| 1044 | + if (viewportStart && viewportEnd && screenStart && screenEnd) { | |
| 1045 | + range = { | |
| 1046 | + viewport: { start: viewportStart, end: viewportEnd }, | |
| 1047 | + screen: { start: screenStart, end: screenEnd }, | |
| 1048 | + }; | |
| 1049 | + } | |
| 1050 | + this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); | |
| 1051 | + } | |
| 1052 | + } finally { | |
| 1053 | + this.runtime.free(selection, selectionLayout.size); | |
| 1054 | + this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); | |
| 1055 | + this.runtime.free(options, optionsLayout.size); | |
| 1056 | + } | |
| 1057 | + return range; | |
| 1058 | + } | |
| 1059 | + | |
| 1060 | + private pointFromGridRef(ref: number, tag: 1 | 2): { x: number; y: number } | null { | |
| 1061 | + const coordinateLayout = this.runtime.layout("GhosttyPointCoordinate"); | |
| 1062 | + const coordinate = this.runtime.alloc(coordinateLayout.size); | |
| 1063 | + const result = this.runtime.call( | |
| 1064 | + "ghostty_terminal_point_from_grid_ref", | |
| 1065 | + this.terminal, | |
| 1066 | + ref, | |
| 1067 | + tag, | |
| 1068 | + coordinate, | |
| 1069 | + ); | |
| 1070 | + const point = | |
| 1071 | + result === GHOSTTY_SUCCESS | |
| 1072 | + ? { | |
| 1073 | + x: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "x"), | |
| 1074 | + y: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "y"), | |
| 1075 | + } | |
| 1076 | + : null; | |
| 1077 | + this.runtime.free(coordinate, coordinateLayout.size); | |
| 1078 | + return point; | |
| 1079 | + } | |
| 1080 | + | |
| 1081 | + private getU16(data: number): number { | |
| 1082 | + this.runtime.bytes(this.scratch, 2).fill(0); | |
| 1083 | + this.assertSuccess( | |
| 1084 | + "ghostty_render_state_get", | |
| 1085 | + this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), | |
| 1086 | + ); | |
| 1087 | + return this.runtime.view(this.scratch, 2).getUint16(0, true); | |
| 1088 | + } | |
| 1089 | + | |
| 1090 | + private getU32(data: number): number { | |
| 1091 | + this.runtime.bytes(this.scratch, 4).fill(0); | |
| 1092 | + this.assertSuccess( | |
| 1093 | + "ghostty_render_state_get", | |
| 1094 | + this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), | |
| 1095 | + ); | |
| 1096 | + return this.runtime.view(this.scratch, 4).getUint32(0, true); | |
| 1097 | + } | |
| 1098 | + | |
| 1099 | + private getBool(data: number): boolean { | |
| 1100 | + this.runtime.bytes(this.scratch, 1)[0] = 0; | |
| 1101 | + this.assertSuccess( | |
| 1102 | + "ghostty_render_state_get", | |
| 1103 | + this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), | |
| 1104 | + ); | |
| 1105 | + return this.runtime.bytes(this.scratch, 1)[0] !== 0; | |
| 1106 | + } | |
| 1107 | + | |
| 1108 | + private getColor(data: number, fallback: GhosttyColor): GhosttyColor { | |
| 1109 | + this.runtime.bytes(this.scratch, 3).fill(0); | |
| 1110 | + const result = this.runtime.call( | |
| 1111 | + "ghostty_render_state_get", | |
| 1112 | + this.renderState, | |
| 1113 | + data, | |
| 1114 | + this.scratch, | |
| 1115 | + ); | |
| 1116 | + return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback; | |
| 1117 | + } | |
| 1118 | + | |
| 1119 | + private getRowBool(iterator: number, data: number): boolean { | |
| 1120 | + this.runtime.bytes(this.scratch, 1)[0] = 0; | |
| 1121 | + return ( | |
| 1122 | + this.runtime.call("ghostty_render_state_row_get", iterator, data, this.scratch) === | |
| 1123 | + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 | |
| 1124 | + ); | |
| 1125 | + } | |
| 1126 | + | |
| 1127 | + private getCellU32(iterator: number, data: number): number { | |
| 1128 | + this.runtime.bytes(this.scratch, 4).fill(0); | |
| 1129 | + const result = this.runtime.call( | |
| 1130 | + "ghostty_render_state_row_cells_get", | |
| 1131 | + iterator, | |
| 1132 | + data, | |
| 1133 | + this.scratch, | |
| 1134 | + ); | |
| 1135 | + return result === GHOSTTY_SUCCESS ? this.runtime.view(this.scratch, 4).getUint32(0, true) : 0; | |
| 1136 | + } | |
| 1137 | + | |
| 1138 | + private getCellBool(iterator: number, data: number): boolean { | |
| 1139 | + this.runtime.bytes(this.scratch, 1)[0] = 0; | |
| 1140 | + return ( | |
| 1141 | + this.runtime.call("ghostty_render_state_row_cells_get", iterator, data, this.scratch) === | |
| 1142 | + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 | |
| 1143 | + ); | |
| 1144 | + } | |
| 1145 | + | |
| 1146 | + private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor { | |
| 1147 | + this.runtime.bytes(this.scratch, 3).fill(0); | |
| 1148 | + const result = this.runtime.call( | |
| 1149 | + "ghostty_render_state_row_cells_get", | |
| 1150 | + iterator, | |
| 1151 | + data, | |
| 1152 | + this.scratch, | |
| 1153 | + ); | |
| 1154 | + return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback; | |
| 1155 | + } | |
| 1156 | + | |
| 1157 | + private readColor(pointer: number): GhosttyColor { | |
| 1158 | + const bytes = this.runtime.bytes(pointer, 3); | |
| 1159 | + return { r: bytes[0] ?? 0, g: bytes[1] ?? 0, b: bytes[2] ?? 0 }; | |
| 1160 | + } | |
| 1161 | + | |
| 1162 | + private emptyCell(foreground: GhosttyColor, background: GhosttyColor): GhosttyCell { | |
| 1163 | + return { | |
| 1164 | + text: "", | |
| 1165 | + wide: 0, | |
| 1166 | + foreground, | |
| 1167 | + background, | |
| 1168 | + bold: false, | |
| 1169 | + italic: false, | |
| 1170 | + invisible: false, | |
| 1171 | + strikethrough: false, | |
| 1172 | + overline: false, | |
| 1173 | + underline: false, | |
| 1174 | + selected: false, | |
| 1175 | + }; | |
| 1176 | + } | |
| 1177 | + | |
| 1178 | + private assertSuccess(operation: string, result: number): void { | |
| 1179 | + if (result !== GHOSTTY_SUCCESS) throw new Error(`${operation} failed with result ${result}`); | |
| 1180 | + } | |
| 1181 | + | |
| 1182 | + private ensureActive(): void { | |
| 1183 | + if (this.disposed) throw new Error("libghostty-vt terminal has been disposed"); | |
| 1184 | + } | |
| 1185 | +} | |
| 1186 | + | |
| 1187 | +export function ghosttyColorsEqual(left: GhosttyColor, right: GhosttyColor): boolean { | |
| 1188 | + return sameColor(left, right); | |
| 1189 | +} | |
| @@ -0,0 +1,21 @@ | ||
| 1 | +The MIT License (MIT) | |
| 2 | + | |
| 3 | +Copyright (c) 2014 Ryan L McIntyre | |
| 4 | + | |
| 5 | +Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | +of this software and associated documentation files (the "Software"), to deal | |
| 7 | +in the Software without restriction, including without limitation the rights | |
| 8 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | +copies of the Software, and to permit persons to whom the Software is | |
| 10 | +furnished to do so, subject to the following conditions: | |
| 11 | + | |
| 12 | +The above copyright notice and this permission notice shall be included in all | |
| 13 | +copies or substantial portions of the Software. | |
| 14 | + | |
| 15 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | +SOFTWARE. | |
Binary file not shown.
| @@ -0,0 +1,53 @@ | ||
| 1 | +import { describe, expect, it } from "vite-plus/test"; | |
| 2 | + | |
| 3 | +import { ghosttyKeyForCode, ghosttyUnshiftedCodepoint } from "./keyCodes"; | |
| 4 | + | |
| 5 | +describe("ghosttyKeyForCode", () => { | |
| 6 | + it("keeps the tail of the pinned Ghostty key enum in order", () => { | |
| 7 | + expect(ghosttyKeyForCode("F25")).toBe(ghosttyKeyForCode("F24") + 1); | |
| 8 | + expect(ghosttyKeyForCode("PrintScreen")).toBe(ghosttyKeyForCode("FnLock") + 1); | |
| 9 | + expect(ghosttyKeyForCode("Pause")).toBe(ghosttyKeyForCode("ScrollLock") + 1); | |
| 10 | + expect(ghosttyKeyForCode("Paste")).toBe(ghosttyKeyForCode("Cut") + 1); | |
| 11 | + }); | |
| 12 | +}); | |
| 13 | + | |
| 14 | +describe("ghosttyUnshiftedCodepoint", () => { | |
| 15 | + it("provides the logical base character for Kitty keyboard encoding", () => { | |
| 16 | + expect(ghosttyUnshiftedCodepoint({ code: "KeyC", key: "c", shiftKey: false })).toBe( | |
| 17 | + "c".codePointAt(0), | |
| 18 | + ); | |
| 19 | + expect(ghosttyUnshiftedCodepoint({ code: "KeyC", key: "C", shiftKey: true })).toBe( | |
| 20 | + "c".codePointAt(0), | |
| 21 | + ); | |
| 22 | + expect(ghosttyUnshiftedCodepoint({ code: "Digit1", key: "!", shiftKey: true })).toBe( | |
| 23 | + "1".codePointAt(0), | |
| 24 | + ); | |
| 25 | + expect(ghosttyUnshiftedCodepoint({ code: "Slash", key: "?", shiftKey: true })).toBe( | |
| 26 | + "/".codePointAt(0), | |
| 27 | + ); | |
| 28 | + expect(ghosttyUnshiftedCodepoint({ code: "Digit1", key: "&", shiftKey: false })).toBe( | |
| 29 | + "&".codePointAt(0), | |
| 30 | + ); | |
| 31 | + expect(ghosttyUnshiftedCodepoint({ code: "Enter", key: "Enter", shiftKey: false })).toBe(0); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it("reports unknown instead of the shifted character without layout data", () => { | |
| 35 | + expect(ghosttyUnshiftedCodepoint({ code: "Digit7", key: "/", shiftKey: true })).toBe(0); | |
| 36 | + expect(ghosttyUnshiftedCodepoint({ code: "KeyD", key: "Д", shiftKey: true })).toBe( | |
| 37 | + "д".codePointAt(0), | |
| 38 | + ); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + it("prefers the active browser layout over US physical key positions", () => { | |
| 42 | + const layoutMap = new Map([ | |
| 43 | + ["Digit1", "&"], | |
| 44 | + ["KeyC", "j"], | |
| 45 | + ]); | |
| 46 | + expect(ghosttyUnshiftedCodepoint({ code: "Digit1", key: "1", shiftKey: true }, layoutMap)).toBe( | |
| 47 | + "&".codePointAt(0), | |
| 48 | + ); | |
| 49 | + expect(ghosttyUnshiftedCodepoint({ code: "KeyC", key: "J", shiftKey: true }, layoutMap)).toBe( | |
| 50 | + "j".codePointAt(0), | |
| 51 | + ); | |
| 52 | + }); | |
| 53 | +}); | |
| @@ -0,0 +1,258 @@ | ||
| 1 | +// This order mirrors GhosttyKey in ghostty/vt/key/event.h. The values are | |
| 2 | +// intentionally derived from the official W3C-aligned enum instead of | |
| 3 | +// maintaining a second keyboard protocol. | |
| 4 | +const ghosttyKeyboardCodes = [ | |
| 5 | + "Unidentified", | |
| 6 | + "Backquote", | |
| 7 | + "Backslash", | |
| 8 | + "BracketLeft", | |
| 9 | + "BracketRight", | |
| 10 | + "Comma", | |
| 11 | + "Digit0", | |
| 12 | + "Digit1", | |
| 13 | + "Digit2", | |
| 14 | + "Digit3", | |
| 15 | + "Digit4", | |
| 16 | + "Digit5", | |
| 17 | + "Digit6", | |
| 18 | + "Digit7", | |
| 19 | + "Digit8", | |
| 20 | + "Digit9", | |
| 21 | + "Equal", | |
| 22 | + "IntlBackslash", | |
| 23 | + "IntlRo", | |
| 24 | + "IntlYen", | |
| 25 | + "KeyA", | |
| 26 | + "KeyB", | |
| 27 | + "KeyC", | |
| 28 | + "KeyD", | |
| 29 | + "KeyE", | |
| 30 | + "KeyF", | |
| 31 | + "KeyG", | |
| 32 | + "KeyH", | |
| 33 | + "KeyI", | |
| 34 | + "KeyJ", | |
| 35 | + "KeyK", | |
| 36 | + "KeyL", | |
| 37 | + "KeyM", | |
| 38 | + "KeyN", | |
| 39 | + "KeyO", | |
| 40 | + "KeyP", | |
| 41 | + "KeyQ", | |
| 42 | + "KeyR", | |
| 43 | + "KeyS", | |
| 44 | + "KeyT", | |
| 45 | + "KeyU", | |
| 46 | + "KeyV", | |
| 47 | + "KeyW", | |
| 48 | + "KeyX", | |
| 49 | + "KeyY", | |
| 50 | + "KeyZ", | |
| 51 | + "Minus", | |
| 52 | + "Period", | |
| 53 | + "Quote", | |
| 54 | + "Semicolon", | |
| 55 | + "Slash", | |
| 56 | + "AltLeft", | |
| 57 | + "AltRight", | |
| 58 | + "Backspace", | |
| 59 | + "CapsLock", | |
| 60 | + "ContextMenu", | |
| 61 | + "ControlLeft", | |
| 62 | + "ControlRight", | |
| 63 | + "Enter", | |
| 64 | + "MetaLeft", | |
| 65 | + "MetaRight", | |
| 66 | + "ShiftLeft", | |
| 67 | + "ShiftRight", | |
| 68 | + "Space", | |
| 69 | + "Tab", | |
| 70 | + "Convert", | |
| 71 | + "KanaMode", | |
| 72 | + "NonConvert", | |
| 73 | + "Delete", | |
| 74 | + "End", | |
| 75 | + "Help", | |
| 76 | + "Home", | |
| 77 | + "Insert", | |
| 78 | + "PageDown", | |
| 79 | + "PageUp", | |
| 80 | + "ArrowDown", | |
| 81 | + "ArrowLeft", | |
| 82 | + "ArrowRight", | |
| 83 | + "ArrowUp", | |
| 84 | + "NumLock", | |
| 85 | + "Numpad0", | |
| 86 | + "Numpad1", | |
| 87 | + "Numpad2", | |
| 88 | + "Numpad3", | |
| 89 | + "Numpad4", | |
| 90 | + "Numpad5", | |
| 91 | + "Numpad6", | |
| 92 | + "Numpad7", | |
| 93 | + "Numpad8", | |
| 94 | + "Numpad9", | |
| 95 | + "NumpadAdd", | |
| 96 | + "NumpadBackspace", | |
| 97 | + "NumpadClear", | |
| 98 | + "NumpadClearEntry", | |
| 99 | + "NumpadComma", | |
| 100 | + "NumpadDecimal", | |
| 101 | + "NumpadDivide", | |
| 102 | + "NumpadEnter", | |
| 103 | + "NumpadEqual", | |
| 104 | + "NumpadMemoryAdd", | |
| 105 | + "NumpadMemoryClear", | |
| 106 | + "NumpadMemoryRecall", | |
| 107 | + "NumpadMemoryStore", | |
| 108 | + "NumpadMemorySubtract", | |
| 109 | + "NumpadMultiply", | |
| 110 | + "NumpadParenLeft", | |
| 111 | + "NumpadParenRight", | |
| 112 | + "NumpadSubtract", | |
| 113 | + "NumpadSeparator", | |
| 114 | + "NumpadArrowUp", | |
| 115 | + "NumpadArrowDown", | |
| 116 | + "NumpadArrowRight", | |
| 117 | + "NumpadArrowLeft", | |
| 118 | + "NumpadBegin", | |
| 119 | + "NumpadHome", | |
| 120 | + "NumpadEnd", | |
| 121 | + "NumpadInsert", | |
| 122 | + "NumpadDelete", | |
| 123 | + "NumpadPageUp", | |
| 124 | + "NumpadPageDown", | |
| 125 | + "Escape", | |
| 126 | + "F1", | |
| 127 | + "F2", | |
| 128 | + "F3", | |
| 129 | + "F4", | |
| 130 | + "F5", | |
| 131 | + "F6", | |
| 132 | + "F7", | |
| 133 | + "F8", | |
| 134 | + "F9", | |
| 135 | + "F10", | |
| 136 | + "F11", | |
| 137 | + "F12", | |
| 138 | + "F13", | |
| 139 | + "F14", | |
| 140 | + "F15", | |
| 141 | + "F16", | |
| 142 | + "F17", | |
| 143 | + "F18", | |
| 144 | + "F19", | |
| 145 | + "F20", | |
| 146 | + "F21", | |
| 147 | + "F22", | |
| 148 | + "F23", | |
| 149 | + "F24", | |
| 150 | + "F25", | |
| 151 | + "Fn", | |
| 152 | + "FnLock", | |
| 153 | + "PrintScreen", | |
| 154 | + "ScrollLock", | |
| 155 | + "Pause", | |
| 156 | + "BrowserBack", | |
| 157 | + "BrowserFavorites", | |
| 158 | + "BrowserForward", | |
| 159 | + "BrowserHome", | |
| 160 | + "BrowserRefresh", | |
| 161 | + "BrowserSearch", | |
| 162 | + "BrowserStop", | |
| 163 | + "Eject", | |
| 164 | + "LaunchApp1", | |
| 165 | + "LaunchApp2", | |
| 166 | + "LaunchMail", | |
| 167 | + "MediaPlayPause", | |
| 168 | + "MediaSelect", | |
| 169 | + "MediaStop", | |
| 170 | + "MediaTrackNext", | |
| 171 | + "MediaTrackPrevious", | |
| 172 | + "Power", | |
| 173 | + "Sleep", | |
| 174 | + "AudioVolumeDown", | |
| 175 | + "AudioVolumeMute", | |
| 176 | + "AudioVolumeUp", | |
| 177 | + "WakeUp", | |
| 178 | + "Copy", | |
| 179 | + "Cut", | |
| 180 | + "Paste", | |
| 181 | +] as const; | |
| 182 | + | |
| 183 | +const codeToGhosttyKey = new Map<string, number>( | |
| 184 | + ghosttyKeyboardCodes.map((code, index) => [code, index]), | |
| 185 | +); | |
| 186 | + | |
| 187 | +export function ghosttyKeyForCode(code: string): number { | |
| 188 | + return codeToGhosttyKey.get(code) ?? 0; | |
| 189 | +} | |
| 190 | + | |
| 191 | +export interface GhosttyKeyboardLayoutMap { | |
| 192 | + get(code: string): string | undefined; | |
| 193 | +} | |
| 194 | + | |
| 195 | +const shiftedToUnshiftedCharacter = new Map<string, string>([ | |
| 196 | + ["!", "1"], | |
| 197 | + ["@", "2"], | |
| 198 | + ["#", "3"], | |
| 199 | + ["$", "4"], | |
| 200 | + ["%", "5"], | |
| 201 | + ["^", "6"], | |
| 202 | + ["&", "7"], | |
| 203 | + ["*", "8"], | |
| 204 | + ["(", "9"], | |
| 205 | + [")", "0"], | |
| 206 | + ["~", "`"], | |
| 207 | + ["_", "-"], | |
| 208 | + ["+", "="], | |
| 209 | + ["{", "["], | |
| 210 | + ["}", "]"], | |
| 211 | + ["|", "\\"], | |
| 212 | + [":", ";"], | |
| 213 | + ['"', "'"], | |
| 214 | + ["<", ","], | |
| 215 | + [">", "."], | |
| 216 | + ["?", "/"], | |
| 217 | +]); | |
| 218 | + | |
| 219 | +let keyboardLayoutMapPromise: Promise<GhosttyKeyboardLayoutMap | undefined> | undefined; | |
| 220 | + | |
| 221 | +export function loadGhosttyKeyboardLayoutMap(): Promise<GhosttyKeyboardLayoutMap | undefined> { | |
| 222 | + if (keyboardLayoutMapPromise) return keyboardLayoutMapPromise; | |
| 223 | + const browserNavigator = globalThis.navigator as | |
| 224 | + | (Navigator & { | |
| 225 | + readonly keyboard?: { | |
| 226 | + getLayoutMap(): Promise<GhosttyKeyboardLayoutMap>; | |
| 227 | + }; | |
| 228 | + }) | |
| 229 | + | undefined; | |
| 230 | + const keyboard = browserNavigator?.keyboard; | |
| 231 | + const promise = keyboard?.getLayoutMap().catch(() => undefined) ?? Promise.resolve(undefined); | |
| 232 | + keyboardLayoutMapPromise = promise; | |
| 233 | + return promise; | |
| 234 | +} | |
| 235 | + | |
| 236 | +export function ghosttyUnshiftedCodepoint( | |
| 237 | + event: Pick<KeyboardEvent, "code" | "key" | "shiftKey">, | |
| 238 | + layoutMap?: GhosttyKeyboardLayoutMap, | |
| 239 | +): number { | |
| 240 | + if ([...event.key].length !== 1) return 0; | |
| 241 | + const layoutCharacter = layoutMap?.get(event.code); | |
| 242 | + if (layoutCharacter && [...layoutCharacter].length === 1) { | |
| 243 | + return layoutCharacter.codePointAt(0) ?? 0; | |
| 244 | + } | |
| 245 | + if (/^[A-Z]$/u.test(event.key)) return event.key.charCodeAt(0) + 32; | |
| 246 | + if (event.shiftKey) { | |
| 247 | + const unshiftedCharacter = shiftedToUnshiftedCharacter.get(event.key); | |
| 248 | + if (unshiftedCharacter) return unshiftedCharacter.codePointAt(0) ?? 0; | |
| 249 | + const lowercase = event.key.toLowerCase(); | |
| 250 | + if (lowercase !== event.key && [...lowercase].length === 1) { | |
| 251 | + return lowercase.codePointAt(0) ?? 0; | |
| 252 | + } | |
| 253 | + // Without layout data the unshifted form of a shifted key is unknowable; | |
| 254 | + // reporting the shifted character as unshifted corrupts Kitty alternate keys. | |
| 255 | + return 0; | |
| 256 | + } | |
| 257 | + return event.key.codePointAt(0) ?? 0; | |
| 258 | +} | |
| @@ -0,0 +1,175 @@ | ||
| 1 | +import { describe, expect, it } from "vite-plus/test"; | |
| 2 | + | |
| 3 | +import { GHOSTTY_CELL_WIDE, type GhosttyCell, type GhosttySnapshot } from "./core"; | |
| 4 | +import { | |
| 5 | + ghosttyTextRunEnd, | |
| 6 | + measureGhosttyCell, | |
| 7 | + renderGhosttySnapshot, | |
| 8 | + terminalGridSize, | |
| 9 | +} from "./renderer"; | |
| 10 | + | |
| 11 | +const cell = (text: string, wide = 0): GhosttyCell => ({ | |
| 12 | + text, | |
| 13 | + wide, | |
| 14 | + foreground: { r: 255, g: 255, b: 255 }, | |
| 15 | + background: { r: 0, g: 0, b: 0 }, | |
| 16 | + bold: false, | |
| 17 | + italic: false, | |
| 18 | + invisible: false, | |
| 19 | + strikethrough: false, | |
| 20 | + overline: false, | |
| 21 | + underline: false, | |
| 22 | + selected: false, | |
| 23 | +}); | |
| 24 | + | |
| 25 | +describe("terminalGridSize", () => { | |
| 26 | + it("matches the mobile renderer's cell-and-padding sizing model", () => { | |
| 27 | + expect(terminalGridSize(808, 408, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({ | |
| 28 | + cols: 80, | |
| 29 | + rows: 20, | |
| 30 | + }); | |
| 31 | + }); | |
| 32 | + | |
| 33 | + it("never sends an invalid zero-sized terminal to libghostty", () => { | |
| 34 | + expect(terminalGridSize(0, 0, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({ | |
| 35 | + cols: 1, | |
| 36 | + rows: 1, | |
| 37 | + }); | |
| 38 | + }); | |
| 39 | +}); | |
| 40 | + | |
| 41 | +describe("measureGhosttyCell", () => { | |
| 42 | + it("uses descender-aware metrics and the mobile terminal line-height", () => { | |
| 43 | + const measureText = (text: string) => | |
| 44 | + text === "M" | |
| 45 | + ? { width: 7.2, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 0 } | |
| 46 | + : { width: 14.4, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 3 }; | |
| 47 | + const context = { | |
| 48 | + font: "", | |
| 49 | + measureText, | |
| 50 | + } as unknown as CanvasRenderingContext2D; | |
| 51 | + | |
| 52 | + expect(measureGhosttyCell(context, 12, "monospace")).toEqual({ | |
| 53 | + width: 7.2, | |
| 54 | + height: 16, | |
| 55 | + baseline: 11, | |
| 56 | + }); | |
| 57 | + }); | |
| 58 | +}); | |
| 59 | + | |
| 60 | +describe("ghosttyTextRunEnd", () => { | |
| 61 | + it("includes wide spacer tails in the visual clip without rendering spaces", () => { | |
| 62 | + const cells = [ | |
| 63 | + cell("界", GHOSTTY_CELL_WIDE.wide), | |
| 64 | + cell("", GHOSTTY_CELL_WIDE.spacerTail), | |
| 65 | + cell("🙂", GHOSTTY_CELL_WIDE.wide), | |
| 66 | + cell("", GHOSTTY_CELL_WIDE.spacerTail), | |
| 67 | + cell(""), | |
| 68 | + ]; | |
| 69 | + expect(ghosttyTextRunEnd(cells, 0, () => true)).toBe(4); | |
| 70 | + }); | |
| 71 | +}); | |
| 72 | + | |
| 73 | +describe("renderGhosttySnapshot", () => { | |
| 74 | + it("constrains text runs and cursor glyphs to their terminal cells", () => { | |
| 75 | + const fillTextCalls: unknown[][] = []; | |
| 76 | + const context = { | |
| 77 | + canvas: { width: 200, height: 40 }, | |
| 78 | + beginPath: () => {}, | |
| 79 | + clip: () => {}, | |
| 80 | + fillRect: () => {}, | |
| 81 | + fillText: (...args: unknown[]) => fillTextCalls.push(args), | |
| 82 | + rect: () => {}, | |
| 83 | + resetTransform: () => {}, | |
| 84 | + restore: () => {}, | |
| 85 | + save: () => {}, | |
| 86 | + set fillStyle(_value: string) {}, | |
| 87 | + set font(_value: string) {}, | |
| 88 | + set textBaseline(_value: string) {}, | |
| 89 | + } as unknown as CanvasRenderingContext2D; | |
| 90 | + const cells = [cell("a"), cell("b"), cell("x")]; | |
| 91 | + const snapshot: GhosttySnapshot = { | |
| 92 | + cols: 3, | |
| 93 | + rows: 1, | |
| 94 | + foreground: { r: 255, g: 255, b: 255 }, | |
| 95 | + background: { r: 0, g: 0, b: 0 }, | |
| 96 | + cursor: { r: 255, g: 255, b: 255 }, | |
| 97 | + cursorX: 2, | |
| 98 | + cursorY: 0, | |
| 99 | + cursorVisible: true, | |
| 100 | + cursorBlinking: false, | |
| 101 | + cursorStyle: 1, | |
| 102 | + dirtyRows: new Set([0]), | |
| 103 | + rowData: [{ cells, text: "abx", isWrapContinuation: false, wrapsToNext: false }], | |
| 104 | + }; | |
| 105 | + | |
| 106 | + renderGhosttySnapshot({ | |
| 107 | + context, | |
| 108 | + snapshot, | |
| 109 | + metrics: { width: 7.2, height: 16, baseline: 11 }, | |
| 110 | + fontSize: 12, | |
| 111 | + fontFamily: "monospace", | |
| 112 | + padding: 4, | |
| 113 | + forceFull: false, | |
| 114 | + cursorOn: true, | |
| 115 | + }); | |
| 116 | + | |
| 117 | + expect(fillTextCalls).toEqual([ | |
| 118 | + ["abx", 4, 15, 21.6], | |
| 119 | + ["x", 18.4, 15, 7.2], | |
| 120 | + ]); | |
| 121 | + }); | |
| 122 | + | |
| 123 | + it("repaints the previous cursor row after the cursor moves", () => { | |
| 124 | + const clearedRows: number[] = []; | |
| 125 | + const context = { | |
| 126 | + canvas: { width: 200, height: 80 }, | |
| 127 | + beginPath: () => {}, | |
| 128 | + clip: () => {}, | |
| 129 | + fillRect: (_left: number, top: number, _width: number, height: number) => { | |
| 130 | + if (height === 16) clearedRows.push(top); | |
| 131 | + }, | |
| 132 | + fillText: () => {}, | |
| 133 | + rect: () => {}, | |
| 134 | + resetTransform: () => {}, | |
| 135 | + restore: () => {}, | |
| 136 | + save: () => {}, | |
| 137 | + set fillStyle(_value: string) {}, | |
| 138 | + set font(_value: string) {}, | |
| 139 | + set textBaseline(_value: string) {}, | |
| 140 | + } as unknown as CanvasRenderingContext2D; | |
| 141 | + const snapshot: GhosttySnapshot = { | |
| 142 | + cols: 1, | |
| 143 | + rows: 3, | |
| 144 | + foreground: { r: 255, g: 255, b: 255 }, | |
| 145 | + background: { r: 0, g: 0, b: 0 }, | |
| 146 | + cursor: { r: 255, g: 255, b: 255 }, | |
| 147 | + cursorX: 0, | |
| 148 | + cursorY: 2, | |
| 149 | + cursorVisible: true, | |
| 150 | + cursorBlinking: false, | |
| 151 | + cursorStyle: 1, | |
| 152 | + dirtyRows: new Set(), | |
| 153 | + rowData: [0, 1, 2].map(() => ({ | |
| 154 | + cells: [cell("")], | |
| 155 | + text: "", | |
| 156 | + isWrapContinuation: false, | |
| 157 | + wrapsToNext: false, | |
| 158 | + })), | |
| 159 | + }; | |
| 160 | + | |
| 161 | + renderGhosttySnapshot({ | |
| 162 | + context, | |
| 163 | + snapshot, | |
| 164 | + metrics: { width: 7.2, height: 16, baseline: 11 }, | |
| 165 | + fontSize: 12, | |
| 166 | + fontFamily: "monospace", | |
| 167 | + padding: 4, | |
| 168 | + forceFull: false, | |
| 169 | + cursorOn: true, | |
| 170 | + previousCursorY: 0, | |
| 171 | + }); | |
| 172 | + | |
| 173 | + expect(clearedRows).toEqual([4, 36, 36]); | |
| 174 | + }); | |
| 175 | +}); | |
| @@ -0,0 +1,255 @@ | ||
| 1 | +import { | |
| 2 | + GHOSTTY_CELL_WIDE, | |
| 3 | + ghosttyColorsEqual, | |
| 4 | + type GhosttyCell, | |
| 5 | + type GhosttyColor, | |
| 6 | + type GhosttySnapshot, | |
| 7 | +} from "./core"; | |
| 8 | + | |
| 9 | +export interface GhosttyCellMetrics { | |
| 10 | + readonly width: number; | |
| 11 | + readonly height: number; | |
| 12 | + readonly baseline: number; | |
| 13 | +} | |
| 14 | + | |
| 15 | +const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)"; | |
| 16 | + | |
| 17 | +function cssColor(color: GhosttyColor): string { | |
| 18 | + return `rgb(${color.r}, ${color.g}, ${color.b})`; | |
| 19 | +} | |
| 20 | + | |
| 21 | +function sameTextStyle(left: GhosttyCell, right: GhosttyCell): boolean { | |
| 22 | + // Selection deliberately does not participate: it only tints the background | |
| 23 | + // overlay, and splitting a text run at a selection boundary visibly shifts | |
| 24 | + // glyph spacing whenever the face's true advance differs from the cell width. | |
| 25 | + return ( | |
| 26 | + ghosttyColorsEqual(left.foreground, right.foreground) && | |
| 27 | + left.bold === right.bold && | |
| 28 | + left.italic === right.italic && | |
| 29 | + left.invisible === right.invisible | |
| 30 | + ); | |
| 31 | +} | |
| 32 | + | |
| 33 | +export function ghosttyTextRunEnd( | |
| 34 | + cells: readonly GhosttyCell[], | |
| 35 | + start: number, | |
| 36 | + sameStyle: (cell: GhosttyCell) => boolean, | |
| 37 | +): number { | |
| 38 | + let end = start + 1; | |
| 39 | + while (end < cells.length) { | |
| 40 | + const next = cells[end]; | |
| 41 | + if (!next) break; | |
| 42 | + if (next.wide === GHOSTTY_CELL_WIDE.spacerTail) { | |
| 43 | + end += 1; | |
| 44 | + continue; | |
| 45 | + } | |
| 46 | + if (next.text.length === 0 || !sameStyle(next)) break; | |
| 47 | + end += 1; | |
| 48 | + } | |
| 49 | + return end; | |
| 50 | +} | |
| 51 | + | |
| 52 | +function fontForCell(cell: GhosttyCell, fontSize: number, fontFamily: string): string { | |
| 53 | + const style = cell.italic ? "italic" : "normal"; | |
| 54 | + const weight = cell.bold ? "700" : "400"; | |
| 55 | + return `${style} ${weight} ${fontSize}px ${fontFamily}`; | |
| 56 | +} | |
| 57 | + | |
| 58 | +export function measureGhosttyCell( | |
| 59 | + context: CanvasRenderingContext2D, | |
| 60 | + fontSize: number, | |
| 61 | + fontFamily: string, | |
| 62 | +): GhosttyCellMetrics { | |
| 63 | + context.font = `normal 400 ${fontSize}px ${fontFamily}`; | |
| 64 | + const widthMeasurement = context.measureText("M"); | |
| 65 | + const verticalMeasurement = context.measureText("Mg"); | |
| 66 | + const ascent = verticalMeasurement.actualBoundingBoxAscent || fontSize; | |
| 67 | + const descent = verticalMeasurement.actualBoundingBoxDescent; | |
| 68 | + const glyphHeight = ascent + descent; | |
| 69 | + const height = Math.max(1, Math.round(fontSize * 1.35), Math.ceil(glyphHeight)); | |
| 70 | + return { | |
| 71 | + width: Math.max(1, widthMeasurement.width), | |
| 72 | + height, | |
| 73 | + baseline: Math.round((height - glyphHeight) / 2 + ascent), | |
| 74 | + }; | |
| 75 | +} | |
| 76 | + | |
| 77 | +export function terminalGridSize( | |
| 78 | + width: number, | |
| 79 | + height: number, | |
| 80 | + metrics: GhosttyCellMetrics, | |
| 81 | + padding: number, | |
| 82 | +): { cols: number; rows: number } { | |
| 83 | + return { | |
| 84 | + cols: Math.max(1, Math.floor((width - padding * 2) / metrics.width)), | |
| 85 | + rows: Math.max(1, Math.floor((height - padding * 2) / metrics.height)), | |
| 86 | + }; | |
| 87 | +} | |
| 88 | + | |
| 89 | +export function renderGhosttySnapshot(options: { | |
| 90 | + readonly context: CanvasRenderingContext2D; | |
| 91 | + readonly snapshot: GhosttySnapshot; | |
| 92 | + readonly metrics: GhosttyCellMetrics; | |
| 93 | + readonly fontSize: number; | |
| 94 | + readonly fontFamily: string; | |
| 95 | + readonly padding: number; | |
| 96 | + readonly forceFull: boolean; | |
| 97 | + readonly cursorOn: boolean; | |
| 98 | + readonly previousCursorY?: number | null; | |
| 99 | + readonly focused?: boolean; | |
| 100 | + readonly selectionBackground?: string; | |
| 101 | + /** Vertical origin of row 0; defaults to the horizontal padding. */ | |
| 102 | + readonly originY?: number; | |
| 103 | +}): void { | |
| 104 | + const { | |
| 105 | + context, | |
| 106 | + snapshot, | |
| 107 | + metrics, | |
| 108 | + fontSize, | |
| 109 | + fontFamily, | |
| 110 | + padding, | |
| 111 | + forceFull, | |
| 112 | + cursorOn, | |
| 113 | + previousCursorY, | |
| 114 | + } = options; | |
| 115 | + const focused = options.focused ?? true; | |
| 116 | + const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND; | |
| 117 | + const originY = options.originY ?? padding; | |
| 118 | + const rowsToDraw = forceFull | |
| 119 | + ? Array.from({ length: snapshot.rows }, (_, index) => index) | |
| 120 | + : [...snapshot.dirtyRows]; | |
| 121 | + if ( | |
| 122 | + previousCursorY !== null && | |
| 123 | + previousCursorY !== undefined && | |
| 124 | + previousCursorY >= 0 && | |
| 125 | + !rowsToDraw.includes(previousCursorY) | |
| 126 | + ) { | |
| 127 | + rowsToDraw.push(previousCursorY); | |
| 128 | + } | |
| 129 | + if (snapshot.cursorVisible && snapshot.cursorY >= 0 && !rowsToDraw.includes(snapshot.cursorY)) { | |
| 130 | + rowsToDraw.push(snapshot.cursorY); | |
| 131 | + } | |
| 132 | + | |
| 133 | + if (forceFull) { | |
| 134 | + context.save(); | |
| 135 | + context.resetTransform(); | |
| 136 | + context.fillStyle = cssColor(snapshot.background); | |
| 137 | + context.fillRect(0, 0, context.canvas.width, context.canvas.height); | |
| 138 | + context.restore(); | |
| 139 | + } | |
| 140 | + | |
| 141 | + context.textBaseline = "alphabetic"; | |
| 142 | + for (const rowIndex of rowsToDraw) { | |
| 143 | + const row = snapshot.rowData[rowIndex]; | |
| 144 | + if (!row) continue; | |
| 145 | + const top = originY + rowIndex * metrics.height; | |
| 146 | + | |
| 147 | + context.fillStyle = cssColor(snapshot.background); | |
| 148 | + context.fillRect(padding, top, snapshot.cols * metrics.width, metrics.height); | |
| 149 | + | |
| 150 | + let backgroundStart = 0; | |
| 151 | + while (backgroundStart < row.cells.length) { | |
| 152 | + const first = row.cells[backgroundStart]; | |
| 153 | + if (!first) break; | |
| 154 | + let backgroundEnd = backgroundStart + 1; | |
| 155 | + while (backgroundEnd < row.cells.length) { | |
| 156 | + const next = row.cells[backgroundEnd]; | |
| 157 | + if ( | |
| 158 | + !next || | |
| 159 | + next.selected !== first.selected || | |
| 160 | + !ghosttyColorsEqual(next.background, first.background) | |
| 161 | + ) { | |
| 162 | + break; | |
| 163 | + } | |
| 164 | + backgroundEnd += 1; | |
| 165 | + } | |
| 166 | + if (first.selected || !ghosttyColorsEqual(first.background, snapshot.background)) { | |
| 167 | + const left = padding + backgroundStart * metrics.width; | |
| 168 | + const width = (backgroundEnd - backgroundStart) * metrics.width; | |
| 169 | + if (!ghosttyColorsEqual(first.background, snapshot.background)) { | |
| 170 | + context.fillStyle = cssColor(first.background); | |
| 171 | + context.fillRect(left, top, width, metrics.height); | |
| 172 | + } | |
| 173 | + if (first.selected) { | |
| 174 | + context.fillStyle = selectionBackground; | |
| 175 | + context.fillRect(left, top, width, metrics.height); | |
| 176 | + } | |
| 177 | + } | |
| 178 | + backgroundStart = backgroundEnd; | |
| 179 | + } | |
| 180 | + | |
| 181 | + let runStart = 0; | |
| 182 | + while (runStart < row.cells.length) { | |
| 183 | + const first = row.cells[runStart]; | |
| 184 | + if (!first) break; | |
| 185 | + if (first.text.length === 0) { | |
| 186 | + runStart += 1; | |
| 187 | + continue; | |
| 188 | + } | |
| 189 | + const runEnd = ghosttyTextRunEnd(row.cells, runStart, (cell) => sameTextStyle(cell, first)); | |
| 190 | + const text = row.cells | |
| 191 | + .slice(runStart, runEnd) | |
| 192 | + .map((cell) => cell.text) | |
| 193 | + .join(""); | |
| 194 | + if (!first.invisible && text.trim().length > 0) { | |
| 195 | + context.save(); | |
| 196 | + context.beginPath(); | |
| 197 | + context.rect( | |
| 198 | + padding + runStart * metrics.width, | |
| 199 | + top, | |
| 200 | + (runEnd - runStart) * metrics.width, | |
| 201 | + metrics.height, | |
| 202 | + ); | |
| 203 | + context.clip(); | |
| 204 | + context.font = fontForCell(first, fontSize, fontFamily); | |
| 205 | + context.fillStyle = cssColor(first.foreground); | |
| 206 | + context.fillText( | |
| 207 | + text, | |
| 208 | + padding + runStart * metrics.width, | |
| 209 | + top + metrics.baseline, | |
| 210 | + (runEnd - runStart) * metrics.width, | |
| 211 | + ); | |
| 212 | + context.restore(); | |
| 213 | + } | |
| 214 | + runStart = runEnd; | |
| 215 | + } | |
| 216 | + | |
| 217 | + for (let column = 0; column < row.cells.length; column += 1) { | |
| 218 | + const cell = row.cells[column]; | |
| 219 | + if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline)) continue; | |
| 220 | + context.fillStyle = cssColor(cell.foreground); | |
| 221 | + const left = padding + column * metrics.width; | |
| 222 | + if (cell.underline) context.fillRect(left, top + metrics.height - 2, metrics.width, 1); | |
| 223 | + if (cell.strikethrough) { | |
| 224 | + context.fillRect(left, top + Math.floor(metrics.height * 0.55), metrics.width, 1); | |
| 225 | + } | |
| 226 | + if (cell.overline) context.fillRect(left, top + 1, metrics.width, 1); | |
| 227 | + } | |
| 228 | + } | |
| 229 | + | |
| 230 | + if (cursorOn && snapshot.cursorVisible && snapshot.cursorX >= 0 && snapshot.cursorY >= 0) { | |
| 231 | + const left = padding + snapshot.cursorX * metrics.width; | |
| 232 | + const top = originY + snapshot.cursorY * metrics.height; | |
| 233 | + context.fillStyle = cssColor(snapshot.cursor); | |
| 234 | + if (!focused) { | |
| 235 | + // An unfocused terminal draws a hollow cursor so the active pane is obvious. | |
| 236 | + context.strokeStyle = cssColor(snapshot.cursor); | |
| 237 | + context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); | |
| 238 | + } else if (snapshot.cursorStyle === 0) { | |
| 239 | + context.fillRect(left, top, 2, metrics.height); | |
| 240 | + } else if (snapshot.cursorStyle === 2) { | |
| 241 | + context.fillRect(left, top + metrics.height - 2, metrics.width, 2); | |
| 242 | + } else if (snapshot.cursorStyle === 3) { | |
| 243 | + context.strokeStyle = cssColor(snapshot.cursor); | |
| 244 | + context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); | |
| 245 | + } else { | |
| 246 | + context.fillRect(left, top, metrics.width, metrics.height); | |
| 247 | + const cell = snapshot.rowData[snapshot.cursorY]?.cells[snapshot.cursorX]; | |
| 248 | + if (cell?.text) { | |
| 249 | + context.font = fontForCell(cell, fontSize, fontFamily); | |
| 250 | + context.fillStyle = cssColor(snapshot.background); | |
| 251 | + context.fillText(cell.text, left, top + metrics.baseline, metrics.width); | |
| 252 | + } | |
| 253 | + } | |
| 254 | + } | |
| 255 | +} | |
| @@ -0,0 +1,221 @@ | ||
| 1 | +import ghosttyWasmUrl from "./vendor/ghostty-vt.wasm?url"; | |
| 2 | +import ghosttyWritePtyWasmUrl from "./vendor/ghostty-write-pty.wasm?url&no-inline"; | |
| 3 | + | |
| 4 | +type WasmFunction = (...args: Array<number | bigint>) => number; | |
| 5 | + | |
| 6 | +interface TypeField { | |
| 7 | + readonly offset: number; | |
| 8 | + readonly size: number; | |
| 9 | + readonly type: string; | |
| 10 | +} | |
| 11 | + | |
| 12 | +interface TypeLayout { | |
| 13 | + readonly size: number; | |
| 14 | + readonly align: number; | |
| 15 | + readonly fields: Readonly<Record<string, TypeField>>; | |
| 16 | +} | |
| 17 | + | |
| 18 | +type TypeLayouts = Readonly<Record<string, TypeLayout>>; | |
| 19 | + | |
| 20 | +const textDecoder = new TextDecoder(); | |
| 21 | + | |
| 22 | +export class GhosttyRuntime { | |
| 23 | + readonly memory: WebAssembly.Memory; | |
| 24 | + readonly layouts: TypeLayouts; | |
| 25 | + private readonly exports: WebAssembly.Exports; | |
| 26 | + private readonly ptyWriters = new Map<number, (data: string) => void>(); | |
| 27 | + private nextPtyWriterId = 1; | |
| 28 | + private writePtyFunctionIndex = 0; | |
| 29 | + | |
| 30 | + private constructor(instance: WebAssembly.Instance) { | |
| 31 | + this.exports = instance.exports; | |
| 32 | + const memory = instance.exports.memory; | |
| 33 | + if (!(memory instanceof WebAssembly.Memory)) { | |
| 34 | + throw new Error("libghostty-vt did not export WebAssembly memory"); | |
| 35 | + } | |
| 36 | + this.memory = memory; | |
| 37 | + const jsonPointer = this.call("ghostty_type_json"); | |
| 38 | + const bytes = new Uint8Array(memory.buffer); | |
| 39 | + let end = jsonPointer; | |
| 40 | + while (end < bytes.length && bytes[end] !== 0) end += 1; | |
| 41 | + this.layouts = JSON.parse(textDecoder.decode(bytes.subarray(jsonPointer, end))) as TypeLayouts; | |
| 42 | + } | |
| 43 | + | |
| 44 | + static async load(): Promise<GhosttyRuntime> { | |
| 45 | + const response = await fetch(ghosttyWasmUrl); | |
| 46 | + if (!response.ok) { | |
| 47 | + throw new Error(`Unable to load libghostty-vt (${response.status})`); | |
| 48 | + } | |
| 49 | + let instance: WebAssembly.Instance | undefined; | |
| 50 | + const imports = { | |
| 51 | + env: { | |
| 52 | + log: (pointer: number, length: number) => { | |
| 53 | + if (!instance) return; | |
| 54 | + const memory = instance.exports.memory; | |
| 55 | + if (!(memory instanceof WebAssembly.Memory)) return; | |
| 56 | + const message = textDecoder.decode(new Uint8Array(memory.buffer, pointer, length)); | |
| 57 | + console.debug("[libghostty-vt]", message); | |
| 58 | + }, | |
| 59 | + }, | |
| 60 | + }; | |
| 61 | + const result = await WebAssembly.instantiate(await response.arrayBuffer(), imports); | |
| 62 | + instance = result.instance; | |
| 63 | + const runtime = new GhosttyRuntime(result.instance); | |
| 64 | + await runtime.installWritePtyTrampoline(); | |
| 65 | + return runtime; | |
| 66 | + } | |
| 67 | + | |
| 68 | + call(name: string, ...args: Array<number | bigint>): number { | |
| 69 | + const fn = this.exports[name]; | |
| 70 | + if (typeof fn !== "function") { | |
| 71 | + throw new Error(`libghostty-vt export is unavailable: ${name}`); | |
| 72 | + } | |
| 73 | + return (fn as WasmFunction)(...args); | |
| 74 | + } | |
| 75 | + | |
| 76 | + layout(name: string): TypeLayout { | |
| 77 | + const layout = this.layouts[name]; | |
| 78 | + if (!layout) throw new Error(`libghostty-vt type layout is unavailable: ${name}`); | |
| 79 | + return layout; | |
| 80 | + } | |
| 81 | + | |
| 82 | + alloc(size: number): number { | |
| 83 | + const pointer = this.call("ghostty_wasm_alloc_u8_array", size); | |
| 84 | + if (pointer === 0) throw new Error(`libghostty-vt failed to allocate ${size} bytes`); | |
| 85 | + new Uint8Array(this.memory.buffer, pointer, size).fill(0); | |
| 86 | + return pointer; | |
| 87 | + } | |
| 88 | + | |
| 89 | + free(pointer: number, size: number): void { | |
| 90 | + if (pointer !== 0) this.call("ghostty_wasm_free_u8_array", pointer, size); | |
| 91 | + } | |
| 92 | + | |
| 93 | + allocOpaque(): number { | |
| 94 | + const pointer = this.call("ghostty_wasm_alloc_opaque"); | |
| 95 | + if (pointer === 0) throw new Error("libghostty-vt failed to allocate an opaque pointer"); | |
| 96 | + // The slot is uninitialized until a *_new call writes it; zero it so dispose | |
| 97 | + // paths that run after a partial initialization never free a garbage pointer. | |
| 98 | + new DataView(this.memory.buffer).setUint32(pointer, 0, true); | |
| 99 | + return pointer; | |
| 100 | + } | |
| 101 | + | |
| 102 | + freeOpaque(pointer: number): void { | |
| 103 | + if (pointer !== 0) this.call("ghostty_wasm_free_opaque", pointer); | |
| 104 | + } | |
| 105 | + | |
| 106 | + readPointer(slot: number): number { | |
| 107 | + return new DataView(this.memory.buffer).getUint32(slot, true); | |
| 108 | + } | |
| 109 | + | |
| 110 | + attachPtyWriter(terminal: number, writer: (data: string) => void): number { | |
| 111 | + if (this.writePtyFunctionIndex === 0) { | |
| 112 | + throw new Error("libghostty-vt PTY callback trampoline is unavailable"); | |
| 113 | + } | |
| 114 | + const id = this.nextPtyWriterId++; | |
| 115 | + this.ptyWriters.set(id, writer); | |
| 116 | + this.call("ghostty_terminal_set", terminal, 0, id); | |
| 117 | + this.call("ghostty_terminal_set", terminal, 1, this.writePtyFunctionIndex); | |
| 118 | + return id; | |
| 119 | + } | |
| 120 | + | |
| 121 | + detachPtyWriter(terminal: number, id: number): void { | |
| 122 | + this.call("ghostty_terminal_set", terminal, 1, 0); | |
| 123 | + this.call("ghostty_terminal_set", terminal, 0, 0); | |
| 124 | + this.ptyWriters.delete(id); | |
| 125 | + } | |
| 126 | + | |
| 127 | + view(pointer: number, size?: number): DataView { | |
| 128 | + return new DataView(this.memory.buffer, pointer, size); | |
| 129 | + } | |
| 130 | + | |
| 131 | + bytes(pointer: number, size: number): Uint8Array { | |
| 132 | + return new Uint8Array(this.memory.buffer, pointer, size); | |
| 133 | + } | |
| 134 | + | |
| 135 | + setField(pointer: number, structName: string, fieldName: string, value: number): void { | |
| 136 | + const field = this.layout(structName).fields[fieldName]; | |
| 137 | + if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); | |
| 138 | + const view = this.view(pointer + field.offset, field.size); | |
| 139 | + switch (field.type) { | |
| 140 | + case "bool": | |
| 141 | + case "u8": | |
| 142 | + view.setUint8(0, value); | |
| 143 | + return; | |
| 144 | + case "u16": | |
| 145 | + view.setUint16(0, value, true); | |
| 146 | + return; | |
| 147 | + case "i32": | |
| 148 | + view.setInt32(0, value, true); | |
| 149 | + return; | |
| 150 | + case "u32": | |
| 151 | + case "enum": | |
| 152 | + view.setUint32(0, value, true); | |
| 153 | + return; | |
| 154 | + case "u64": | |
| 155 | + view.setBigUint64(0, BigInt(value), true); | |
| 156 | + return; | |
| 157 | + default: | |
| 158 | + throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); | |
| 159 | + } | |
| 160 | + } | |
| 161 | + | |
| 162 | + readField(pointer: number, structName: string, fieldName: string): number { | |
| 163 | + const field = this.layout(structName).fields[fieldName]; | |
| 164 | + if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); | |
| 165 | + const view = this.view(pointer + field.offset, field.size); | |
| 166 | + switch (field.type) { | |
| 167 | + case "bool": | |
| 168 | + case "u8": | |
| 169 | + return view.getUint8(0); | |
| 170 | + case "u16": | |
| 171 | + return view.getUint16(0, true); | |
| 172 | + case "i32": | |
| 173 | + return view.getInt32(0, true); | |
| 174 | + case "u32": | |
| 175 | + case "enum": | |
| 176 | + return view.getUint32(0, true); | |
| 177 | + case "u64": | |
| 178 | + return Number(view.getBigUint64(0, true)); | |
| 179 | + default: | |
| 180 | + throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); | |
| 181 | + } | |
| 182 | + } | |
| 183 | + | |
| 184 | + private async installWritePtyTrampoline(): Promise<void> { | |
| 185 | + const response = await fetch(ghosttyWritePtyWasmUrl); | |
| 186 | + if (!response.ok) { | |
| 187 | + throw new Error(`Unable to load the libghostty-vt PTY trampoline (${response.status})`); | |
| 188 | + } | |
| 189 | + const result = await WebAssembly.instantiate(await response.arrayBuffer(), { | |
| 190 | + env: { | |
| 191 | + t3_write_pty: (_terminal: number, userdata: number, pointer: number, length: number) => { | |
| 192 | + const writer = this.ptyWriters.get(userdata); | |
| 193 | + if (!writer || length === 0) return; | |
| 194 | + writer(textDecoder.decode(new Uint8Array(this.memory.buffer, pointer, length))); | |
| 195 | + }, | |
| 196 | + }, | |
| 197 | + }); | |
| 198 | + const trampoline = result.instance.exports.ghostty_write_pty; | |
| 199 | + const table = this.exports.__indirect_function_table; | |
| 200 | + if (typeof trampoline !== "function" || !(table instanceof WebAssembly.Table)) { | |
| 201 | + throw new Error("libghostty-vt did not expose its callback table"); | |
| 202 | + } | |
| 203 | + const index = table.length; | |
| 204 | + // grow-then-set instead of grow(1, fn): WebKit stores a grow init value | |
| 205 | + // with broken type information and every later call_indirect through the | |
| 206 | + // entry traps with a signature mismatch. table.set canonicalizes correctly. | |
| 207 | + table.grow(1); | |
| 208 | + table.set(index, trampoline); | |
| 209 | + this.writePtyFunctionIndex = index; | |
| 210 | + } | |
| 211 | +} | |
| 212 | + | |
| 213 | +let runtimePromise: Promise<GhosttyRuntime> | null = null; | |
| 214 | + | |
| 215 | +export function loadGhosttyRuntime(): Promise<GhosttyRuntime> { | |
| 216 | + runtimePromise ??= GhosttyRuntime.load().catch((error) => { | |
| 217 | + runtimePromise = null; | |
| 218 | + throw error; | |
| 219 | + }); | |
| 220 | + return runtimePromise; | |
| 221 | +} | |
| @@ -0,0 +1,561 @@ | ||
| 1 | +import { describe, expect, it } from "vite-plus/test"; | |
| 2 | + | |
| 3 | +import wasmDataUrl from "./vendor/ghostty-vt.wasm?inline"; | |
| 4 | +import writePtyWasmDataUrl from "./vendor/ghostty-write-pty.wasm?inline"; | |
| 5 | +import mobileVersion from "../../../../mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION?raw"; | |
| 6 | +import { ghosttyKeyForCode } from "./keyCodes"; | |
| 7 | + | |
| 8 | +type WasmFunction = (...args: number[]) => number; | |
| 9 | + | |
| 10 | +function decodeWasmDataUrl(dataUrl: string): Uint8Array { | |
| 11 | + const encoded = dataUrl.split(",", 2)[1]; | |
| 12 | + if (!encoded) throw new Error("The vendored Ghostty WASM data URL is invalid"); | |
| 13 | + return Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0)); | |
| 14 | +} | |
| 15 | + | |
| 16 | +describe("vendored libghostty-vt WebAssembly", () => { | |
| 17 | + it("stays pinned to mobile's canonical revision and size budget", async () => { | |
| 18 | + const wasm = decodeWasmDataUrl(wasmDataUrl); | |
| 19 | + expect(wasm.byteLength).toBeLessThan(750_000); | |
| 20 | + | |
| 21 | + // The artifact carries its own provenance: the build embeds the pinned | |
| 22 | + // revision as semver build metadata, so mobile's VERSION file is the | |
| 23 | + // single source of truth and drift is caught here without a copy. | |
| 24 | + const result = await WebAssembly.instantiate(wasm.buffer as ArrayBuffer, { | |
| 25 | + env: { log: () => {} }, | |
| 26 | + }); | |
| 27 | + const instance = result instanceof WebAssembly.Instance ? result : result.instance; | |
| 28 | + const memory = instance.exports.memory as WebAssembly.Memory; | |
| 29 | + const call = (name: string, ...args: number[]) => | |
| 30 | + (instance.exports[name] as WasmFunction)(...args); | |
| 31 | + const out = call("ghostty_wasm_alloc_u8_array", 8); | |
| 32 | + expect(call("ghostty_build_info", 10, out)).toBe(0); | |
| 33 | + const view = new DataView(memory.buffer, out, 8); | |
| 34 | + const embeddedRevision = new TextDecoder().decode( | |
| 35 | + new Uint8Array(memory.buffer, view.getUint32(0, true), view.getUint32(4, true)), | |
| 36 | + ); | |
| 37 | + call("ghostty_wasm_free_u8_array", out, 8); | |
| 38 | + expect(embeddedRevision).toBe(mobileVersion.trim()); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + it("creates, writes multi-codepoint graphemes, and frees repeated terminals", async () => { | |
| 42 | + const bytes = decodeWasmDataUrl(wasmDataUrl); | |
| 43 | + let memory: WebAssembly.Memory | null = null; | |
| 44 | + const instantiated = await WebAssembly.instantiate(bytes.buffer as ArrayBuffer, { | |
| 45 | + env: { | |
| 46 | + log: () => {}, | |
| 47 | + }, | |
| 48 | + }); | |
| 49 | + const instance = | |
| 50 | + instantiated instanceof WebAssembly.Instance ? instantiated : instantiated.instance; | |
| 51 | + const exports = instance.exports; | |
| 52 | + memory = exports.memory as WebAssembly.Memory; | |
| 53 | + const call = (name: string, ...args: number[]) => (exports[name] as WasmFunction)(...args); | |
| 54 | + const jsonPointer = call("ghostty_type_json"); | |
| 55 | + const jsonBytes = new Uint8Array(memory.buffer, jsonPointer); | |
| 56 | + const jsonEnd = jsonBytes.indexOf(0); | |
| 57 | + const layouts = JSON.parse(new TextDecoder().decode(jsonBytes.subarray(0, jsonEnd))) as Record< | |
| 58 | + string, | |
| 59 | + { size: number } | |
| 60 | + >; | |
| 61 | + const optionsSize = layouts.GhosttyTerminalOptions?.size; | |
| 62 | + expect(optionsSize).toBe(8); | |
| 63 | + if (optionsSize === undefined) throw new Error("GhosttyTerminalOptions layout is missing"); | |
| 64 | + | |
| 65 | + for (let iteration = 0; iteration < 25; iteration += 1) { | |
| 66 | + const options = call("ghostty_wasm_alloc_u8_array", optionsSize); | |
| 67 | + const optionsView = new DataView(memory.buffer, options, optionsSize); | |
| 68 | + optionsView.setUint16(0, 80, true); | |
| 69 | + optionsView.setUint16(2, 24, true); | |
| 70 | + optionsView.setUint32(4, 5_000, true); | |
| 71 | + const terminalSlot = call("ghostty_wasm_alloc_opaque"); | |
| 72 | + expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0); | |
| 73 | + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); | |
| 74 | + | |
| 75 | + const input = new TextEncoder().encode("e\u0301 👨👩👧👦 العربية\r\n"); | |
| 76 | + const inputPointer = call("ghostty_wasm_alloc_u8_array", input.length); | |
| 77 | + new Uint8Array(memory.buffer, inputPointer, input.length).set(input); | |
| 78 | + call("ghostty_terminal_vt_write", terminal, inputPointer, input.length); | |
| 79 | + call("ghostty_wasm_free_u8_array", inputPointer, input.length); | |
| 80 | + call("ghostty_terminal_free", terminal); | |
| 81 | + call("ghostty_wasm_free_opaque", terminalSlot); | |
| 82 | + call("ghostty_wasm_free_u8_array", options, optionsSize); | |
| 83 | + } | |
| 84 | + }); | |
| 85 | + | |
| 86 | + it("reports and scrolls the viewport with Ghostty's scrollbar state", async () => { | |
| 87 | + const result = await WebAssembly.instantiate( | |
| 88 | + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, | |
| 89 | + { env: { log: () => {} } }, | |
| 90 | + ); | |
| 91 | + const instance = result instanceof WebAssembly.Instance ? result : result.instance; | |
| 92 | + const memory = instance.exports.memory as WebAssembly.Memory; | |
| 93 | + const call = (name: string, ...args: number[]) => | |
| 94 | + (instance.exports[name] as WasmFunction)(...args); | |
| 95 | + const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size); | |
| 96 | + const options = alloc(8); | |
| 97 | + const optionsView = new DataView(memory.buffer, options, 8); | |
| 98 | + optionsView.setUint16(0, 80, true); | |
| 99 | + optionsView.setUint16(2, 10, true); | |
| 100 | + optionsView.setUint32(4, 1_000, true); | |
| 101 | + const terminalSlot = call("ghostty_wasm_alloc_opaque"); | |
| 102 | + expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0); | |
| 103 | + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); | |
| 104 | + const input = new TextEncoder().encode( | |
| 105 | + Array.from({ length: 50 }, (_, index) => `${index + 1}\r\n`).join(""), | |
| 106 | + ); | |
| 107 | + const inputPointer = alloc(input.length); | |
| 108 | + new Uint8Array(memory.buffer, inputPointer, input.length).set(input); | |
| 109 | + call("ghostty_terminal_vt_write", terminal, inputPointer, input.length); | |
| 110 | + | |
| 111 | + const scrollbar = alloc(24); | |
| 112 | + const scrollbarView = new DataView(memory.buffer, scrollbar, 24); | |
| 113 | + expect(call("ghostty_terminal_get", terminal, 9, scrollbar)).toBe(0); | |
| 114 | + expect([0, 8, 16].map((offset) => Number(scrollbarView.getBigUint64(offset, true)))).toEqual([ | |
| 115 | + 51, 41, 10, | |
| 116 | + ]); | |
| 117 | + | |
| 118 | + const scroll = alloc(24); | |
| 119 | + const scrollView = new DataView(memory.buffer, scroll, 24); | |
| 120 | + scrollView.setUint32(0, 2, true); | |
| 121 | + scrollView.setInt32(8, -5, true); | |
| 122 | + call("ghostty_terminal_scroll_viewport", terminal, scroll); | |
| 123 | + expect(call("ghostty_terminal_get", terminal, 9, scrollbar)).toBe(0); | |
| 124 | + expect(Number(scrollbarView.getBigUint64(8, true))).toBe(36); | |
| 125 | + | |
| 126 | + call("ghostty_wasm_free_u8_array", scroll, 24); | |
| 127 | + call("ghostty_wasm_free_u8_array", scrollbar, 24); | |
| 128 | + call("ghostty_wasm_free_u8_array", inputPointer, input.length); | |
| 129 | + call("ghostty_terminal_free", terminal); | |
| 130 | + call("ghostty_wasm_free_opaque", terminalSlot); | |
| 131 | + call("ghostty_wasm_free_u8_array", options, 8); | |
| 132 | + }); | |
| 133 | + | |
| 134 | + it("routes terminal-generated replies through the shared callback table", async () => { | |
| 135 | + const mainResult = await WebAssembly.instantiate( | |
| 136 | + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, | |
| 137 | + { env: { log: () => {} } }, | |
| 138 | + ); | |
| 139 | + const main = mainResult instanceof WebAssembly.Instance ? mainResult : mainResult.instance; | |
| 140 | + const memory = main.exports.memory as WebAssembly.Memory; | |
| 141 | + let reply = ""; | |
| 142 | + const trampolineResult = await WebAssembly.instantiate( | |
| 143 | + decodeWasmDataUrl(writePtyWasmDataUrl).buffer as ArrayBuffer, | |
| 144 | + { | |
| 145 | + env: { | |
| 146 | + t3_write_pty: (_terminal: number, _userdata: number, pointer: number, length: number) => { | |
| 147 | + reply += new TextDecoder().decode(new Uint8Array(memory.buffer, pointer, length)); | |
| 148 | + }, | |
| 149 | + }, | |
| 150 | + }, | |
| 151 | + ); | |
| 152 | + const trampoline = | |
| 153 | + trampolineResult instanceof WebAssembly.Instance | |
| 154 | + ? trampolineResult | |
| 155 | + : trampolineResult.instance; | |
| 156 | + const table = main.exports.__indirect_function_table as WebAssembly.Table; | |
| 157 | + const callbackIndex = table.length; | |
| 158 | + table.grow(1, trampoline.exports.ghostty_write_pty as CallableFunction); | |
| 159 | + const call = (name: string, ...args: number[]) => (main.exports[name] as WasmFunction)(...args); | |
| 160 | + const options = call("ghostty_wasm_alloc_u8_array", 8); | |
| 161 | + const optionsView = new DataView(memory.buffer, options, 8); | |
| 162 | + optionsView.setUint16(0, 80, true); | |
| 163 | + optionsView.setUint16(2, 24, true); | |
| 164 | + const terminalSlot = call("ghostty_wasm_alloc_opaque"); | |
| 165 | + expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0); | |
| 166 | + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); | |
| 167 | + call("ghostty_terminal_set", terminal, 0, 1); | |
| 168 | + call("ghostty_terminal_set", terminal, 1, callbackIndex); | |
| 169 | + const query = new TextEncoder().encode("\u001b[5n"); | |
| 170 | + const queryPointer = call("ghostty_wasm_alloc_u8_array", query.length); | |
| 171 | + new Uint8Array(memory.buffer, queryPointer, query.length).set(query); | |
| 172 | + call("ghostty_terminal_vt_write", terminal, queryPointer, query.length); | |
| 173 | + | |
| 174 | + expect(reply).toBe("\u001b[0n"); | |
| 175 | + reply = ""; | |
| 176 | + call("ghostty_terminal_set", terminal, 1, 0); | |
| 177 | + call("ghostty_terminal_set", terminal, 0, 0); | |
| 178 | + call("ghostty_terminal_vt_write", terminal, queryPointer, query.length); | |
| 179 | + expect(reply).toBe(""); | |
| 180 | + call("ghostty_terminal_set", terminal, 0, 1); | |
| 181 | + call("ghostty_terminal_set", terminal, 1, callbackIndex); | |
| 182 | + call("ghostty_terminal_vt_write", terminal, queryPointer, query.length); | |
| 183 | + expect(reply).toBe("\u001b[0n"); | |
| 184 | + call("ghostty_wasm_free_u8_array", queryPointer, query.length); | |
| 185 | + call("ghostty_terminal_free", terminal); | |
| 186 | + call("ghostty_wasm_free_opaque", terminalSlot); | |
| 187 | + call("ghostty_wasm_free_u8_array", options, 8); | |
| 188 | + }); | |
| 189 | + | |
| 190 | + it("formats the active selection with Ghostty's copy semantics", async () => { | |
| 191 | + const result = await WebAssembly.instantiate( | |
| 192 | + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, | |
| 193 | + { env: { log: () => {} } }, | |
| 194 | + ); | |
| 195 | + const instance = result instanceof WebAssembly.Instance ? result : result.instance; | |
| 196 | + const memory = instance.exports.memory as WebAssembly.Memory; | |
| 197 | + const call = (name: string, ...args: number[]) => | |
| 198 | + (instance.exports[name] as WasmFunction)(...args); | |
| 199 | + const options = call("ghostty_wasm_alloc_u8_array", 8); | |
| 200 | + const optionsView = new DataView(memory.buffer, options, 8); | |
| 201 | + optionsView.setUint16(0, 80, true); | |
| 202 | + optionsView.setUint16(2, 24, true); | |
| 203 | + const terminalSlot = call("ghostty_wasm_alloc_opaque"); | |
| 204 | + expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0); | |
| 205 | + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); | |
| 206 | + const input = new TextEncoder().encode("a\r\n\r\nb"); | |
| 207 | + const inputPointer = call("ghostty_wasm_alloc_u8_array", input.length); | |
| 208 | + new Uint8Array(memory.buffer, inputPointer, input.length).set(input); | |
| 209 | + call("ghostty_terminal_vt_write", terminal, inputPointer, input.length); | |
| 210 | + | |
| 211 | + const selection = call("ghostty_wasm_alloc_u8_array", 32); | |
| 212 | + new DataView(memory.buffer, selection, 32).setUint32(0, 32, true); | |
| 213 | + expect(call("ghostty_terminal_select_all", terminal, selection)).toBe(0); | |
| 214 | + expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0); | |
| 215 | + const formatOptions = call("ghostty_wasm_alloc_u8_array", 16); | |
| 216 | + const formatView = new DataView(memory.buffer, formatOptions, 16); | |
| 217 | + formatView.setUint32(0, 16, true); | |
| 218 | + formatView.setUint8(8, 1); | |
| 219 | + formatView.setUint8(9, 1); | |
| 220 | + const written = call("ghostty_wasm_alloc_usize"); | |
| 221 | + expect( | |
| 222 | + call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written), | |
| 223 | + ).toBe(-3); | |
| 224 | + const outputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); | |
| 225 | + const output = call("ghostty_wasm_alloc_u8_array", outputSize); | |
| 226 | + expect( | |
| 227 | + call( | |
| 228 | + "ghostty_terminal_selection_format_buf", | |
| 229 | + terminal, | |
| 230 | + formatOptions, | |
| 231 | + output, | |
| 232 | + outputSize, | |
| 233 | + written, | |
| 234 | + ), | |
| 235 | + ).toBe(0); | |
| 236 | + expect(new TextDecoder().decode(new Uint8Array(memory.buffer, output, outputSize))).toBe( | |
| 237 | + "a\n\nb", | |
| 238 | + ); | |
| 239 | + | |
| 240 | + call("ghostty_wasm_free_u8_array", output, outputSize); | |
| 241 | + call("ghostty_wasm_free_usize", written); | |
| 242 | + call("ghostty_wasm_free_u8_array", formatOptions, 16); | |
| 243 | + call("ghostty_wasm_free_u8_array", selection, 32); | |
| 244 | + call("ghostty_wasm_free_u8_array", inputPointer, input.length); | |
| 245 | + call("ghostty_terminal_free", terminal); | |
| 246 | + call("ghostty_wasm_free_opaque", terminalSlot); | |
| 247 | + call("ghostty_wasm_free_u8_array", options, 8); | |
| 248 | + }); | |
| 249 | + | |
| 250 | + it("uses Ghostty for mouse encoding, word selection, and OSC 8 hit testing", async () => { | |
| 251 | + const result = await WebAssembly.instantiate( | |
| 252 | + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, | |
| 253 | + { env: { log: () => {} } }, | |
| 254 | + ); | |
| 255 | + const instance = result instanceof WebAssembly.Instance ? result : result.instance; | |
| 256 | + const memory = instance.exports.memory as WebAssembly.Memory; | |
| 257 | + const call = (name: string, ...args: number[]) => | |
| 258 | + (instance.exports[name] as WasmFunction)(...args); | |
| 259 | + const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size); | |
| 260 | + const free = (pointer: number, size: number) => | |
| 261 | + call("ghostty_wasm_free_u8_array", pointer, size); | |
| 262 | + | |
| 263 | + const terminalOptions = alloc(8); | |
| 264 | + const terminalOptionsView = new DataView(memory.buffer, terminalOptions, 8); | |
| 265 | + terminalOptionsView.setUint16(0, 80, true); | |
| 266 | + terminalOptionsView.setUint16(2, 24, true); | |
| 267 | + const terminalSlot = call("ghostty_wasm_alloc_opaque"); | |
| 268 | + expect(call("ghostty_terminal_new", 0, terminalSlot, terminalOptions)).toBe(0); | |
| 269 | + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); | |
| 270 | + const input = new TextEncoder().encode( | |
| 271 | + "\u001b[?1000h\u001b[?1006h\u001b]8;;https://t3.codes/docs\u001b\\linked\u001b]8;;\u001b\\ plain", | |
| 272 | + ); | |
| 273 | + const inputPointer = alloc(input.length); | |
| 274 | + new Uint8Array(memory.buffer, inputPointer, input.length).set(input); | |
| 275 | + call("ghostty_terminal_vt_write", terminal, inputPointer, input.length); | |
| 276 | + | |
| 277 | + const modeFlag = alloc(1); | |
| 278 | + new Uint8Array(memory.buffer, modeFlag, 1)[0] = 0; | |
| 279 | + expect(call("ghostty_terminal_get", terminal, 11, modeFlag)).toBe(0); | |
| 280 | + expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(1); | |
| 281 | + new Uint8Array(memory.buffer, modeFlag, 1)[0] = 1; | |
| 282 | + expect(call("ghostty_terminal_mode_get", terminal, 1003, modeFlag)).toBe(0); | |
| 283 | + expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(0); | |
| 284 | + const anyEventInput = new TextEncoder().encode("\u001b[?1003h"); | |
| 285 | + const anyEventPointer = alloc(anyEventInput.length); | |
| 286 | + new Uint8Array(memory.buffer, anyEventPointer, anyEventInput.length).set(anyEventInput); | |
| 287 | + call("ghostty_terminal_vt_write", terminal, anyEventPointer, anyEventInput.length); | |
| 288 | + expect(call("ghostty_terminal_mode_get", terminal, 1003, modeFlag)).toBe(0); | |
| 289 | + expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(1); | |
| 290 | + const anyEventReset = new TextEncoder().encode("\u001b[?1003l\u001b[?1000h"); | |
| 291 | + const anyEventResetPointer = alloc(anyEventReset.length); | |
| 292 | + new Uint8Array(memory.buffer, anyEventResetPointer, anyEventReset.length).set(anyEventReset); | |
| 293 | + call("ghostty_terminal_vt_write", terminal, anyEventResetPointer, anyEventReset.length); | |
| 294 | + expect(call("ghostty_terminal_mode_get", terminal, 1003, modeFlag)).toBe(0); | |
| 295 | + expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(0); | |
| 296 | + free(anyEventResetPointer, anyEventReset.length); | |
| 297 | + free(anyEventPointer, anyEventInput.length); | |
| 298 | + free(modeFlag, 1); | |
| 299 | + | |
| 300 | + const point = alloc(24); | |
| 301 | + const pointView = new DataView(memory.buffer, point, 24); | |
| 302 | + pointView.setUint32(0, 1, true); | |
| 303 | + pointView.setUint16(8, 1, true); | |
| 304 | + pointView.setUint32(12, 0, true); | |
| 305 | + const gridRef = alloc(12); | |
| 306 | + new DataView(memory.buffer, gridRef, 12).setUint32(0, 12, true); | |
| 307 | + expect(call("ghostty_terminal_grid_ref", terminal, point, gridRef)).toBe(0); | |
| 308 | + | |
| 309 | + const written = call("ghostty_wasm_alloc_usize"); | |
| 310 | + expect(call("ghostty_grid_ref_hyperlink_uri", gridRef, 0, 0, written)).toBe(-3); | |
| 311 | + const hyperlinkSize = new DataView(memory.buffer, written, 4).getUint32(0, true); | |
| 312 | + const hyperlink = alloc(hyperlinkSize); | |
| 313 | + expect(call("ghostty_grid_ref_hyperlink_uri", gridRef, hyperlink, hyperlinkSize, written)).toBe( | |
| 314 | + 0, | |
| 315 | + ); | |
| 316 | + expect(new TextDecoder().decode(new Uint8Array(memory.buffer, hyperlink, hyperlinkSize))).toBe( | |
| 317 | + "https://t3.codes/docs", | |
| 318 | + ); | |
| 319 | + | |
| 320 | + const wordOptions = alloc(24); | |
| 321 | + const wordOptionsView = new DataView(memory.buffer, wordOptions, 24); | |
| 322 | + wordOptionsView.setUint32(0, 24, true); | |
| 323 | + new Uint8Array(memory.buffer, wordOptions + 4, 12).set( | |
| 324 | + new Uint8Array(memory.buffer, gridRef, 12), | |
| 325 | + ); | |
| 326 | + const selection = alloc(32); | |
| 327 | + new DataView(memory.buffer, selection, 32).setUint32(0, 32, true); | |
| 328 | + expect(call("ghostty_terminal_select_word", terminal, wordOptions, selection)).toBe(0); | |
| 329 | + expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0); | |
| 330 | + const formatOptions = alloc(16); | |
| 331 | + const formatView = new DataView(memory.buffer, formatOptions, 16); | |
| 332 | + formatView.setUint32(0, 16, true); | |
| 333 | + formatView.setUint8(8, 1); | |
| 334 | + formatView.setUint8(9, 1); | |
| 335 | + expect( | |
| 336 | + call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written), | |
| 337 | + ).toBe(-3); | |
| 338 | + const selectionSize = new DataView(memory.buffer, written, 4).getUint32(0, true); | |
| 339 | + const selectionText = alloc(selectionSize); | |
| 340 | + expect( | |
| 341 | + call( | |
| 342 | + "ghostty_terminal_selection_format_buf", | |
| 343 | + terminal, | |
| 344 | + formatOptions, | |
| 345 | + selectionText, | |
| 346 | + selectionSize, | |
| 347 | + written, | |
| 348 | + ), | |
| 349 | + ).toBe(0); | |
| 350 | + expect( | |
| 351 | + new TextDecoder().decode(new Uint8Array(memory.buffer, selectionText, selectionSize)), | |
| 352 | + ).toBe("linked"); | |
| 353 | + | |
| 354 | + const lineOptions = alloc(28); | |
| 355 | + const lineOptionsView = new DataView(memory.buffer, lineOptions, 28); | |
| 356 | + lineOptionsView.setUint32(0, 28, true); | |
| 357 | + new Uint8Array(memory.buffer, lineOptions + 4, 12).set( | |
| 358 | + new Uint8Array(memory.buffer, gridRef, 12), | |
| 359 | + ); | |
| 360 | + expect(call("ghostty_terminal_select_line", terminal, lineOptions, selection)).toBe(0); | |
| 361 | + expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0); | |
| 362 | + expect( | |
| 363 | + call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written), | |
| 364 | + ).toBe(-3); | |
| 365 | + const lineSelectionSize = new DataView(memory.buffer, written, 4).getUint32(0, true); | |
| 366 | + const lineSelectionText = alloc(lineSelectionSize); | |
| 367 | + expect( | |
| 368 | + call( | |
| 369 | + "ghostty_terminal_selection_format_buf", | |
| 370 | + terminal, | |
| 371 | + formatOptions, | |
| 372 | + lineSelectionText, | |
| 373 | + lineSelectionSize, | |
| 374 | + written, | |
| 375 | + ), | |
| 376 | + ).toBe(0); | |
| 377 | + expect( | |
| 378 | + new TextDecoder().decode(new Uint8Array(memory.buffer, lineSelectionText, lineSelectionSize)), | |
| 379 | + ).toBe("linked plain"); | |
| 380 | + | |
| 381 | + const mouseEncoderSlot = call("ghostty_wasm_alloc_opaque"); | |
| 382 | + const mouseEventSlot = call("ghostty_wasm_alloc_opaque"); | |
| 383 | + expect(call("ghostty_mouse_encoder_new", 0, mouseEncoderSlot)).toBe(0); | |
| 384 | + expect(call("ghostty_mouse_event_new", 0, mouseEventSlot)).toBe(0); | |
| 385 | + const view = new DataView(memory.buffer); | |
| 386 | + const mouseEncoder = view.getUint32(mouseEncoderSlot, true); | |
| 387 | + const mouseEvent = view.getUint32(mouseEventSlot, true); | |
| 388 | + call("ghostty_mouse_encoder_setopt_from_terminal", mouseEncoder, terminal); | |
| 389 | + const mouseSize = alloc(36); | |
| 390 | + const mouseSizeView = new DataView(memory.buffer, mouseSize, 36); | |
| 391 | + for (const [offset, value] of [ | |
| 392 | + [0, 36], | |
| 393 | + [4, 800], | |
| 394 | + [8, 480], | |
| 395 | + [12, 10], | |
| 396 | + [16, 20], | |
| 397 | + ] as const) { | |
| 398 | + mouseSizeView.setUint32(offset, value, true); | |
| 399 | + } | |
| 400 | + call("ghostty_mouse_encoder_setopt", mouseEncoder, 2, mouseSize); | |
| 401 | + call("ghostty_mouse_event_set_action", mouseEvent, 0); | |
| 402 | + call("ghostty_mouse_event_set_button", mouseEvent, 1); | |
| 403 | + const mousePosition = new DataView(new ArrayBuffer(8)); | |
| 404 | + mousePosition.setFloat32(0, 15, true); | |
| 405 | + mousePosition.setFloat32(4, 25, true); | |
| 406 | + const mousePositionPointer = alloc(8); | |
| 407 | + new Uint8Array(memory.buffer, mousePositionPointer, 8).set( | |
| 408 | + new Uint8Array(mousePosition.buffer), | |
| 409 | + ); | |
| 410 | + call("ghostty_mouse_event_set_position", mouseEvent, mousePositionPointer); | |
| 411 | + const mouseOutput = alloc(128); | |
| 412 | + expect( | |
| 413 | + call("ghostty_mouse_encoder_encode", mouseEncoder, mouseEvent, mouseOutput, 128, written), | |
| 414 | + ).toBe(0); | |
| 415 | + const mouseOutputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); | |
| 416 | + expect( | |
| 417 | + new TextDecoder().decode(new Uint8Array(memory.buffer, mouseOutput, mouseOutputSize)), | |
| 418 | + ).toBe("\u001b[<0;2;2M"); | |
| 419 | + | |
| 420 | + call("ghostty_mouse_event_free", mouseEvent); | |
| 421 | + call("ghostty_mouse_encoder_free", mouseEncoder); | |
| 422 | + call("ghostty_wasm_free_opaque", mouseEventSlot); | |
| 423 | + call("ghostty_wasm_free_opaque", mouseEncoderSlot); | |
| 424 | + free(mousePositionPointer, 8); | |
| 425 | + free(mouseOutput, 128); | |
| 426 | + free(mouseSize, 36); | |
| 427 | + free(lineSelectionText, lineSelectionSize); | |
| 428 | + free(lineOptions, 28); | |
| 429 | + free(selectionText, selectionSize); | |
| 430 | + free(formatOptions, 16); | |
| 431 | + free(selection, 32); | |
| 432 | + free(wordOptions, 24); | |
| 433 | + free(hyperlink, hyperlinkSize); | |
| 434 | + call("ghostty_wasm_free_usize", written); | |
| 435 | + free(gridRef, 12); | |
| 436 | + free(point, 24); | |
| 437 | + free(inputPointer, input.length); | |
| 438 | + call("ghostty_terminal_free", terminal); | |
| 439 | + call("ghostty_wasm_free_opaque", terminalSlot); | |
| 440 | + free(terminalOptions, 8); | |
| 441 | + }); | |
| 442 | + | |
| 443 | + it("encodes modified printable keys in Kitty keyboard mode", async () => { | |
| 444 | + const result = await WebAssembly.instantiate( | |
| 445 | + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, | |
| 446 | + { env: { log: () => {} } }, | |
| 447 | + ); | |
| 448 | + const instance = result instanceof WebAssembly.Instance ? result : result.instance; | |
| 449 | + const memory = instance.exports.memory as WebAssembly.Memory; | |
| 450 | + const call = (name: string, ...args: number[]) => | |
| 451 | + (instance.exports[name] as WasmFunction)(...args); | |
| 452 | + const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size); | |
| 453 | + const free = (pointer: number, size: number) => | |
| 454 | + call("ghostty_wasm_free_u8_array", pointer, size); | |
| 455 | + | |
| 456 | + const terminalOptions = alloc(8); | |
| 457 | + const terminalOptionsView = new DataView(memory.buffer, terminalOptions, 8); | |
| 458 | + terminalOptionsView.setUint16(0, 80, true); | |
| 459 | + terminalOptionsView.setUint16(2, 24, true); | |
| 460 | + const terminalSlot = call("ghostty_wasm_alloc_opaque"); | |
| 461 | + expect(call("ghostty_terminal_new", 0, terminalSlot, terminalOptions)).toBe(0); | |
| 462 | + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); | |
| 463 | + const kittyMode = new TextEncoder().encode("\u001b[>1u"); | |
| 464 | + const kittyModePointer = alloc(kittyMode.length); | |
| 465 | + new Uint8Array(memory.buffer, kittyModePointer, kittyMode.length).set(kittyMode); | |
| 466 | + call("ghostty_terminal_vt_write", terminal, kittyModePointer, kittyMode.length); | |
| 467 | + | |
| 468 | + const encoderSlot = call("ghostty_wasm_alloc_opaque"); | |
| 469 | + const eventSlot = call("ghostty_wasm_alloc_opaque"); | |
| 470 | + expect(call("ghostty_key_encoder_new", 0, encoderSlot)).toBe(0); | |
| 471 | + expect(call("ghostty_key_event_new", 0, eventSlot)).toBe(0); | |
| 472 | + const view = new DataView(memory.buffer); | |
| 473 | + const keyEncoder = view.getUint32(encoderSlot, true); | |
| 474 | + const keyEvent = view.getUint32(eventSlot, true); | |
| 475 | + call("ghostty_key_encoder_setopt_from_terminal", keyEncoder, terminal); | |
| 476 | + call("ghostty_key_event_set_action", keyEvent, 1); | |
| 477 | + call("ghostty_key_event_set_key", keyEvent, ghosttyKeyForCode("KeyC")); | |
| 478 | + call("ghostty_key_event_set_mods", keyEvent, 1 << 1); | |
| 479 | + call("ghostty_key_event_set_consumed_mods", keyEvent, 0); | |
| 480 | + call("ghostty_key_event_set_composing", keyEvent, 0); | |
| 481 | + call("ghostty_key_event_set_unshifted_codepoint", keyEvent, "c".codePointAt(0)!); | |
| 482 | + const text = new TextEncoder().encode("c"); | |
| 483 | + const textPointer = alloc(text.length); | |
| 484 | + new Uint8Array(memory.buffer, textPointer, text.length).set(text); | |
| 485 | + call("ghostty_key_event_set_utf8", keyEvent, textPointer, text.length); | |
| 486 | + | |
| 487 | + const written = call("ghostty_wasm_alloc_usize"); | |
| 488 | + expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(-3); | |
| 489 | + const outputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); | |
| 490 | + const output = alloc(outputSize); | |
| 491 | + expect( | |
| 492 | + call("ghostty_key_encoder_encode", keyEncoder, keyEvent, output, outputSize, written), | |
| 493 | + ).toBe(0); | |
| 494 | + const outputLength = new DataView(memory.buffer, written, 4).getUint32(0, true); | |
| 495 | + expect(new TextDecoder().decode(new Uint8Array(memory.buffer, output, outputLength))).toBe( | |
| 496 | + "\u001b[99;5u", | |
| 497 | + ); | |
| 498 | + | |
| 499 | + const remappedText = new TextEncoder().encode("j"); | |
| 500 | + const remappedTextPointer = alloc(remappedText.length); | |
| 501 | + new Uint8Array(memory.buffer, remappedTextPointer, remappedText.length).set(remappedText); | |
| 502 | + call("ghostty_key_event_set_unshifted_codepoint", keyEvent, "j".codePointAt(0)!); | |
| 503 | + call("ghostty_key_event_set_utf8", keyEvent, remappedTextPointer, remappedText.length); | |
| 504 | + expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(-3); | |
| 505 | + const remappedOutputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); | |
| 506 | + const remappedOutput = alloc(remappedOutputSize); | |
| 507 | + expect( | |
| 508 | + call( | |
| 509 | + "ghostty_key_encoder_encode", | |
| 510 | + keyEncoder, | |
| 511 | + keyEvent, | |
| 512 | + remappedOutput, | |
| 513 | + remappedOutputSize, | |
| 514 | + written, | |
| 515 | + ), | |
| 516 | + ).toBe(0); | |
| 517 | + const remappedOutputLength = new DataView(memory.buffer, written, 4).getUint32(0, true); | |
| 518 | + expect( | |
| 519 | + new TextDecoder().decode(new Uint8Array(memory.buffer, remappedOutput, remappedOutputLength)), | |
| 520 | + ).toBe("\u001b[106;5u"); | |
| 521 | + | |
| 522 | + // Without the Kitty report-event-types flag a release encodes nothing, so | |
| 523 | + // the surface's keyup handler stays silent for legacy sessions. | |
| 524 | + call("ghostty_key_event_set_action", keyEvent, 0); | |
| 525 | + expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(0); | |
| 526 | + expect(new DataView(memory.buffer, written, 4).getUint32(0, true)).toBe(0); | |
| 527 | + | |
| 528 | + // With report-event-types enabled the same release encodes an event-typed code. | |
| 529 | + const reportEvents = new TextEncoder().encode("\u001b[>3u"); | |
| 530 | + const reportEventsPointer = alloc(reportEvents.length); | |
| 531 | + new Uint8Array(memory.buffer, reportEventsPointer, reportEvents.length).set(reportEvents); | |
| 532 | + call("ghostty_terminal_vt_write", terminal, reportEventsPointer, reportEvents.length); | |
| 533 | + call("ghostty_key_encoder_setopt_from_terminal", keyEncoder, terminal); | |
| 534 | + expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(-3); | |
| 535 | + const releaseSize = new DataView(memory.buffer, written, 4).getUint32(0, true); | |
| 536 | + const releaseOutput = alloc(releaseSize); | |
| 537 | + expect( | |
| 538 | + call("ghostty_key_encoder_encode", keyEncoder, keyEvent, releaseOutput, releaseSize, written), | |
| 539 | + ).toBe(0); | |
| 540 | + const releaseLength = new DataView(memory.buffer, written, 4).getUint32(0, true); | |
| 541 | + expect( | |
| 542 | + new TextDecoder().decode(new Uint8Array(memory.buffer, releaseOutput, releaseLength)), | |
| 543 | + ).toBe("\u001b[106;5:3u"); | |
| 544 | + free(releaseOutput, releaseSize); | |
| 545 | + free(reportEventsPointer, reportEvents.length); | |
| 546 | + | |
| 547 | + free(remappedOutput, remappedOutputSize); | |
| 548 | + free(remappedTextPointer, remappedText.length); | |
| 549 | + free(output, outputSize); | |
| 550 | + call("ghostty_wasm_free_usize", written); | |
| 551 | + free(textPointer, text.length); | |
| 552 | + call("ghostty_key_event_free", keyEvent); | |
| 553 | + call("ghostty_key_encoder_free", keyEncoder); | |
| 554 | + call("ghostty_wasm_free_opaque", eventSlot); | |
| 555 | + call("ghostty_wasm_free_opaque", encoderSlot); | |
| 556 | + free(kittyModePointer, kittyMode.length); | |
| 557 | + call("ghostty_terminal_free", terminal); | |
| 558 | + call("ghostty_wasm_free_opaque", terminalSlot); | |
| 559 | + free(terminalOptions, 8); | |
| 560 | + }); | |
| 561 | +}); | |
| @@ -0,0 +1,383 @@ | ||
| 1 | +import { describe, expect, it } from "vite-plus/test"; | |
| 2 | + | |
| 3 | +import type { GhosttyCell, GhosttyRow } from "./core"; | |
| 4 | +import { | |
| 5 | + DEFAULT_TERMINAL_FONT_FAMILY, | |
| 6 | + DEFAULT_TERMINAL_FONT_SIZE, | |
| 7 | + advanceTerminalSelectionClickSequence, | |
| 8 | + ghosttyMouseButton, | |
| 9 | + isTerminalAltGraphText, | |
| 10 | + isTerminalCompositionCommitInput, | |
| 11 | + isTerminalCopyShortcut, | |
| 12 | + isTerminalLinkPointerGesture, | |
| 13 | + isTerminalPasteShortcut, | |
| 14 | + shouldReportTerminalMouse, | |
| 15 | + terminalScrollbarGeometry, | |
| 16 | + terminalScrollbarOffsetAtPointer, | |
| 17 | + terminalLinkAtColumn, | |
| 18 | + terminalLinkAtPosition, | |
| 19 | + terminalContentOriginY, | |
| 20 | + terminalFontFamily, | |
| 21 | + terminalFontSize, | |
| 22 | + terminalWheelArrowData, | |
| 23 | + terminalWheelDeltaRows, | |
| 24 | +} from "./surface"; | |
| 25 | + | |
| 26 | +const cell = (text: string): GhosttyCell => ({ | |
| 27 | + text, | |
| 28 | + wide: 0, | |
| 29 | + foreground: { r: 255, g: 255, b: 255 }, | |
| 30 | + background: { r: 0, g: 0, b: 0 }, | |
| 31 | + bold: false, | |
| 32 | + italic: false, | |
| 33 | + invisible: false, | |
| 34 | + strikethrough: false, | |
| 35 | + overline: false, | |
| 36 | + underline: false, | |
| 37 | + selected: false, | |
| 38 | +}); | |
| 39 | + | |
| 40 | +describe("isTerminalAltGraphText", () => { | |
| 41 | + it("defers printable AltGr output to the textarea input event", () => { | |
| 42 | + expect( | |
| 43 | + isTerminalAltGraphText({ | |
| 44 | + key: "@", | |
| 45 | + getModifierState: (modifier) => modifier === "AltGraph", | |
| 46 | + }), | |
| 47 | + ).toBe(true); | |
| 48 | + expect( | |
| 49 | + isTerminalAltGraphText({ | |
| 50 | + key: "ArrowRight", | |
| 51 | + getModifierState: (modifier) => modifier === "AltGraph", | |
| 52 | + }), | |
| 53 | + ).toBe(false); | |
| 54 | + }); | |
| 55 | +}); | |
| 56 | + | |
| 57 | +describe("terminalLinkAtColumn", () => { | |
| 58 | + it("maps terminal cells to UTF-16 offsets after a wide emoji", () => { | |
| 59 | + const cells = [ | |
| 60 | + cell("🙂"), | |
| 61 | + cell(""), | |
| 62 | + ...Array.from("https://t3.codes", (character) => cell(character)), | |
| 63 | + ]; | |
| 64 | + const row: GhosttyRow = { | |
| 65 | + cells, | |
| 66 | + text: cells | |
| 67 | + .map((value) => value.text || " ") | |
| 68 | + .join("") | |
| 69 | + .trimEnd(), | |
| 70 | + isWrapContinuation: false, | |
| 71 | + wrapsToNext: false, | |
| 72 | + }; | |
| 73 | + | |
| 74 | + expect(terminalLinkAtColumn(row, 2)).toBe("https://t3.codes"); | |
| 75 | + expect(terminalLinkAtColumn(row, cells.length - 1)).toBe("https://t3.codes"); | |
| 76 | + expect(terminalLinkAtColumn(row, 0)).toBeNull(); | |
| 77 | + }); | |
| 78 | + | |
| 79 | + it("uses shared path matching and reconstructs soft-wrapped links", () => { | |
| 80 | + const row = (text: string, isWrapContinuation: boolean, wrapsToNext = false): GhosttyRow => ({ | |
| 81 | + cells: Array.from(text.padEnd(16), (character) => cell(character)), | |
| 82 | + text: text.trimEnd(), | |
| 83 | + isWrapContinuation, | |
| 84 | + wrapsToNext, | |
| 85 | + }); | |
| 86 | + const rows = [ | |
| 87 | + row("https://example.", false), | |
| 88 | + row("com/reference", true), | |
| 89 | + row("~/project/file", false), | |
| 90 | + row("C:\\repo\\file.ts", false), | |
| 91 | + ]; | |
| 92 | + | |
| 93 | + expect(terminalLinkAtPosition(rows, 0, 8)).toBe("https://example.com/reference"); | |
| 94 | + expect(terminalLinkAtPosition(rows, 1, 4)).toBe("https://example.com/reference"); | |
| 95 | + expect(terminalLinkAtPosition(rows, 2, 2)).toBe("~/project/file"); | |
| 96 | + expect(terminalLinkAtPosition(rows, 3, 4)).toBe("C:\\repo\\file.ts"); | |
| 97 | + }); | |
| 98 | + | |
| 99 | + it("refuses links truncated at the viewport edges instead of mis-resolving", () => { | |
| 100 | + const row = (text: string, isWrapContinuation: boolean, wrapsToNext = false): GhosttyRow => ({ | |
| 101 | + cells: Array.from(text.padEnd(16), (character) => cell(character)), | |
| 102 | + text: text.trimEnd(), | |
| 103 | + isWrapContinuation, | |
| 104 | + wrapsToNext, | |
| 105 | + }); | |
| 106 | + // The head of the wrapped line scrolled above the viewport. | |
| 107 | + const headCut = [row("ple.com/missing", true), row("head", true)]; | |
| 108 | + expect(terminalLinkAtPosition(headCut, 0, 4)).toBeNull(); | |
| 109 | + // The bottom row soft-wraps on below the viewport. | |
| 110 | + const tailCut = [row("https://t3.codes", false, true)]; | |
| 111 | + expect(terminalLinkAtPosition(tailCut, 0, 8)).toBeNull(); | |
| 112 | + // A partial bottom row is provably complete and still resolves. | |
| 113 | + const complete = [row("https://t3.codes", false), row("", false)]; | |
| 114 | + expect(terminalLinkAtPosition(complete, 0, 8)).toBe("https://t3.codes"); | |
| 115 | + // A wide grapheme earlier in the row must not break truncation detection: | |
| 116 | + // the soft-wrap flag decides, not string-length-versus-cell-count. | |
| 117 | + const wideFull: GhosttyRow = { | |
| 118 | + cells: [ | |
| 119 | + { ...cell("🙂"), wide: 1 }, | |
| 120 | + { ...cell(""), wide: 2 }, | |
| 121 | + ...Array.from("https://t3.code", (character) => cell(character)), | |
| 122 | + ], | |
| 123 | + text: "🙂 https://t3.code", | |
| 124 | + isWrapContinuation: false, | |
| 125 | + wrapsToNext: true, | |
| 126 | + }; | |
| 127 | + expect(terminalLinkAtPosition([wideFull], 0, 8)).toBeNull(); | |
| 128 | + // Unwritten trailing cells prove the bottom row is complete. | |
| 129 | + const unwrittenTail: GhosttyRow = { | |
| 130 | + cells: [ | |
| 131 | + ...Array.from("https://t3.codes", (character) => cell(character)), | |
| 132 | + cell(""), | |
| 133 | + cell(""), | |
| 134 | + ], | |
| 135 | + text: "https://t3.codes", | |
| 136 | + isWrapContinuation: false, | |
| 137 | + wrapsToNext: false, | |
| 138 | + }; | |
| 139 | + expect(terminalLinkAtPosition([unwrittenTail], 0, 8)).toBe("https://t3.codes"); | |
| 140 | + }); | |
| 141 | +}); | |
| 142 | + | |
| 143 | +describe("isTerminalCopyShortcut", () => { | |
| 144 | + const event = (overrides: Partial<Parameters<typeof isTerminalCopyShortcut>[0]> = {}) => ({ | |
| 145 | + ctrlKey: false, | |
| 146 | + key: "c", | |
| 147 | + metaKey: false, | |
| 148 | + shiftKey: false, | |
| 149 | + ...overrides, | |
| 150 | + }); | |
| 151 | + | |
| 152 | + it("keeps Ctrl+C available for SIGINT on macOS", () => { | |
| 153 | + expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "MacIntel")).toBe(false); | |
| 154 | + expect(isTerminalCopyShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); | |
| 155 | + }); | |
| 156 | + | |
| 157 | + it("uses the conventional Ctrl+Shift+C shortcut elsewhere", () => { | |
| 158 | + expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(false); | |
| 159 | + expect(isTerminalCopyShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe( | |
| 160 | + true, | |
| 161 | + ); | |
| 162 | + }); | |
| 163 | + | |
| 164 | + it("uses the produced character instead of the physical key position", () => { | |
| 165 | + expect(isTerminalCopyShortcut(event({ key: "C", metaKey: true }), "MacIntel")).toBe(true); | |
| 166 | + expect(isTerminalCopyShortcut(event({ key: "j", metaKey: true }), "MacIntel")).toBe(false); | |
| 167 | + }); | |
| 168 | +}); | |
| 169 | + | |
| 170 | +describe("isTerminalPasteShortcut", () => { | |
| 171 | + const event = (overrides: Partial<Parameters<typeof isTerminalPasteShortcut>[0]> = {}) => ({ | |
| 172 | + ctrlKey: false, | |
| 173 | + key: "v", | |
| 174 | + metaKey: false, | |
| 175 | + shiftKey: false, | |
| 176 | + ...overrides, | |
| 177 | + }); | |
| 178 | + | |
| 179 | + it("uses Cmd+V on macOS", () => { | |
| 180 | + expect(isTerminalPasteShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); | |
| 181 | + expect(isTerminalPasteShortcut(event({ ctrlKey: true }), "MacIntel")).toBe(false); | |
| 182 | + }); | |
| 183 | + | |
| 184 | + it("preserves Ctrl+V and uses Ctrl+Shift+V elsewhere", () => { | |
| 185 | + expect(isTerminalPasteShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(false); | |
| 186 | + expect(isTerminalPasteShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe( | |
| 187 | + true, | |
| 188 | + ); | |
| 189 | + }); | |
| 190 | +}); | |
| 191 | + | |
| 192 | +describe("isTerminalCompositionCommitInput", () => { | |
| 193 | + it("identifies browser composition follow-up input", () => { | |
| 194 | + expect(isTerminalCompositionCommitInput({ inputType: "" })).toBe(true); | |
| 195 | + expect(isTerminalCompositionCommitInput({ inputType: "insertCompositionText" })).toBe(true); | |
| 196 | + expect(isTerminalCompositionCommitInput({ inputType: "insertFromComposition" })).toBe(true); | |
| 197 | + }); | |
| 198 | + | |
| 199 | + it("keeps a fast repeated input as legitimate text", () => { | |
| 200 | + expect(isTerminalCompositionCommitInput({ inputType: "insertText" })).toBe(false); | |
| 201 | + }); | |
| 202 | +}); | |
| 203 | + | |
| 204 | +describe("application mouse reporting", () => { | |
| 205 | + const event = (overrides: Partial<Parameters<typeof shouldReportTerminalMouse>[1]> = {}) => ({ | |
| 206 | + ctrlKey: false, | |
| 207 | + metaKey: false, | |
| 208 | + shiftKey: false, | |
| 209 | + ...overrides, | |
| 210 | + }); | |
| 211 | + | |
| 212 | + it("keeps Shift and link activation modifiers available to the browser", () => { | |
| 213 | + expect(shouldReportTerminalMouse(true, event())).toBe(true); | |
| 214 | + expect(shouldReportTerminalMouse(true, event({ shiftKey: true }))).toBe(false); | |
| 215 | + expect(shouldReportTerminalMouse(true, event({ ctrlKey: true }))).toBe(false); | |
| 216 | + expect(shouldReportTerminalMouse(true, event({ metaKey: true }))).toBe(false); | |
| 217 | + expect(shouldReportTerminalMouse(false, event())).toBe(false); | |
| 218 | + }); | |
| 219 | + | |
| 220 | + it("maps browser buttons to Ghostty's button enum", () => { | |
| 221 | + expect([0, 1, 2, 3, 4, 5].map(ghosttyMouseButton)).toEqual([1, 3, 2, 4, 5, null]); | |
| 222 | + }); | |
| 223 | +}); | |
| 224 | + | |
| 225 | +describe("terminal font resolution", () => { | |
| 226 | + it("keeps the glyph fallbacks behind a custom text face", () => { | |
| 227 | + expect(terminalFontFamily()).toBe(DEFAULT_TERMINAL_FONT_FAMILY); | |
| 228 | + expect(terminalFontFamily(" ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY); | |
| 229 | + const custom = terminalFontFamily('"Fira Code"'); | |
| 230 | + expect(custom.startsWith('"Fira Code", ')).toBe(true); | |
| 231 | + expect(custom).toContain('"Symbols Nerd Font Mono"'); | |
| 232 | + expect(custom.endsWith("monospace")).toBe(true); | |
| 233 | + }); | |
| 234 | + | |
| 235 | + it("clamps requested font sizes to the supported range", () => { | |
| 236 | + expect(terminalFontSize()).toBe(DEFAULT_TERMINAL_FONT_SIZE); | |
| 237 | + expect(terminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE); | |
| 238 | + expect(terminalFontSize(13.4)).toBe(13); | |
| 239 | + expect(terminalFontSize(2)).toBe(6); | |
| 240 | + expect(terminalFontSize(90)).toBe(32); | |
| 241 | + }); | |
| 242 | +}); | |
| 243 | + | |
| 244 | +describe("terminalContentOriginY", () => { | |
| 245 | + it("stays top-anchored like a fresh terminal until scrollback exists", () => { | |
| 246 | + expect(terminalContentOriginY(100, 4, 5, 16, false)).toBe(4); | |
| 247 | + }); | |
| 248 | + | |
| 249 | + it("pins the grid to the bottom by moving the sub-row slack above row 0", () => { | |
| 250 | + // 100px mount, 4px padding, 5 rows of 16px: 92 - 80 = 12px slack on top. | |
| 251 | + expect(terminalContentOriginY(100, 4, 5, 16, true)).toBe(16); | |
| 252 | + // Exact fit keeps the origin at the padding. | |
| 253 | + expect(terminalContentOriginY(88, 4, 5, 16, true)).toBe(4); | |
| 254 | + // A mount smaller than the grid never pushes the origin above the padding. | |
| 255 | + expect(terminalContentOriginY(80, 4, 5, 16, true)).toBe(4); | |
| 256 | + }); | |
| 257 | + | |
| 258 | + it("keeps the prompt stationary while a drag crosses row boundaries", () => { | |
| 259 | + // Growing the mount pixel by pixel: the bottom edge (origin + rows*cell) | |
| 260 | + // tracks the mount bottom exactly until a new row fits. | |
| 261 | + for (let height = 88; height < 104; height += 1) { | |
| 262 | + const rows = Math.max(1, Math.floor((height - 8) / 16)); | |
| 263 | + const origin = terminalContentOriginY(height, 4, rows, 16, true); | |
| 264 | + expect(origin + rows * 16).toBe(height - 4); | |
| 265 | + } | |
| 266 | + }); | |
| 267 | +}); | |
| 268 | + | |
| 269 | +describe("terminalWheelDeltaRows", () => { | |
| 270 | + it("converts line-mode deltas into whole rows", () => { | |
| 271 | + const result = terminalWheelDeltaRows({ deltaY: 3, deltaMode: 1 }, 16, 24, 0); | |
| 272 | + expect(result.rows).toBe(3); | |
| 273 | + expect(result.remainder).toBe(0); | |
| 274 | + }); | |
| 275 | + | |
| 276 | + it("accumulates fractional pixel deltas across events", () => { | |
| 277 | + let remainder = 0; | |
| 278 | + let scrolled = 0; | |
| 279 | + for (let index = 0; index < 4; index += 1) { | |
| 280 | + const result = terminalWheelDeltaRows({ deltaY: 5, deltaMode: 0 }, 16, 24, remainder); | |
| 281 | + remainder = result.remainder; | |
| 282 | + scrolled += result.rows; | |
| 283 | + } | |
| 284 | + expect(scrolled).toBe(1); | |
| 285 | + expect(remainder).toBeCloseTo(4 / 16); | |
| 286 | + }); | |
| 287 | + | |
| 288 | + it("keeps direction for negative page-mode deltas", () => { | |
| 289 | + const result = terminalWheelDeltaRows({ deltaY: -1, deltaMode: 2 }, 16, 24, 0); | |
| 290 | + expect(result.rows).toBe(-24); | |
| 291 | + expect(result.remainder).toBe(0); | |
| 292 | + }); | |
| 293 | +}); | |
| 294 | + | |
| 295 | +describe("terminalWheelArrowData", () => { | |
| 296 | + it("emits one arrow per row honoring application cursor keys", () => { | |
| 297 | + expect(terminalWheelArrowData(-2, false)).toBe("\u001b[A\u001b[A"); | |
| 298 | + expect(terminalWheelArrowData(3, false)).toBe("\u001b[B\u001b[B\u001b[B"); | |
| 299 | + expect(terminalWheelArrowData(-1, true)).toBe("\u001bOA"); | |
| 300 | + expect(terminalWheelArrowData(0, true)).toBe(""); | |
| 301 | + }); | |
| 302 | +}); | |
| 303 | + | |
| 304 | +describe("isTerminalLinkPointerGesture", () => { | |
| 305 | + it("uses Command on macOS and Control elsewhere", () => { | |
| 306 | + expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "MacIntel")).toBe(true); | |
| 307 | + expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "MacIntel")).toBe(false); | |
| 308 | + expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "Linux x86_64")).toBe( | |
| 309 | + true, | |
| 310 | + ); | |
| 311 | + expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "Linux x86_64")).toBe( | |
| 312 | + false, | |
| 313 | + ); | |
| 314 | + }); | |
| 315 | +}); | |
| 316 | + | |
| 317 | +describe("advanceTerminalSelectionClickSequence", () => { | |
| 318 | + it("recognizes stationary double and triple pointer presses without PointerEvent.detail", () => { | |
| 319 | + const first = advanceTerminalSelectionClickSequence(null, { | |
| 320 | + clientX: 20, | |
| 321 | + clientY: 30, | |
| 322 | + timeStamp: 1_000, | |
| 323 | + }); | |
| 324 | + const second = advanceTerminalSelectionClickSequence(first, { | |
| 325 | + clientX: 22, | |
| 326 | + clientY: 29, | |
| 327 | + timeStamp: 1_200, | |
| 328 | + }); | |
| 329 | + const third = advanceTerminalSelectionClickSequence(second, { | |
| 330 | + clientX: 21, | |
| 331 | + clientY: 31, | |
| 332 | + timeStamp: 1_400, | |
| 333 | + }); | |
| 334 | + | |
| 335 | + expect([first.count, second.count, third.count]).toEqual([1, 2, 3]); | |
| 336 | + }); | |
| 337 | + | |
| 338 | + it("starts over after movement, delay, or a completed triple click", () => { | |
| 339 | + const previous = { count: 3, time: 1_000, x: 20, y: 30 }; | |
| 340 | + expect( | |
| 341 | + advanceTerminalSelectionClickSequence(previous, { | |
| 342 | + clientX: 20, | |
| 343 | + clientY: 30, | |
| 344 | + timeStamp: 1_100, | |
| 345 | + }).count, | |
| 346 | + ).toBe(1); | |
| 347 | + expect( | |
| 348 | + advanceTerminalSelectionClickSequence(previous, { | |
| 349 | + clientX: 30, | |
| 350 | + clientY: 30, | |
| 351 | + timeStamp: 1_100, | |
| 352 | + }).count, | |
| 353 | + ).toBe(1); | |
| 354 | + expect( | |
| 355 | + advanceTerminalSelectionClickSequence(previous, { | |
| 356 | + clientX: 20, | |
| 357 | + clientY: 30, | |
| 358 | + timeStamp: 1_501, | |
| 359 | + }).count, | |
| 360 | + ).toBe(1); | |
| 361 | + }); | |
| 362 | +}); | |
| 363 | + | |
| 364 | +describe("terminal scrollbar", () => { | |
| 365 | + it("maps Ghostty's viewport state to a proportional thumb", () => { | |
| 366 | + expect(terminalScrollbarGeometry({ total: 100, offset: 40, len: 20 }, 200)).toEqual({ | |
| 367 | + thumbHeight: 40, | |
| 368 | + thumbTop: 80, | |
| 369 | + maxOffset: 80, | |
| 370 | + }); | |
| 371 | + expect(terminalScrollbarGeometry({ total: 100, offset: 0, len: 100 }, 200)).toBeNull(); | |
| 372 | + }); | |
| 373 | + | |
| 374 | + it("keeps the thumb usable for large scrollback and maps dragging back to rows", () => { | |
| 375 | + const state = { total: 10_000, offset: 0, len: 20 }; | |
| 376 | + expect(terminalScrollbarGeometry(state, 200)).toEqual({ | |
| 377 | + thumbHeight: 18, | |
| 378 | + thumbTop: 0, | |
| 379 | + maxOffset: 9_980, | |
| 380 | + }); | |
| 381 | + expect(terminalScrollbarOffsetAtPointer(state, 200, 191, 9)).toBe(9_980); | |
| 382 | + }); | |
| 383 | +}); | |
| @@ -0,0 +1,1393 @@ | ||
| 1 | +import { isMacPlatform } from "../../lib/utils"; | |
| 2 | +import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links"; | |
| 3 | +import { | |
| 4 | + GhosttyTerminalCore, | |
| 5 | + type GhosttyScrollbar, | |
| 6 | + type GhosttySnapshot, | |
| 7 | + type GhosttyTheme, | |
| 8 | +} from "./core"; | |
| 9 | +import { | |
| 10 | + measureGhosttyCell, | |
| 11 | + renderGhosttySnapshot, | |
| 12 | + terminalGridSize, | |
| 13 | + type GhosttyCellMetrics, | |
| 14 | +} from "./renderer"; | |
| 15 | +import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; | |
| 16 | + | |
| 17 | +export const DEFAULT_TERMINAL_FONT_SIZE = 12; | |
| 18 | +const MIN_TERMINAL_FONT_SIZE = 6; | |
| 19 | +const MAX_TERMINAL_FONT_SIZE = 32; | |
| 20 | +// The glyph fallbacks only supply symbols the text faces are missing (powerline | |
| 21 | +// separators, devicons, and other private-use prompt symbols), so shells | |
| 22 | +// configured for a locally installed Nerd Font keep their prompt glyphs no | |
| 23 | +// matter which text face is active. | |
| 24 | +const TERMINAL_GLYPH_FALLBACKS = | |
| 25 | + '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono Nerd Font", ' + | |
| 26 | + '"JetBrainsMono NF", "FiraCode Nerd Font", "Hack Nerd Font", "MesloLGS NF", ' + | |
| 27 | + '"CaskaydiaCove Nerd Font", "PowerlineSymbols", monospace'; | |
| 28 | +// SF Mono where the platform has it (macOS), otherwise the bundled JetBrains | |
| 29 | +// Mono webfont, so the default rendering is identical everywhere else. | |
| 30 | +export const DEFAULT_TERMINAL_FONT_FAMILY = | |
| 31 | + '"SF Mono", "SFMono-Regular", "JetBrains Mono", ' + TERMINAL_GLYPH_FALLBACKS; | |
| 32 | +const CONTENT_PADDING = 4; | |
| 33 | +const MIN_SCROLLBAR_THUMB_HEIGHT = 18; | |
| 34 | + | |
| 35 | +/** Requested terminal font; omitted fields fall back to the defaults. */ | |
| 36 | +export interface GhosttyTerminalFont { | |
| 37 | + readonly family?: string; | |
| 38 | + readonly size?: number; | |
| 39 | +} | |
| 40 | + | |
| 41 | +let symbolsFontLoad: Promise<void> | null = null; | |
| 42 | + | |
| 43 | +/** | |
| 44 | + * Register the bundled symbols-only Nerd Font once per page. It loads lazily | |
| 45 | + * with the first terminal, and because it carries no regular text glyphs it | |
| 46 | + * composes with any text face without changing metrics — prompt symbols and | |
| 47 | + * devicons render even on machines without a locally installed Nerd Font. | |
| 48 | + */ | |
| 49 | +function ensureTerminalSymbolsFont(): Promise<void> { | |
| 50 | + if (symbolsFontLoad !== null) return symbolsFontLoad; | |
| 51 | + symbolsFontLoad = (async () => { | |
| 52 | + try { | |
| 53 | + const face = new FontFace("Symbols Nerd Font Mono", `url(${symbolsFontUrl})`); | |
| 54 | + document.fonts.add(await face.load()); | |
| 55 | + } catch { | |
| 56 | + // Locally installed fallback faces still apply. | |
| 57 | + } | |
| 58 | + })(); | |
| 59 | + return symbolsFontLoad; | |
| 60 | +} | |
| 61 | + | |
| 62 | +export function terminalFontFamily(family?: string): string { | |
| 63 | + const custom = family?.trim(); | |
| 64 | + if (!custom) return DEFAULT_TERMINAL_FONT_FAMILY; | |
| 65 | + // A custom face keeps the glyph fallbacks so prompt symbols stay covered. | |
| 66 | + return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; | |
| 67 | +} | |
| 68 | + | |
| 69 | +export function terminalFontSize(size?: number): number { | |
| 70 | + if (size === undefined || !Number.isFinite(size)) return DEFAULT_TERMINAL_FONT_SIZE; | |
| 71 | + return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size))); | |
| 72 | +} | |
| 73 | + | |
| 74 | +/** | |
| 75 | + * Vertical origin of the grid inside the mount. While content is shorter than | |
| 76 | + * the viewport the grid sits at the top like a fresh terminal. Once scrollback | |
| 77 | + * exists the prompt lives on the bottom row, so the grid anchors to the bottom | |
| 78 | + * edge instead: the sub-row remainder moves above row 0 and resizing within a | |
| 79 | + * row boundary keeps the prompt pinned instead of snapping up and down. | |
| 80 | + */ | |
| 81 | +export function terminalContentOriginY( | |
| 82 | + mountHeight: number, | |
| 83 | + padding: number, | |
| 84 | + rows: number, | |
| 85 | + cellHeight: number, | |
| 86 | + anchorBottom: boolean, | |
| 87 | +): number { | |
| 88 | + if (!anchorBottom) return padding; | |
| 89 | + const slack = mountHeight - padding * 2 - rows * cellHeight; | |
| 90 | + return padding + Math.max(0, slack); | |
| 91 | +} | |
| 92 | + | |
| 93 | +export interface TerminalScrollbarGeometry { | |
| 94 | + readonly thumbHeight: number; | |
| 95 | + readonly thumbTop: number; | |
| 96 | + readonly maxOffset: number; | |
| 97 | +} | |
| 98 | + | |
| 99 | +export function terminalScrollbarGeometry( | |
| 100 | + state: GhosttyScrollbar, | |
| 101 | + trackHeight: number, | |
| 102 | +): TerminalScrollbarGeometry | null { | |
| 103 | + const total = Math.max(0, state.total); | |
| 104 | + const len = Math.max(0, Math.min(state.len, total)); | |
| 105 | + const maxOffset = Math.max(0, total - len); | |
| 106 | + if (trackHeight <= 0 || len <= 0 || maxOffset === 0) return null; | |
| 107 | + const thumbHeight = Math.min( | |
| 108 | + trackHeight, | |
| 109 | + Math.max(MIN_SCROLLBAR_THUMB_HEIGHT, (trackHeight * len) / total), | |
| 110 | + ); | |
| 111 | + const travel = Math.max(0, trackHeight - thumbHeight); | |
| 112 | + const offset = Math.max(0, Math.min(state.offset, maxOffset)); | |
| 113 | + return { | |
| 114 | + thumbHeight, | |
| 115 | + thumbTop: travel * (offset / maxOffset), | |
| 116 | + maxOffset, | |
| 117 | + }; | |
| 118 | +} | |
| 119 | + | |
| 120 | +export function terminalScrollbarOffsetAtPointer( | |
| 121 | + state: GhosttyScrollbar, | |
| 122 | + trackHeight: number, | |
| 123 | + pointerY: number, | |
| 124 | + pointerOffset: number, | |
| 125 | +): number { | |
| 126 | + const geometry = terminalScrollbarGeometry(state, trackHeight); | |
| 127 | + if (geometry === null) return 0; | |
| 128 | + const travel = Math.max(0, trackHeight - geometry.thumbHeight); | |
| 129 | + if (travel === 0) return 0; | |
| 130 | + const thumbTop = Math.max(0, Math.min(pointerY - pointerOffset, travel)); | |
| 131 | + return Math.round((thumbTop / travel) * geometry.maxOffset); | |
| 132 | +} | |
| 133 | + | |
| 134 | +function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { | |
| 135 | + const text = row.cells.map((cell) => cell.text || " ").join(""); | |
| 136 | + return trimRight ? text.trimEnd() : text; | |
| 137 | +} | |
| 138 | + | |
| 139 | +function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: number): number { | |
| 140 | + let offset = 0; | |
| 141 | + for (let cellIndex = 0; cellIndex < column; cellIndex += 1) { | |
| 142 | + offset += row.cells[cellIndex]?.text.length || 1; | |
| 143 | + } | |
| 144 | + return offset; | |
| 145 | +} | |
| 146 | + | |
| 147 | +export function terminalLinkAtPosition( | |
| 148 | + rows: GhosttySnapshot["rowData"], | |
| 149 | + rowIndex: number, | |
| 150 | + column: number, | |
| 151 | +): string | null { | |
| 152 | + const wrappedLine = collectWrappedTerminalLinkLine(rowIndex + 1, (index) => { | |
| 153 | + const row = rows[index]; | |
| 154 | + if (!row) return null; | |
| 155 | + return { | |
| 156 | + isWrapped: row.isWrapContinuation, | |
| 157 | + translateToString: (trimRight = false) => terminalRowText(row, trimRight), | |
| 158 | + }; | |
| 159 | + }); | |
| 160 | + if (!wrappedLine) return null; | |
| 161 | + // Only viewport rows are available: a wrapped line whose head scrolled above | |
| 162 | + // the viewport would resolve a truncated match into a wrong link. | |
| 163 | + const firstSegment = wrappedLine.segments[0]; | |
| 164 | + if (firstSegment && rows[firstSegment.bufferLineNumber - 1]?.isWrapContinuation) { | |
| 165 | + return null; | |
| 166 | + } | |
| 167 | + const segment = wrappedLine.segments.find((value) => value.bufferLineNumber === rowIndex + 1); | |
| 168 | + const row = rows[rowIndex]; | |
| 169 | + if (!segment || !row) return null; | |
| 170 | + const lastSegment = wrappedLine.segments.at(-1); | |
| 171 | + const lastRow = lastSegment ? rows[lastSegment.bufferLineNumber - 1] : undefined; | |
| 172 | + // Ghostty's soft-wrap flag is authoritative: when the last collected row | |
| 173 | + // still wraps onward, its continuation is outside the viewport. | |
| 174 | + const continuesBelowViewport = lastRow !== undefined && lastRow.wrapsToNext; | |
| 175 | + const offset = segment.startIndex + terminalColumnOffset(row, column); | |
| 176 | + for (const match of extractTerminalLinks(wrappedLine.text)) { | |
| 177 | + if (offset >= match.start && offset < match.end) { | |
| 178 | + // A truncated tail must not activate as a complete link. | |
| 179 | + if (match.end === wrappedLine.text.length && continuesBelowViewport) return null; | |
| 180 | + return match.text; | |
| 181 | + } | |
| 182 | + } | |
| 183 | + return null; | |
| 184 | +} | |
| 185 | + | |
| 186 | +export function terminalLinkAtColumn(row: GhosttySnapshot["rowData"][number], column: number) { | |
| 187 | + return terminalLinkAtPosition([row], 0, column); | |
| 188 | +} | |
| 189 | + | |
| 190 | +export function isTerminalCopyShortcut( | |
| 191 | + event: Pick<KeyboardEvent, "ctrlKey" | "key" | "metaKey" | "shiftKey">, | |
| 192 | + platform = navigator.platform, | |
| 193 | +) { | |
| 194 | + if (event.key.toLowerCase() !== "c") return false; | |
| 195 | + return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; | |
| 196 | +} | |
| 197 | + | |
| 198 | +export function isTerminalPasteShortcut( | |
| 199 | + event: Pick<KeyboardEvent, "ctrlKey" | "key" | "metaKey" | "shiftKey">, | |
| 200 | + platform = navigator.platform, | |
| 201 | +) { | |
| 202 | + if (event.key.toLowerCase() !== "v") return false; | |
| 203 | + return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; | |
| 204 | +} | |
| 205 | + | |
| 206 | +export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputType">): boolean { | |
| 207 | + return ( | |
| 208 | + event.inputType === "" || | |
| 209 | + event.inputType === "insertCompositionText" || | |
| 210 | + event.inputType === "insertFromComposition" | |
| 211 | + ); | |
| 212 | +} | |
| 213 | + | |
| 214 | +export function isTerminalAltGraphText( | |
| 215 | + event: Pick<KeyboardEvent, "getModifierState" | "key">, | |
| 216 | +): boolean { | |
| 217 | + return event.getModifierState("AltGraph") && [...event.key].length === 1; | |
| 218 | +} | |
| 219 | + | |
| 220 | +export function shouldReportTerminalMouse( | |
| 221 | + tracking: boolean, | |
| 222 | + event: Pick<MouseEvent, "ctrlKey" | "metaKey" | "shiftKey">, | |
| 223 | +): boolean { | |
| 224 | + return tracking && !event.shiftKey && !event.ctrlKey && !event.metaKey; | |
| 225 | +} | |
| 226 | + | |
| 227 | +export function terminalWheelDeltaRows( | |
| 228 | + event: Pick<WheelEvent, "deltaY" | "deltaMode">, | |
| 229 | + cellHeight: number, | |
| 230 | + viewportRows: number, | |
| 231 | + remainder: number, | |
| 232 | +): { readonly rows: number; readonly remainder: number } { | |
| 233 | + // deltaMode: 0 pixels, 1 lines, 2 pages. | |
| 234 | + const pixels = | |
| 235 | + event.deltaMode === 1 | |
| 236 | + ? event.deltaY * cellHeight | |
| 237 | + : event.deltaMode === 2 | |
| 238 | + ? event.deltaY * viewportRows * cellHeight | |
| 239 | + : event.deltaY; | |
| 240 | + const total = remainder + pixels / cellHeight; | |
| 241 | + const rows = Math.trunc(total); | |
| 242 | + return { rows, remainder: total - rows }; | |
| 243 | +} | |
| 244 | + | |
| 245 | +export function terminalWheelArrowData(rows: number, applicationCursorKeys: boolean): string { | |
| 246 | + if (rows === 0) return ""; | |
| 247 | + const sequence = | |
| 248 | + rows < 0 | |
| 249 | + ? applicationCursorKeys | |
| 250 | + ? "\u001bOA" | |
| 251 | + : "\u001b[A" | |
| 252 | + : applicationCursorKeys | |
| 253 | + ? "\u001bOB" | |
| 254 | + : "\u001b[B"; | |
| 255 | + return sequence.repeat(Math.abs(rows)); | |
| 256 | +} | |
| 257 | + | |
| 258 | +export function isTerminalLinkPointerGesture( | |
| 259 | + event: Pick<MouseEvent, "ctrlKey" | "metaKey">, | |
| 260 | + platform = navigator.platform, | |
| 261 | +): boolean { | |
| 262 | + return isMacPlatform(platform) | |
| 263 | + ? event.metaKey && !event.ctrlKey | |
| 264 | + : event.ctrlKey && !event.metaKey; | |
| 265 | +} | |
| 266 | + | |
| 267 | +export function ghosttyMouseButton(button: number): number | null { | |
| 268 | + switch (button) { | |
| 269 | + case 0: | |
| 270 | + return 1; | |
| 271 | + case 1: | |
| 272 | + return 3; | |
| 273 | + case 2: | |
| 274 | + return 2; | |
| 275 | + case 3: | |
| 276 | + return 4; | |
| 277 | + case 4: | |
| 278 | + return 5; | |
| 279 | + default: | |
| 280 | + return null; | |
| 281 | + } | |
| 282 | +} | |
| 283 | + | |
| 284 | +export interface TerminalSelectionClickSequence { | |
| 285 | + readonly count: number; | |
| 286 | + readonly time: number; | |
| 287 | + readonly x: number; | |
| 288 | + readonly y: number; | |
| 289 | +} | |
| 290 | + | |
| 291 | +export function advanceTerminalSelectionClickSequence( | |
| 292 | + previous: TerminalSelectionClickSequence | null, | |
| 293 | + event: Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">, | |
| 294 | +): TerminalSelectionClickSequence { | |
| 295 | + const repeats = | |
| 296 | + previous !== null && | |
| 297 | + event.timeStamp - previous.time <= 500 && | |
| 298 | + Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= 4; | |
| 299 | + return { | |
| 300 | + count: repeats ? (previous.count >= 3 ? 1 : previous.count + 1) : 1, | |
| 301 | + time: event.timeStamp, | |
| 302 | + x: event.clientX, | |
| 303 | + y: event.clientY, | |
| 304 | + }; | |
| 305 | +} | |
| 306 | + | |
| 307 | +export interface GhosttySelectionPosition { | |
| 308 | + readonly start: { readonly x: number; readonly y: number }; | |
| 309 | + readonly end: { readonly x: number; readonly y: number }; | |
| 310 | +} | |
| 311 | + | |
| 312 | +export interface GhosttyTerminalSurfaceOptions { | |
| 313 | + readonly theme: GhosttyTheme; | |
| 314 | + readonly font?: GhosttyTerminalFont; | |
| 315 | + readonly onData: (data: string) => void; | |
| 316 | + readonly onResize: (cols: number, rows: number) => void; | |
| 317 | + readonly onSelectionChange: () => void; | |
| 318 | + readonly onCopy: (text: string) => void; | |
| 319 | + readonly beforeKey: (event: KeyboardEvent) => boolean; | |
| 320 | + readonly onLinkActivate: (text: string, event: MouseEvent) => void; | |
| 321 | +} | |
| 322 | + | |
| 323 | +export class GhosttyTerminalSurface { | |
| 324 | + readonly canvas: HTMLCanvasElement; | |
| 325 | + readonly input: HTMLTextAreaElement; | |
| 326 | + readonly scrollbar: HTMLDivElement; | |
| 327 | + cols = 1; | |
| 328 | + rows = 1; | |
| 329 | + | |
| 330 | + private readonly mount: HTMLElement; | |
| 331 | + private readonly context: CanvasRenderingContext2D; | |
| 332 | + private readonly core: GhosttyTerminalCore; | |
| 333 | + private readonly options: GhosttyTerminalSurfaceOptions; | |
| 334 | + private metrics: GhosttyCellMetrics; | |
| 335 | + private fontFamily: string; | |
| 336 | + private fontSize: number; | |
| 337 | + private fontEpoch = 0; | |
| 338 | + private readonly resizeObserver: ResizeObserver; | |
| 339 | + private readonly scrollbarThumb: HTMLDivElement; | |
| 340 | + private snapshot: GhosttySnapshot | null = null; | |
| 341 | + private frame = 0; | |
| 342 | + private cursorTimer: number | null = null; | |
| 343 | + private compositionInputToSuppress: string | null = null; | |
| 344 | + private compositionSuppressionTimer: number | null = null; | |
| 345 | + private cursorOn = true; | |
| 346 | + private renderedCursorY: number | null = null; | |
| 347 | + private forceFullRender = true; | |
| 348 | + private scrollbarDirty = true; | |
| 349 | + private scrollbarState: GhosttyScrollbar | null = null; | |
| 350 | + private scrollbarPointerId: number | null = null; | |
| 351 | + private scrollbarPointerOffset = 0; | |
| 352 | + private disposed = false; | |
| 353 | + private resizeNotifyTimer: number | null = null; | |
| 354 | + private originY = CONTENT_PADDING; | |
| 355 | + private mountHeight = 0; | |
| 356 | + private selectionEnd: { x: number; y: number } | null = null; | |
| 357 | + private selectionAnchorScreen: { x: number; y: number } | null = null; | |
| 358 | + private selectionEndScreen: { x: number; y: number } | null = null; | |
| 359 | + private selectionMode: "cell" | "word" | "line" = "cell"; | |
| 360 | + // Word/line selection base in screen coordinates so streaming output cannot | |
| 361 | + // shift the origin of a drag selection. | |
| 362 | + private selectionBase: { | |
| 363 | + start: { x: number; y: number }; | |
| 364 | + end: { x: number; y: number }; | |
| 365 | + } | null = null; | |
| 366 | + private selectionScrollTimer: number | null = null; | |
| 367 | + private selectionScrollDelta = 0; | |
| 368 | + private selectionPointer: { x: number; y: number } | null = null; | |
| 369 | + private mouseReportingPointerId: number | null = null; | |
| 370 | + private mouseReportingButton: number | null = null; | |
| 371 | + private linkActivationPointerId: number | null = null; | |
| 372 | + private selectionClickSequence: TerminalSelectionClickSequence | null = null; | |
| 373 | + private selectionMoved = false; | |
| 374 | + private composing = false; | |
| 375 | + private focused = false; | |
| 376 | + private resizeNotified = false; | |
| 377 | + private canvasConfigured = false; | |
| 378 | + private theme: GhosttyTheme; | |
| 379 | + private readonly suppressedKeyCodes = new Set<string>(); | |
| 380 | + private pasteShortcutToken = 0; | |
| 381 | + private wheelRemainder = 0; | |
| 382 | + private dprMedia: MediaQueryList | null = null; | |
| 383 | + private inputLeft = -1; | |
| 384 | + private inputTop = -1; | |
| 385 | + | |
| 386 | + private constructor( | |
| 387 | + mount: HTMLElement, | |
| 388 | + canvas: HTMLCanvasElement, | |
| 389 | + input: HTMLTextAreaElement, | |
| 390 | + scrollbar: HTMLDivElement, | |
| 391 | + scrollbarThumb: HTMLDivElement, | |
| 392 | + context: CanvasRenderingContext2D, | |
| 393 | + core: GhosttyTerminalCore, | |
| 394 | + metrics: GhosttyCellMetrics, | |
| 395 | + options: GhosttyTerminalSurfaceOptions, | |
| 396 | + ) { | |
| 397 | + this.mount = mount; | |
| 398 | + this.canvas = canvas; | |
| 399 | + this.input = input; | |
| 400 | + this.scrollbar = scrollbar; | |
| 401 | + this.scrollbarThumb = scrollbarThumb; | |
| 402 | + this.context = context; | |
| 403 | + this.core = core; | |
| 404 | + this.metrics = metrics; | |
| 405 | + this.options = options; | |
| 406 | + this.theme = options.theme; | |
| 407 | + this.fontFamily = terminalFontFamily(options.font?.family); | |
| 408 | + this.fontSize = terminalFontSize(options.font?.size); | |
| 409 | + this.resizeObserver = new ResizeObserver(() => this.fit()); | |
| 410 | + this.installEvents(); | |
| 411 | + this.watchDevicePixelRatio(); | |
| 412 | + document.fonts.addEventListener("loadingdone", this.onFontsLoaded); | |
| 413 | + this.resizeObserver.observe(mount); | |
| 414 | + } | |
| 415 | + | |
| 416 | + static async create( | |
| 417 | + mount: HTMLElement, | |
| 418 | + options: GhosttyTerminalSurfaceOptions, | |
| 419 | + ): Promise<GhosttyTerminalSurface> { | |
| 420 | + const canvas = document.createElement("canvas"); | |
| 421 | + canvas.className = "t3-ghostty-canvas"; | |
| 422 | + canvas.style.cssText = "display:block;width:100%;height:100%;"; | |
| 423 | + canvas.setAttribute("aria-hidden", "true"); | |
| 424 | + | |
| 425 | + const input = document.createElement("textarea"); | |
| 426 | + input.className = "t3-ghostty-input"; | |
| 427 | + input.setAttribute("aria-label", "Terminal input"); | |
| 428 | + input.autocapitalize = "off"; | |
| 429 | + input.autocomplete = "off"; | |
| 430 | + input.spellcheck = false; | |
| 431 | + input.style.cssText = | |
| 432 | + "position:absolute;left:4px;top:4px;width:1px;height:1px;opacity:0;padding:0;border:0;resize:none;pointer-events:none;"; | |
| 433 | + | |
| 434 | + const scrollbar = document.createElement("div"); | |
| 435 | + scrollbar.className = "t3-ghostty-scrollbar"; | |
| 436 | + scrollbar.setAttribute("role", "scrollbar"); | |
| 437 | + scrollbar.setAttribute("aria-label", "Terminal scrollback"); | |
| 438 | + scrollbar.setAttribute("aria-orientation", "vertical"); | |
| 439 | + scrollbar.tabIndex = 0; | |
| 440 | + scrollbar.hidden = true; | |
| 441 | + const scrollbarThumb = document.createElement("div"); | |
| 442 | + scrollbarThumb.className = "t3-ghostty-scrollbar-thumb"; | |
| 443 | + scrollbar.append(scrollbarThumb); | |
| 444 | + mount.replaceChildren(canvas, input, scrollbar); | |
| 445 | + | |
| 446 | + const context = canvas.getContext("2d", { alpha: false }); | |
| 447 | + if (!context) throw new Error("Canvas 2D is unavailable"); | |
| 448 | + const fontFamily = terminalFontFamily(options.font?.family); | |
| 449 | + const fontSize = terminalFontSize(options.font?.size); | |
| 450 | + try { | |
| 451 | + // Cell metrics must come from the faces that will render; measuring before | |
| 452 | + // the bundled webfonts load would size the grid from a fallback font. | |
| 453 | + await ensureTerminalSymbolsFont(); | |
| 454 | + await document.fonts.load(`${fontSize}px ${fontFamily}`); | |
| 455 | + } catch { | |
| 456 | + // Metrics fall back to whichever faces are already available. | |
| 457 | + } | |
| 458 | + const metrics = measureGhosttyCell(context, fontSize, fontFamily); | |
| 459 | + const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING); | |
| 460 | + const core = await GhosttyTerminalCore.create( | |
| 461 | + grid.cols, | |
| 462 | + grid.rows, | |
| 463 | + metrics.width, | |
| 464 | + metrics.height, | |
| 465 | + options.theme, | |
| 466 | + options.onData, | |
| 467 | + ); | |
| 468 | + const surface = new GhosttyTerminalSurface( | |
| 469 | + mount, | |
| 470 | + canvas, | |
| 471 | + input, | |
| 472 | + scrollbar, | |
| 473 | + scrollbarThumb, | |
| 474 | + context, | |
| 475 | + core, | |
| 476 | + metrics, | |
| 477 | + options, | |
| 478 | + ); | |
| 479 | + surface.fit(); | |
| 480 | + surface.requestRender(); | |
| 481 | + return surface; | |
| 482 | + } | |
| 483 | + | |
| 484 | + write(data: string): void { | |
| 485 | + if (this.disposed) return; | |
| 486 | + this.core.write(data); | |
| 487 | + // Restart the blink cycle from the visible phase so the cursor never sits | |
| 488 | + // invisible through a stream of output or a burst of typing echo. | |
| 489 | + this.cursorOn = true; | |
| 490 | + this.scrollbarDirty = true; | |
| 491 | + this.requestRender(); | |
| 492 | + } | |
| 493 | + | |
| 494 | + resetAndWrite(data: string): void { | |
| 495 | + if (this.disposed) return; | |
| 496 | + this.core.resetAndWrite(data); | |
| 497 | + this.forceFullRender = true; | |
| 498 | + this.scrollbarDirty = true; | |
| 499 | + this.requestRender(); | |
| 500 | + } | |
| 501 | + | |
| 502 | + setTheme(theme: GhosttyTheme): void { | |
| 503 | + if (this.disposed) return; | |
| 504 | + this.theme = theme; | |
| 505 | + this.core.setTheme(theme); | |
| 506 | + this.forceFullRender = true; | |
| 507 | + this.requestRender(); | |
| 508 | + } | |
| 509 | + | |
| 510 | + async setFont(font: GhosttyTerminalFont): Promise<void> { | |
| 511 | + if (this.disposed) return; | |
| 512 | + const fontFamily = terminalFontFamily(font.family); | |
| 513 | + const fontSize = terminalFontSize(font.size); | |
| 514 | + // The fields only change together with their metrics after the load, and | |
| 515 | + // the epoch lets the newest overlapping call win regardless of load order. | |
| 516 | + const epoch = ++this.fontEpoch; | |
| 517 | + try { | |
| 518 | + await document.fonts.load(`${fontSize}px ${fontFamily}`); | |
| 519 | + } catch { | |
| 520 | + // Metrics fall back to whichever faces are already available. | |
| 521 | + } | |
| 522 | + if (this.disposed || epoch !== this.fontEpoch) return; | |
| 523 | + this.fontFamily = fontFamily; | |
| 524 | + this.fontSize = fontSize; | |
| 525 | + this.applyFontMetrics(); | |
| 526 | + } | |
| 527 | + | |
| 528 | + private applyFontMetrics(): void { | |
| 529 | + this.metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); | |
| 530 | + this.core.resize(this.cols, this.rows, this.metrics.width, this.metrics.height); | |
| 531 | + // Cached IME textarea coordinates are stale in the new cell geometry. | |
| 532 | + this.inputLeft = -1; | |
| 533 | + this.inputTop = -1; | |
| 534 | + this.forceFullRender = true; | |
| 535 | + this.scrollbarDirty = true; | |
| 536 | + this.fit(); | |
| 537 | + this.requestRender(); | |
| 538 | + } | |
| 539 | + | |
| 540 | + private readonly onFontsLoaded = () => { | |
| 541 | + if (this.disposed) return; | |
| 542 | + // A face that finished loading after the initial measurement changes glyph | |
| 543 | + // advances; re-measure and refit so the grid matches what actually renders. | |
| 544 | + const metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); | |
| 545 | + if ( | |
| 546 | + metrics.width === this.metrics.width && | |
| 547 | + metrics.height === this.metrics.height && | |
| 548 | + metrics.baseline === this.metrics.baseline | |
| 549 | + ) { | |
| 550 | + return; | |
| 551 | + } | |
| 552 | + this.applyFontMetrics(); | |
| 553 | + }; | |
| 554 | + | |
| 555 | + fit(): boolean { | |
| 556 | + if (this.disposed) return false; | |
| 557 | + const width = this.mount.clientWidth; | |
| 558 | + const height = this.mount.clientHeight; | |
| 559 | + if (width <= 0 || height <= 0) return false; | |
| 560 | + const ratio = window.devicePixelRatio || 1; | |
| 561 | + const pixelWidth = Math.max(1, Math.round(width * ratio)); | |
| 562 | + const pixelHeight = Math.max(1, Math.round(height * ratio)); | |
| 563 | + let shouldRender = false; | |
| 564 | + // The DPR transform must be installed even when the target size happens to | |
| 565 | + // equal the canvas default 300x150 backing store, so the first fit always | |
| 566 | + // schedules a canvas configuration. | |
| 567 | + if ( | |
| 568 | + this.canvas.width !== pixelWidth || | |
| 569 | + this.canvas.height !== pixelHeight || | |
| 570 | + !this.canvasConfigured | |
| 571 | + ) { | |
| 572 | + this.canvas.width = pixelWidth; | |
| 573 | + this.canvas.height = pixelHeight; | |
| 574 | + this.context.setTransform(ratio, 0, 0, ratio, 0, 0); | |
| 575 | + this.canvasConfigured = true; | |
| 576 | + this.forceFullRender = true; | |
| 577 | + this.scrollbarDirty = true; | |
| 578 | + shouldRender = true; | |
| 579 | + } | |
| 580 | + const grid = terminalGridSize(width, height, this.metrics, CONTENT_PADDING); | |
| 581 | + this.mountHeight = height; | |
| 582 | + // onResize is the only PTY resize channel, so the first successful fit must | |
| 583 | + // notify even when the measured grid equals the 1x1 construction sentinel. | |
| 584 | + if (grid.cols !== this.cols || grid.rows !== this.rows || !this.resizeNotified) { | |
| 585 | + this.cols = grid.cols; | |
| 586 | + this.rows = grid.rows; | |
| 587 | + this.core.resize(grid.cols, grid.rows, this.metrics.width, this.metrics.height); | |
| 588 | + this.notifyResize(); | |
| 589 | + this.forceFullRender = true; | |
| 590 | + this.scrollbarDirty = true; | |
| 591 | + shouldRender = true; | |
| 592 | + } | |
| 593 | + // Rendering synchronously keeps the repaint inside the same frame as the | |
| 594 | + // layout change: ResizeObserver fires before paint, so the browser never | |
| 595 | + // composites the old backing store stretched into the new element box. | |
| 596 | + if (shouldRender) this.renderFrame(); | |
| 597 | + return true; | |
| 598 | + } | |
| 599 | + | |
| 600 | + /** | |
| 601 | + * The local grid reflows immediately, but the PTY only hears about settled | |
| 602 | + * dimensions: notifying on every drag step makes the shell reprint its | |
| 603 | + * prompt mid-drag, which reads as jitter. | |
| 604 | + */ | |
| 605 | + private notifyResize(): void { | |
| 606 | + this.resizeNotified = true; | |
| 607 | + if (this.resizeNotifyTimer !== null) window.clearTimeout(this.resizeNotifyTimer); | |
| 608 | + this.resizeNotifyTimer = window.setTimeout(() => { | |
| 609 | + this.resizeNotifyTimer = null; | |
| 610 | + if (!this.disposed) this.options.onResize(this.cols, this.rows); | |
| 611 | + }, 150); | |
| 612 | + } | |
| 613 | + | |
| 614 | + focus(): void { | |
| 615 | + this.input.focus({ preventScroll: true }); | |
| 616 | + } | |
| 617 | + | |
| 618 | + hasSelection(): boolean { | |
| 619 | + return this.core.selectionText().length > 0; | |
| 620 | + } | |
| 621 | + | |
| 622 | + getSelection(): string { | |
| 623 | + return this.core.selectionText(); | |
| 624 | + } | |
| 625 | + | |
| 626 | + getSelectionPosition(): GhosttySelectionPosition | null { | |
| 627 | + if (!this.selectionAnchorScreen || !this.selectionEndScreen || !this.hasSelection()) | |
| 628 | + return null; | |
| 629 | + const before = | |
| 630 | + this.selectionAnchorScreen.y < this.selectionEndScreen.y || | |
| 631 | + (this.selectionAnchorScreen.y === this.selectionEndScreen.y && | |
| 632 | + this.selectionAnchorScreen.x <= this.selectionEndScreen.x); | |
| 633 | + return before | |
| 634 | + ? { start: this.selectionAnchorScreen, end: this.selectionEndScreen } | |
| 635 | + : { start: this.selectionEndScreen, end: this.selectionAnchorScreen }; | |
| 636 | + } | |
| 637 | + | |
| 638 | + getSelectionEndClientRect(): { readonly right: number; readonly bottom: number } | null { | |
| 639 | + const position = this.getSelectionPosition(); | |
| 640 | + if (!position) return null; | |
| 641 | + const viewportEnd = this.core.screenPointToViewport(position.end.x, position.end.y); | |
| 642 | + if (!viewportEnd) return null; | |
| 643 | + const bounds = this.canvas.getBoundingClientRect(); | |
| 644 | + return { | |
| 645 | + right: bounds.left + CONTENT_PADDING + (viewportEnd.x + 1) * this.metrics.width, | |
| 646 | + bottom: bounds.top + this.originY + (viewportEnd.y + 1) * this.metrics.height, | |
| 647 | + }; | |
| 648 | + } | |
| 649 | + | |
| 650 | + clearSelection(): void { | |
| 651 | + this.core.clearSelection(); | |
| 652 | + this.selectionEnd = null; | |
| 653 | + this.selectionAnchorScreen = null; | |
| 654 | + this.selectionEndScreen = null; | |
| 655 | + this.selectionMode = "cell"; | |
| 656 | + this.selectionBase = null; | |
| 657 | + this.setSelectionAutoscroll(0); | |
| 658 | + this.options.onSelectionChange(); | |
| 659 | + // Selection highlights span rows Ghostty may not mark dirty for this change. | |
| 660 | + this.forceFullRender = true; | |
| 661 | + this.requestRender(); | |
| 662 | + } | |
| 663 | + | |
| 664 | + scrollToBottom(): void { | |
| 665 | + this.core.scrollToBottom(); | |
| 666 | + this.forceFullRender = true; | |
| 667 | + this.scrollbarDirty = true; | |
| 668 | + this.requestRender(); | |
| 669 | + } | |
| 670 | + | |
| 671 | + isAtBottom(): boolean { | |
| 672 | + return this.core.isViewportActive(); | |
| 673 | + } | |
| 674 | + | |
| 675 | + dispose(): void { | |
| 676 | + if (this.disposed) return; | |
| 677 | + this.disposed = true; | |
| 678 | + this.resizeObserver.disconnect(); | |
| 679 | + document.fonts.removeEventListener("loadingdone", this.onFontsLoaded); | |
| 680 | + this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); | |
| 681 | + this.dprMedia = null; | |
| 682 | + if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer); | |
| 683 | + if (this.resizeNotifyTimer !== null) { | |
| 684 | + window.clearTimeout(this.resizeNotifyTimer); | |
| 685 | + this.resizeNotifyTimer = null; | |
| 686 | + // Flush the settled dimensions so the PTY keeps the final size even when | |
| 687 | + // the surface unmounts inside the debounce window. | |
| 688 | + this.options.onResize(this.cols, this.rows); | |
| 689 | + } | |
| 690 | + if (this.frame !== 0) window.cancelAnimationFrame(this.frame); | |
| 691 | + if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer); | |
| 692 | + if (this.compositionSuppressionTimer !== null) { | |
| 693 | + window.clearTimeout(this.compositionSuppressionTimer); | |
| 694 | + } | |
| 695 | + this.removeEvents(); | |
| 696 | + this.core.dispose(); | |
| 697 | + if ( | |
| 698 | + this.canvas.parentElement === this.mount || | |
| 699 | + this.input.parentElement === this.mount || | |
| 700 | + this.scrollbar.parentElement === this.mount | |
| 701 | + ) { | |
| 702 | + this.canvas.remove(); | |
| 703 | + this.input.remove(); | |
| 704 | + this.scrollbar.remove(); | |
| 705 | + } | |
| 706 | + } | |
| 707 | + | |
| 708 | + private readonly onKeyDown = (event: KeyboardEvent) => { | |
| 709 | + // Presses handled outside the terminal must also swallow their release: | |
| 710 | + // beforeKey runs side effects (keybindings, navigation sends), so it cannot | |
| 711 | + // be consulted again on keyup, and Kitty report-event-types sessions would | |
| 712 | + // otherwise receive a release for a press the shell never saw. | |
| 713 | + if (isTerminalAltGraphText(event) || !this.options.beforeKey(event)) { | |
| 714 | + this.suppressedKeyCodes.add(event.code); | |
| 715 | + return; | |
| 716 | + } | |
| 717 | + if (isTerminalCopyShortcut(event) && this.hasSelection()) { | |
| 718 | + event.preventDefault(); | |
| 719 | + this.suppressedKeyCodes.add(event.code); | |
| 720 | + this.options.onCopy(this.getSelection()); | |
| 721 | + return; | |
| 722 | + } | |
| 723 | + if (isTerminalPasteShortcut(event)) { | |
| 724 | + this.suppressedKeyCodes.add(event.code); | |
| 725 | + const clipboard = navigator.clipboard; | |
| 726 | + if (typeof clipboard?.readText === "function") { | |
| 727 | + // Race the async clipboard read against the browser's own paste event: | |
| 728 | + // the native event (dispatched synchronously with the default action) | |
| 729 | + // always claims the token first when it fires, and the read covers | |
| 730 | + // browsers whose paste shortcut produces no paste event. Not preventing | |
| 731 | + // the default keeps the native path alive when the read is denied. | |
| 732 | + const token = ++this.pasteShortcutToken; | |
| 733 | + void clipboard.readText().then( | |
| 734 | + (text) => { | |
| 735 | + if (this.disposed || this.pasteShortcutToken !== token) return; | |
| 736 | + this.pasteShortcutToken += 1; | |
| 737 | + if (text.length > 0) this.options.onData(this.core.encodePaste(text)); | |
| 738 | + }, | |
| 739 | + () => { | |
| 740 | + // Clipboard read denied; the native paste event remains the path. | |
| 741 | + }, | |
| 742 | + ); | |
| 743 | + } | |
| 744 | + return; | |
| 745 | + } | |
| 746 | + // keyCode 229 is Safari's only signal that this keydown opens an IME | |
| 747 | + // composition; encoding it would double the committed text. | |
| 748 | + if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) { | |
| 749 | + return; | |
| 750 | + } | |
| 751 | + const data = this.core.encodeKey(event); | |
| 752 | + if (data.length === 0) return; | |
| 753 | + this.suppressedKeyCodes.delete(event.code); | |
| 754 | + event.preventDefault(); | |
| 755 | + event.stopPropagation(); | |
| 756 | + this.options.onData(data); | |
| 757 | + }; | |
| 758 | + | |
| 759 | + private readonly onKeyUp = (event: KeyboardEvent) => { | |
| 760 | + if (this.suppressedKeyCodes.delete(event.code)) return; | |
| 761 | + if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) { | |
| 762 | + return; | |
| 763 | + } | |
| 764 | + // Ghostty's encoder only emits release codes when the terminal enabled the | |
| 765 | + // Kitty report-event-types flag, so legacy sessions send nothing here. | |
| 766 | + const data = this.core.encodeKey(event, "release"); | |
| 767 | + if (data.length === 0) return; | |
| 768 | + event.preventDefault(); | |
| 769 | + event.stopPropagation(); | |
| 770 | + this.options.onData(data); | |
| 771 | + }; | |
| 772 | + | |
| 773 | + private readonly onFocus = () => { | |
| 774 | + this.focused = true; | |
| 775 | + this.cursorOn = true; | |
| 776 | + this.requestRender(); | |
| 777 | + }; | |
| 778 | + | |
| 779 | + private readonly onBlur = () => { | |
| 780 | + this.focused = false; | |
| 781 | + // Suppressions survive blur deliberately: a shortcut that moves focus (for | |
| 782 | + // example terminal-toggle) must still swallow its own keyup if focus comes | |
| 783 | + // back before release. Stale entries are harmless — an encoding keydown | |
| 784 | + // always removes its code first. | |
| 785 | + // The steady unfocused hollow cursor must not inherit an off blink phase. | |
| 786 | + this.cursorOn = true; | |
| 787 | + this.requestRender(); | |
| 788 | + }; | |
| 789 | + | |
| 790 | + private readonly onDevicePixelRatioChange = () => { | |
| 791 | + this.watchDevicePixelRatio(); | |
| 792 | + this.fit(); | |
| 793 | + }; | |
| 794 | + | |
| 795 | + private watchDevicePixelRatio(): void { | |
| 796 | + this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); | |
| 797 | + // A resolution media query only fires once for the ratio it was created at, | |
| 798 | + // so re-arm it after every change (monitor moves, browser zoom). | |
| 799 | + this.dprMedia = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); | |
| 800 | + this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange); | |
| 801 | + } | |
| 802 | + | |
| 803 | + private readonly onPaste = (event: ClipboardEvent) => { | |
| 804 | + // Always suppress the browser's default insertion: content the textarea | |
| 805 | + // would receive (for example an html-only clipboard converted to text) | |
| 806 | + // leaks through onInput without bracketed-paste encoding. | |
| 807 | + event.preventDefault(); | |
| 808 | + const data = event.clipboardData?.getData("text/plain") ?? ""; | |
| 809 | + if (data.length === 0) return; | |
| 810 | + // The native paste won the race with actual text; a pending clipboard read | |
| 811 | + // must not double. An empty native paste leaves the read as the only path. | |
| 812 | + this.pasteShortcutToken += 1; | |
| 813 | + this.options.onData(this.core.encodePaste(data)); | |
| 814 | + }; | |
| 815 | + | |
| 816 | + private readonly onCompositionStart = () => { | |
| 817 | + this.clearCompositionInputSuppression(); | |
| 818 | + this.composing = true; | |
| 819 | + }; | |
| 820 | + | |
| 821 | + private readonly onCompositionEnd = (event: CompositionEvent) => { | |
| 822 | + this.composing = false; | |
| 823 | + const data = this.input.value || event.data; | |
| 824 | + if (data.length > 0) this.options.onData(data); | |
| 825 | + this.input.value = ""; | |
| 826 | + this.compositionInputToSuppress = data; | |
| 827 | + this.compositionSuppressionTimer = window.setTimeout(() => { | |
| 828 | + this.compositionInputToSuppress = null; | |
| 829 | + this.compositionSuppressionTimer = null; | |
| 830 | + }, 100); | |
| 831 | + }; | |
| 832 | + | |
| 833 | + private readonly onInput = (event: Event) => { | |
| 834 | + const inputEvent = event as InputEvent; | |
| 835 | + if (this.composing || inputEvent.isComposing) return; | |
| 836 | + const data = this.input.value || inputEvent.data || ""; | |
| 837 | + if (data === this.compositionInputToSuppress && isTerminalCompositionCommitInput(inputEvent)) { | |
| 838 | + this.clearCompositionInputSuppression(); | |
| 839 | + this.input.value = ""; | |
| 840 | + return; | |
| 841 | + } | |
| 842 | + this.clearCompositionInputSuppression(); | |
| 843 | + if (data.length > 0) this.options.onData(data); | |
| 844 | + this.input.value = ""; | |
| 845 | + }; | |
| 846 | + | |
| 847 | + private clearCompositionInputSuppression(): void { | |
| 848 | + if (this.compositionSuppressionTimer !== null) { | |
| 849 | + window.clearTimeout(this.compositionSuppressionTimer); | |
| 850 | + this.compositionSuppressionTimer = null; | |
| 851 | + } | |
| 852 | + this.compositionInputToSuppress = null; | |
| 853 | + } | |
| 854 | + | |
| 855 | + private readonly onPointerDown = (event: PointerEvent) => { | |
| 856 | + this.focus(); | |
| 857 | + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { | |
| 858 | + const button = ghosttyMouseButton(event.button); | |
| 859 | + if (button === null) return; | |
| 860 | + event.preventDefault(); | |
| 861 | + event.stopPropagation(); | |
| 862 | + this.mouseReportingPointerId = event.pointerId; | |
| 863 | + this.mouseReportingButton = button; | |
| 864 | + this.sendMouse("press", button, event); | |
| 865 | + this.canvas.setPointerCapture(event.pointerId); | |
| 866 | + return; | |
| 867 | + } | |
| 868 | + if (event.button !== 0) return; | |
| 869 | + if (isTerminalLinkPointerGesture(event)) { | |
| 870 | + event.preventDefault(); | |
| 871 | + event.stopPropagation(); | |
| 872 | + this.linkActivationPointerId = event.pointerId; | |
| 873 | + this.canvas.setPointerCapture(event.pointerId); | |
| 874 | + return; | |
| 875 | + } | |
| 876 | + const cell = this.cellAt(event.clientX, event.clientY); | |
| 877 | + this.selectionMoved = false; | |
| 878 | + this.selectionClickSequence = advanceTerminalSelectionClickSequence( | |
| 879 | + this.selectionClickSequence, | |
| 880 | + event, | |
| 881 | + ); | |
| 882 | + const clickCount = this.selectionClickSequence.count; | |
| 883 | + this.selectionMode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : "cell"; | |
| 884 | + const range = | |
| 885 | + this.selectionMode === "line" | |
| 886 | + ? this.core.selectLine(cell.x, cell.y) | |
| 887 | + : this.selectionMode === "word" | |
| 888 | + ? this.core.selectWord(cell.x, cell.y) | |
| 889 | + : null; | |
| 890 | + if (range) { | |
| 891 | + this.selectionBase = range.screen; | |
| 892 | + this.selectionEnd = range.viewport.end; | |
| 893 | + this.selectionAnchorScreen = range.screen.start; | |
| 894 | + this.selectionEndScreen = range.screen.end; | |
| 895 | + this.options.onSelectionChange(); | |
| 896 | + } else { | |
| 897 | + this.selectionMode = "cell"; | |
| 898 | + this.selectionBase = null; | |
| 899 | + this.selectionEnd = cell; | |
| 900 | + const screen = this.core.viewportPointToScreen(cell.x, cell.y); | |
| 901 | + this.selectionAnchorScreen = screen; | |
| 902 | + this.selectionEndScreen = screen; | |
| 903 | + if (screen) { | |
| 904 | + this.core.setSelection({ ...screen, tag: 2 }, { ...screen, tag: 2 }); | |
| 905 | + } else { | |
| 906 | + this.core.setSelection(cell, cell); | |
| 907 | + } | |
| 908 | + } | |
| 909 | + this.forceFullRender = true; | |
| 910 | + this.canvas.setPointerCapture(event.pointerId); | |
| 911 | + this.requestRender(); | |
| 912 | + }; | |
| 913 | + | |
| 914 | + private readonly onPointerMove = (event: PointerEvent) => { | |
| 915 | + if (this.linkActivationPointerId === event.pointerId) return; | |
| 916 | + // Hover motion is only reportable in any-event tracking (DEC 1003); normal and | |
| 917 | + // button-event tracking never report motion without a captured pressed button. | |
| 918 | + if ( | |
| 919 | + this.mouseReportingPointerId === event.pointerId || | |
| 920 | + shouldReportTerminalMouse(this.core.isMouseAnyEventTracking(), event) | |
| 921 | + ) { | |
| 922 | + event.preventDefault(); | |
| 923 | + this.canvas.style.cursor = "default"; | |
| 924 | + this.sendMouse("motion", this.buttonFromButtons(event.buttons), event); | |
| 925 | + return; | |
| 926 | + } | |
| 927 | + if (!this.selectionAnchorScreen || !this.canvas.hasPointerCapture(event.pointerId)) { | |
| 928 | + this.updateHoverCursor(event); | |
| 929 | + return; | |
| 930 | + } | |
| 931 | + this.selectionPointer = { x: event.clientX, y: event.clientY }; | |
| 932 | + const bounds = this.canvas.getBoundingClientRect(); | |
| 933 | + this.setSelectionAutoscroll( | |
| 934 | + event.clientY < bounds.top ? -1 : event.clientY > bounds.bottom ? 1 : 0, | |
| 935 | + ); | |
| 936 | + const cell = this.cellAt(event.clientX, event.clientY); | |
| 937 | + if (cell.x === this.selectionEnd?.x && cell.y === this.selectionEnd.y) return; | |
| 938 | + this.extendSelectionTo(event.clientX, event.clientY); | |
| 939 | + }; | |
| 940 | + | |
| 941 | + private extendSelectionTo(clientX: number, clientY: number): void { | |
| 942 | + const anchorScreen = this.selectionAnchorScreen; | |
| 943 | + if (anchorScreen === null) return; | |
| 944 | + const cell = this.cellAt(clientX, clientY); | |
| 945 | + this.selectionMoved = true; | |
| 946 | + this.selectionEnd = cell; | |
| 947 | + const range = | |
| 948 | + this.selectionMode === "line" | |
| 949 | + ? this.core.selectLine(cell.x, cell.y) | |
| 950 | + : this.selectionMode === "word" | |
| 951 | + ? this.core.selectWord(cell.x, cell.y) | |
| 952 | + : null; | |
| 953 | + const cellScreen = this.core.viewportPointToScreen(cell.x, cell.y); | |
| 954 | + if (cellScreen === null) return; | |
| 955 | + const base = this.selectionBase; | |
| 956 | + const beforeBase = | |
| 957 | + base !== null && | |
| 958 | + (cellScreen.y < base.start.y || | |
| 959 | + (cellScreen.y === base.start.y && cellScreen.x < base.start.x)); | |
| 960 | + const anchor = base === null ? anchorScreen : beforeBase ? base.end : base.start; | |
| 961 | + const end = range === null ? cellScreen : beforeBase ? range.screen.start : range.screen.end; | |
| 962 | + this.selectionAnchorScreen = anchor; | |
| 963 | + this.selectionEndScreen = end; | |
| 964 | + this.core.setSelection({ ...anchor, tag: 2 }, { ...end, tag: 2 }); | |
| 965 | + this.options.onSelectionChange(); | |
| 966 | + this.forceFullRender = true; | |
| 967 | + this.requestRender(); | |
| 968 | + } | |
| 969 | + | |
| 970 | + private setSelectionAutoscroll(delta: number): void { | |
| 971 | + this.selectionScrollDelta = delta; | |
| 972 | + if (delta === 0) { | |
| 973 | + if (this.selectionScrollTimer !== null) { | |
| 974 | + window.clearInterval(this.selectionScrollTimer); | |
| 975 | + this.selectionScrollTimer = null; | |
| 976 | + } | |
| 977 | + return; | |
| 978 | + } | |
| 979 | + if (this.selectionScrollTimer !== null) return; | |
| 980 | + // Dragging past the edge scrolls the viewport and keeps extending the | |
| 981 | + // selection into the newly revealed rows, like xterm's drag scroller. | |
| 982 | + this.selectionScrollTimer = window.setInterval(() => { | |
| 983 | + if (this.disposed || this.selectionScrollDelta === 0) return; | |
| 984 | + this.scrollViewport(this.selectionScrollDelta); | |
| 985 | + const pointer = this.selectionPointer; | |
| 986 | + if (pointer) this.extendSelectionTo(pointer.x, pointer.y); | |
| 987 | + }, 80); | |
| 988 | + } | |
| 989 | + | |
| 990 | + private updateHoverCursor(event: PointerEvent): void { | |
| 991 | + const overLink = | |
| 992 | + isTerminalLinkPointerGesture(event) && this.linkAt(event.clientX, event.clientY) !== null; | |
| 993 | + const cursor = overLink ? "pointer" : ""; | |
| 994 | + if (this.canvas.style.cursor !== cursor) this.canvas.style.cursor = cursor; | |
| 995 | + } | |
| 996 | + | |
| 997 | + private readonly onPointerUp = (event: PointerEvent) => { | |
| 998 | + this.setSelectionAutoscroll(0); | |
| 999 | + if (this.linkActivationPointerId === event.pointerId) { | |
| 1000 | + event.preventDefault(); | |
| 1001 | + event.stopPropagation(); | |
| 1002 | + this.linkActivationPointerId = null; | |
| 1003 | + if (this.canvas.hasPointerCapture(event.pointerId)) { | |
| 1004 | + this.canvas.releasePointerCapture(event.pointerId); | |
| 1005 | + } | |
| 1006 | + if (event.type !== "pointercancel") { | |
| 1007 | + const link = this.linkAt(event.clientX, event.clientY); | |
| 1008 | + if (link) this.options.onLinkActivate(link, event); | |
| 1009 | + } | |
| 1010 | + return; | |
| 1011 | + } | |
| 1012 | + if (this.mouseReportingPointerId === event.pointerId) { | |
| 1013 | + event.preventDefault(); | |
| 1014 | + event.stopPropagation(); | |
| 1015 | + this.sendMouse("release", this.mouseReportingButton, event); | |
| 1016 | + this.mouseReportingPointerId = null; | |
| 1017 | + this.mouseReportingButton = null; | |
| 1018 | + if (this.canvas.hasPointerCapture(event.pointerId)) { | |
| 1019 | + this.canvas.releasePointerCapture(event.pointerId); | |
| 1020 | + } | |
| 1021 | + return; | |
| 1022 | + } | |
| 1023 | + if (this.canvas.hasPointerCapture(event.pointerId)) { | |
| 1024 | + this.canvas.releasePointerCapture(event.pointerId); | |
| 1025 | + } | |
| 1026 | + if (event.button !== 0) return; | |
| 1027 | + if (!this.selectionMoved && this.selectionMode === "cell") { | |
| 1028 | + this.clearSelection(); | |
| 1029 | + } | |
| 1030 | + this.options.onSelectionChange(); | |
| 1031 | + }; | |
| 1032 | + | |
| 1033 | + private readonly onWheel = (event: WheelEvent) => { | |
| 1034 | + if (event.deltaY === 0) return; | |
| 1035 | + event.preventDefault(); | |
| 1036 | + const delta = terminalWheelDeltaRows( | |
| 1037 | + event, | |
| 1038 | + this.metrics.height, | |
| 1039 | + this.rows, | |
| 1040 | + this.wheelRemainder, | |
| 1041 | + ); | |
| 1042 | + this.wheelRemainder = delta.remainder; | |
| 1043 | + if (delta.rows === 0) return; | |
| 1044 | + const magnitude = Math.abs(delta.rows); | |
| 1045 | + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { | |
| 1046 | + const button = delta.rows < 0 ? 4 : 5; | |
| 1047 | + for (let index = 0; index < magnitude; index += 1) { | |
| 1048 | + this.sendMouse("press", button, event); | |
| 1049 | + } | |
| 1050 | + return; | |
| 1051 | + } | |
| 1052 | + if (this.core.isAlternateScreen()) { | |
| 1053 | + // The alternate screen has no scrollback: translate wheel motion into | |
| 1054 | + // arrow keys so full-screen apps like vim and less scroll, matching xterm. | |
| 1055 | + this.options.onData(terminalWheelArrowData(delta.rows, this.core.isApplicationCursorKeys())); | |
| 1056 | + return; | |
| 1057 | + } | |
| 1058 | + this.scrollViewport(delta.rows); | |
| 1059 | + }; | |
| 1060 | + | |
| 1061 | + private readonly onMouseDown = (event: MouseEvent) => { | |
| 1062 | + if (event.button === 0) event.preventDefault(); | |
| 1063 | + this.focus(); | |
| 1064 | + }; | |
| 1065 | + | |
| 1066 | + private readonly onContextMenu = (event: MouseEvent) => { | |
| 1067 | + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { | |
| 1068 | + event.preventDefault(); | |
| 1069 | + } | |
| 1070 | + }; | |
| 1071 | + | |
| 1072 | + private readonly onScrollbarPointerDown = (event: PointerEvent) => { | |
| 1073 | + if (event.button !== 0) return; | |
| 1074 | + const state = this.readScrollbarState(); | |
| 1075 | + if (state === null) return; | |
| 1076 | + const bounds = this.scrollbar.getBoundingClientRect(); | |
| 1077 | + const geometry = terminalScrollbarGeometry(state, bounds.height); | |
| 1078 | + if (geometry === null) return; | |
| 1079 | + event.preventDefault(); | |
| 1080 | + event.stopPropagation(); | |
| 1081 | + this.scrollbarPointerId = event.pointerId; | |
| 1082 | + this.scrollbarPointerOffset = | |
| 1083 | + event.target === this.scrollbarThumb | |
| 1084 | + ? event.clientY - bounds.top - geometry.thumbTop | |
| 1085 | + : geometry.thumbHeight / 2; | |
| 1086 | + this.scrollbar.setPointerCapture(event.pointerId); | |
| 1087 | + this.scrollbarToPointer(event.clientY, bounds); | |
| 1088 | + }; | |
| 1089 | + | |
| 1090 | + private readonly onScrollbarPointerMove = (event: PointerEvent) => { | |
| 1091 | + if (event.pointerId !== this.scrollbarPointerId || this.scrollbarState === null) return; | |
| 1092 | + event.preventDefault(); | |
| 1093 | + this.scrollbarToPointer(event.clientY, this.scrollbar.getBoundingClientRect()); | |
| 1094 | + }; | |
| 1095 | + | |
| 1096 | + private readonly onScrollbarPointerUp = (event: PointerEvent) => { | |
| 1097 | + if (event.pointerId !== this.scrollbarPointerId) return; | |
| 1098 | + event.preventDefault(); | |
| 1099 | + this.scrollbarPointerId = null; | |
| 1100 | + if (this.scrollbar.hasPointerCapture(event.pointerId)) { | |
| 1101 | + this.scrollbar.releasePointerCapture(event.pointerId); | |
| 1102 | + } | |
| 1103 | + }; | |
| 1104 | + | |
| 1105 | + private readonly onScrollbarKeyDown = (event: KeyboardEvent) => { | |
| 1106 | + const state = this.readScrollbarState(); | |
| 1107 | + if (state === null) return; | |
| 1108 | + let delta = 0; | |
| 1109 | + switch (event.key) { | |
| 1110 | + case "ArrowUp": | |
| 1111 | + delta = -1; | |
| 1112 | + break; | |
| 1113 | + case "ArrowDown": | |
| 1114 | + delta = 1; | |
| 1115 | + break; | |
| 1116 | + case "PageUp": | |
| 1117 | + delta = -Math.max(1, state.len); | |
| 1118 | + break; | |
| 1119 | + case "PageDown": | |
| 1120 | + delta = Math.max(1, state.len); | |
| 1121 | + break; | |
| 1122 | + case "Home": | |
| 1123 | + delta = -state.offset; | |
| 1124 | + break; | |
| 1125 | + case "End": | |
| 1126 | + delta = state.total - state.len - state.offset; | |
| 1127 | + break; | |
| 1128 | + default: | |
| 1129 | + return; | |
| 1130 | + } | |
| 1131 | + event.preventDefault(); | |
| 1132 | + event.stopPropagation(); | |
| 1133 | + this.scrollViewport(delta); | |
| 1134 | + }; | |
| 1135 | + | |
| 1136 | + private installEvents(): void { | |
| 1137 | + this.input.addEventListener("keydown", this.onKeyDown); | |
| 1138 | + this.input.addEventListener("keyup", this.onKeyUp); | |
| 1139 | + this.input.addEventListener("focus", this.onFocus); | |
| 1140 | + this.input.addEventListener("blur", this.onBlur); | |
| 1141 | + this.input.addEventListener("input", this.onInput); | |
| 1142 | + this.input.addEventListener("paste", this.onPaste); | |
| 1143 | + this.input.addEventListener("compositionstart", this.onCompositionStart); | |
| 1144 | + this.input.addEventListener("compositionend", this.onCompositionEnd); | |
| 1145 | + this.canvas.addEventListener("pointerdown", this.onPointerDown); | |
| 1146 | + this.canvas.addEventListener("pointermove", this.onPointerMove); | |
| 1147 | + this.canvas.addEventListener("pointerup", this.onPointerUp); | |
| 1148 | + this.canvas.addEventListener("pointercancel", this.onPointerUp); | |
| 1149 | + this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); | |
| 1150 | + this.canvas.addEventListener("mousedown", this.onMouseDown); | |
| 1151 | + this.canvas.addEventListener("contextmenu", this.onContextMenu); | |
| 1152 | + this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown); | |
| 1153 | + this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove); | |
| 1154 | + this.scrollbar.addEventListener("pointerup", this.onScrollbarPointerUp); | |
| 1155 | + this.scrollbar.addEventListener("pointercancel", this.onScrollbarPointerUp); | |
| 1156 | + this.scrollbar.addEventListener("keydown", this.onScrollbarKeyDown); | |
| 1157 | + } | |
| 1158 | + | |
| 1159 | + private removeEvents(): void { | |
| 1160 | + this.input.removeEventListener("keydown", this.onKeyDown); | |
| 1161 | + this.input.removeEventListener("keyup", this.onKeyUp); | |
| 1162 | + this.input.removeEventListener("focus", this.onFocus); | |
| 1163 | + this.input.removeEventListener("blur", this.onBlur); | |
| 1164 | + this.input.removeEventListener("input", this.onInput); | |
| 1165 | + this.input.removeEventListener("paste", this.onPaste); | |
| 1166 | + this.input.removeEventListener("compositionstart", this.onCompositionStart); | |
| 1167 | + this.input.removeEventListener("compositionend", this.onCompositionEnd); | |
| 1168 | + this.canvas.removeEventListener("pointerdown", this.onPointerDown); | |
| 1169 | + this.canvas.removeEventListener("pointermove", this.onPointerMove); | |
| 1170 | + this.canvas.removeEventListener("pointerup", this.onPointerUp); | |
| 1171 | + this.canvas.removeEventListener("pointercancel", this.onPointerUp); | |
| 1172 | + this.canvas.removeEventListener("wheel", this.onWheel); | |
| 1173 | + this.canvas.removeEventListener("mousedown", this.onMouseDown); | |
| 1174 | + this.canvas.removeEventListener("contextmenu", this.onContextMenu); | |
| 1175 | + this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown); | |
| 1176 | + this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove); | |
| 1177 | + this.scrollbar.removeEventListener("pointerup", this.onScrollbarPointerUp); | |
| 1178 | + this.scrollbar.removeEventListener("pointercancel", this.onScrollbarPointerUp); | |
| 1179 | + this.scrollbar.removeEventListener("keydown", this.onScrollbarKeyDown); | |
| 1180 | + } | |
| 1181 | + | |
| 1182 | + private scrollViewport(deltaRows: number): void { | |
| 1183 | + let delta = Math.trunc(deltaRows); | |
| 1184 | + const state = this.readScrollbarState(); | |
| 1185 | + if (state !== null) { | |
| 1186 | + const maxOffset = Math.max(0, state.total - state.len); | |
| 1187 | + const offset = Math.max(0, Math.min(state.offset + delta, maxOffset)); | |
| 1188 | + delta = offset - state.offset; | |
| 1189 | + this.scrollbarState = { ...state, offset }; | |
| 1190 | + } | |
| 1191 | + if (delta === 0) return; | |
| 1192 | + this.core.scroll(delta); | |
| 1193 | + this.forceFullRender = true; | |
| 1194 | + this.scrollbarDirty = true; | |
| 1195 | + this.requestRender(); | |
| 1196 | + } | |
| 1197 | + | |
| 1198 | + private scrollbarToPointer(clientY: number, bounds: DOMRect): void { | |
| 1199 | + const state = this.scrollbarState; | |
| 1200 | + if (state === null) return; | |
| 1201 | + const offset = terminalScrollbarOffsetAtPointer( | |
| 1202 | + state, | |
| 1203 | + bounds.height, | |
| 1204 | + clientY - bounds.top, | |
| 1205 | + this.scrollbarPointerOffset, | |
| 1206 | + ); | |
| 1207 | + this.scrollViewport(offset - state.offset); | |
| 1208 | + } | |
| 1209 | + | |
| 1210 | + private updateScrollbar(): void { | |
| 1211 | + const state = this.readScrollbarState(); | |
| 1212 | + const geometry = | |
| 1213 | + state === null | |
| 1214 | + ? null | |
| 1215 | + : terminalScrollbarGeometry( | |
| 1216 | + state, | |
| 1217 | + Math.max(0, this.mount.clientHeight - CONTENT_PADDING * 2), | |
| 1218 | + ); | |
| 1219 | + this.scrollbar.hidden = geometry === null; | |
| 1220 | + if (state === null || geometry === null) return; | |
| 1221 | + this.scrollbar.setAttribute("aria-valuemin", "0"); | |
| 1222 | + this.scrollbar.setAttribute("aria-valuemax", String(geometry.maxOffset)); | |
| 1223 | + this.scrollbar.setAttribute( | |
| 1224 | + "aria-valuenow", | |
| 1225 | + String(Math.max(0, Math.min(state.offset, geometry.maxOffset))), | |
| 1226 | + ); | |
| 1227 | + this.scrollbarThumb.style.height = `${geometry.thumbHeight}px`; | |
| 1228 | + this.scrollbarThumb.style.transform = `translateY(${geometry.thumbTop}px)`; | |
| 1229 | + } | |
| 1230 | + | |
| 1231 | + private readScrollbarState(): GhosttyScrollbar | null { | |
| 1232 | + const state = this.core.scrollbarState(); | |
| 1233 | + this.scrollbarState = state; | |
| 1234 | + return state; | |
| 1235 | + } | |
| 1236 | + | |
| 1237 | + private requestRender(): void { | |
| 1238 | + if (this.disposed || this.frame !== 0) return; | |
| 1239 | + this.frame = window.requestAnimationFrame(() => { | |
| 1240 | + this.frame = 0; | |
| 1241 | + this.renderFrame(); | |
| 1242 | + }); | |
| 1243 | + } | |
| 1244 | + | |
| 1245 | + private renderFrame(): void { | |
| 1246 | + if (this.disposed) return; | |
| 1247 | + if (this.frame !== 0) { | |
| 1248 | + window.cancelAnimationFrame(this.frame); | |
| 1249 | + this.frame = 0; | |
| 1250 | + } | |
| 1251 | + this.snapshot = this.core.snapshot(); | |
| 1252 | + if (!this.snapshot.cursorBlinking) this.cursorOn = true; | |
| 1253 | + // The origin only moves together with a forced full repaint: partial | |
| 1254 | + // dirty-row redraws must never composite rows at a shifted origin over | |
| 1255 | + // rows painted at the previous one. Bottom anchoring starts once | |
| 1256 | + // scrollback exists, i.e. when the prompt actually lives at the bottom. | |
| 1257 | + const scrollState = this.readScrollbarState(); | |
| 1258 | + const anchorBottom = scrollState !== null && scrollState.total > scrollState.len; | |
| 1259 | + const nextOriginY = terminalContentOriginY( | |
| 1260 | + this.mountHeight, | |
| 1261 | + CONTENT_PADDING, | |
| 1262 | + this.rows, | |
| 1263 | + this.metrics.height, | |
| 1264 | + anchorBottom, | |
| 1265 | + ); | |
| 1266 | + if (nextOriginY !== this.originY) { | |
| 1267 | + this.originY = nextOriginY; | |
| 1268 | + this.forceFullRender = true; | |
| 1269 | + } | |
| 1270 | + renderGhosttySnapshot({ | |
| 1271 | + context: this.context, | |
| 1272 | + snapshot: this.snapshot, | |
| 1273 | + metrics: this.metrics, | |
| 1274 | + fontSize: this.fontSize, | |
| 1275 | + fontFamily: this.fontFamily, | |
| 1276 | + padding: CONTENT_PADDING, | |
| 1277 | + originY: this.originY, | |
| 1278 | + forceFull: this.forceFullRender, | |
| 1279 | + cursorOn: this.cursorOn, | |
| 1280 | + previousCursorY: this.renderedCursorY, | |
| 1281 | + focused: this.focused, | |
| 1282 | + ...(this.theme.selectionBackground !== undefined | |
| 1283 | + ? { selectionBackground: this.theme.selectionBackground } | |
| 1284 | + : {}), | |
| 1285 | + }); | |
| 1286 | + this.positionInput(); | |
| 1287 | + this.renderedCursorY = | |
| 1288 | + this.cursorOn && this.snapshot.cursorVisible && this.snapshot.cursorY >= 0 | |
| 1289 | + ? this.snapshot.cursorY | |
| 1290 | + : null; | |
| 1291 | + if (this.scrollbarDirty) { | |
| 1292 | + this.scrollbarDirty = false; | |
| 1293 | + this.updateScrollbar(); | |
| 1294 | + } | |
| 1295 | + this.forceFullRender = false; | |
| 1296 | + this.scheduleCursorBlink(); | |
| 1297 | + } | |
| 1298 | + | |
| 1299 | + private scheduleCursorBlink(): void { | |
| 1300 | + if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer); | |
| 1301 | + this.cursorTimer = null; | |
| 1302 | + // An unfocused surface shows a steady hollow cursor instead of blinking. | |
| 1303 | + if (!this.focused || !this.snapshot?.cursorBlinking || !this.snapshot.cursorVisible) return; | |
| 1304 | + this.cursorTimer = window.setTimeout(() => { | |
| 1305 | + this.cursorTimer = null; | |
| 1306 | + this.cursorOn = !this.cursorOn; | |
| 1307 | + this.requestRender(); | |
| 1308 | + }, 500); | |
| 1309 | + } | |
| 1310 | + | |
| 1311 | + private positionInput(): void { | |
| 1312 | + const snapshot = this.snapshot; | |
| 1313 | + if (!snapshot || !snapshot.cursorVisible || snapshot.cursorX < 0 || snapshot.cursorY < 0) { | |
| 1314 | + return; | |
| 1315 | + } | |
| 1316 | + // The IME candidate window anchors to the textarea, so it must follow the | |
| 1317 | + // terminal cursor for composition to appear where the user is typing. | |
| 1318 | + const left = CONTENT_PADDING + snapshot.cursorX * this.metrics.width; | |
| 1319 | + const top = this.originY + snapshot.cursorY * this.metrics.height; | |
| 1320 | + if (left === this.inputLeft && top === this.inputTop) return; | |
| 1321 | + this.inputLeft = left; | |
| 1322 | + this.inputTop = top; | |
| 1323 | + this.input.style.left = `${left}px`; | |
| 1324 | + this.input.style.top = `${top}px`; | |
| 1325 | + this.input.style.height = `${this.metrics.height}px`; | |
| 1326 | + } | |
| 1327 | + | |
| 1328 | + private cellAt(clientX: number, clientY: number): { x: number; y: number } { | |
| 1329 | + const bounds = this.canvas.getBoundingClientRect(); | |
| 1330 | + return { | |
| 1331 | + x: Math.max( | |
| 1332 | + 0, | |
| 1333 | + Math.min( | |
| 1334 | + this.cols - 1, | |
| 1335 | + Math.floor((clientX - bounds.left - CONTENT_PADDING) / this.metrics.width), | |
| 1336 | + ), | |
| 1337 | + ), | |
| 1338 | + y: Math.max( | |
| 1339 | + 0, | |
| 1340 | + Math.min( | |
| 1341 | + this.rows - 1, | |
| 1342 | + Math.floor((clientY - bounds.top - this.originY) / this.metrics.height), | |
| 1343 | + ), | |
| 1344 | + ), | |
| 1345 | + }; | |
| 1346 | + } | |
| 1347 | + | |
| 1348 | + private linkAt(clientX: number, clientY: number): string | null { | |
| 1349 | + if (!this.snapshot) return null; | |
| 1350 | + const cell = this.cellAt(clientX, clientY); | |
| 1351 | + const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y); | |
| 1352 | + if (explicitHyperlink) return explicitHyperlink; | |
| 1353 | + return terminalLinkAtPosition(this.snapshot.rowData, cell.y, cell.x); | |
| 1354 | + } | |
| 1355 | + | |
| 1356 | + private sendMouse( | |
| 1357 | + action: "press" | "release" | "motion", | |
| 1358 | + button: number | null, | |
| 1359 | + event: MouseEvent, | |
| 1360 | + ): void { | |
| 1361 | + const bounds = this.canvas.getBoundingClientRect(); | |
| 1362 | + const data = this.core.encodeMouse({ | |
| 1363 | + action, | |
| 1364 | + button, | |
| 1365 | + mods: | |
| 1366 | + (event.shiftKey ? 1 : 0) | | |
| 1367 | + (event.ctrlKey ? 1 << 1 : 0) | | |
| 1368 | + (event.altKey ? 1 << 2 : 0) | | |
| 1369 | + (event.metaKey ? 1 << 3 : 0), | |
| 1370 | + x: Math.max(0, event.clientX - bounds.left), | |
| 1371 | + y: Math.max(0, event.clientY - bounds.top), | |
| 1372 | + screenWidth: bounds.width, | |
| 1373 | + screenHeight: bounds.height, | |
| 1374 | + cellWidth: this.metrics.width, | |
| 1375 | + cellHeight: this.metrics.height, | |
| 1376 | + paddingLeft: CONTENT_PADDING, | |
| 1377 | + paddingRight: CONTENT_PADDING, | |
| 1378 | + paddingTop: this.originY, | |
| 1379 | + paddingBottom: Math.max(0, bounds.height - this.originY - this.rows * this.metrics.height), | |
| 1380 | + anyButtonPressed: event.buttons !== 0, | |
| 1381 | + }); | |
| 1382 | + if (data.length > 0) this.options.onData(data); | |
| 1383 | + } | |
| 1384 | + | |
| 1385 | + private buttonFromButtons(buttons: number): number | null { | |
| 1386 | + if ((buttons & 1) !== 0) return 1; | |
| 1387 | + if ((buttons & 4) !== 0) return 3; | |
| 1388 | + if ((buttons & 2) !== 0) return 2; | |
| 1389 | + if ((buttons & 8) !== 0) return 4; | |
| 1390 | + if ((buttons & 16) !== 0) return 5; | |
| 1391 | + return null; | |
| 1392 | + } | |
| 1393 | +} | |
Binary file not shown.
Binary file not shown.
| @@ -126,6 +126,7 @@ const allowedHosts = [".ts.net", ...configuredAllowedHosts]; · 125 unmodified lines | ||
| 126 | 126 | |
| 127 | 127 | export default defineConfig(() => { |
| 128 | 128 | return { |
| 129 | + assetsInclude: ["**/*.wasm"], | |
| 129 | 130 | plugins: [ |
| 130 | 131 | tanstackRouter(), |
| 131 | 132 | react(), |
| @@ -0,0 +1,42 @@ | ||
| 1 | +# Terminal renderers | |
| 2 | + | |
| 3 | +Terminal sessions remain server-owned PTYs. Clients receive the existing raw byte stream and send | |
| 4 | +input and resize events over the existing terminal contracts; renderer choices never cross the | |
| 5 | +wire. | |
| 6 | + | |
| 7 | +## Ghostty alignment | |
| 8 | + | |
| 9 | +Android and web use the official `libghostty-vt` C ABI for parsing, terminal state, grapheme | |
| 10 | +boundaries, keyboard encoding, selection, and scrollback: | |
| 11 | + | |
| 12 | +- Android links the native shared library and converts render state into a compact JNI snapshot. | |
| 13 | +- Web loads a separately cached WebAssembly build and reads render state into a Canvas 2D surface. | |
| 14 | +- Both artifacts are built from the revision in | |
| 15 | + `apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION`. | |
| 16 | + | |
| 17 | +The platform adapters deliberately own only platform behavior. Android owns its Kotlin Canvas and | |
| 18 | +touch integration. Web owns browser font shaping, the hidden IME textarea, clipboard and DOM input, | |
| 19 | +and its Canvas renderer. The web adapter also delegates application mouse encoding, word and line | |
| 20 | +selection, and OSC 8 hyperlink metadata to the official ABI. Browser conventions remain available: | |
| 21 | +holding Shift bypasses application mouse capture, and the platform link modifier opens hyperlinks. | |
| 22 | +React does not participate in terminal frames. | |
| 23 | + | |
| 24 | +The web runtime is singleton-scoped per browser tab so split terminals share one compiled module | |
| 25 | +and memory. Each visible terminal owns and frees its own terminal, render state, row iterator, cell | |
| 26 | +iterator, key and mouse encoder, and input event handles. Restoring captured scrollback temporarily | |
| 27 | +detaches the PTY callback so historical device queries cannot emit replies into the current shell. | |
| 28 | + | |
| 29 | +## Updating Ghostty | |
| 30 | + | |
| 31 | +Update and rebuild Android first, because mobile's `VERSION` file is the single source of truth for | |
| 32 | +the upstream pin (the upstream `LICENSE` lives beside it). Then run: | |
| 33 | + | |
| 34 | +```sh | |
| 35 | +pnpm --dir apps/web build:ghostty-wasm | |
| 36 | +``` | |
| 37 | + | |
| 38 | +Commit the regenerated web `wasm` artifacts. The build embeds the pinned revision into the binary as | |
| 39 | +semver build metadata, and the focused web ABI test reads it back through `ghostty_build_info` and | |
| 40 | +compares it against mobile's `VERSION` — so the web vendor directory holds only the artifacts, drift | |
| 41 | +cannot hide, and there is no second pin to keep in sync. The same test enforces the artifact budget | |
| 42 | +and exercises repeated create/write/free cycles with multi-codepoint graphemes. | |
| @@ -570,12 +570,6 @@ importers: · 569 unmodified lines | ||
| 570 | 570 | '@tanstack/react-router': |
| 571 | 571 | specifier: ^1.160.2 |
| 572 | 572 | version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 573 | − '@xterm/addon-fit': | |
| 574 | − specifier: ^0.11.0 | |
| 575 | − version: 0.11.0 | |
| 576 | − '@xterm/xterm': | |
| 577 | − specifier: ^6.0.0 | |
| 578 | − version: 6.0.0 | |
| 579 | 573 | class-variance-authority: |
| 580 | 574 | specifier: ^0.7.1 |
| 581 | 575 | version: 0.7.1 |
| @@ -5156,12 +5150,6 @@ packages: · 4574 unmodified lines | ||
| 5156 | 5150 | resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} |
| 5157 | 5151 | engines: {node: '>=14.6'} |
| 5158 | 5152 | |
| 5159 | − '@xterm/addon-fit@0.11.0': | |
| 5160 | − resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} | |
| 5161 | − | |
| 5162 | − '@xterm/xterm@6.0.0': | |
| 5163 | − resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} | |
| 5164 | − | |
| 5165 | 5153 | '@yuuang/ffi-rs-android-arm64@1.3.2': |
| 5166 | 5154 | resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==} |
| 5167 | 5155 | engines: {node: '>= 12'} |
| @@ -15283,10 +15271,6 @@ snapshots: · 10115 unmodified lines | ||
| 15283 | 15271 | |
| 15284 | 15272 | '@xmldom/xmldom@0.9.10': {} |
| 15285 | 15273 | |
| 15286 | − '@xterm/addon-fit@0.11.0': {} | |
| 15287 | − | |
| 15288 | − '@xterm/xterm@6.0.0': {} | |
| 15289 | − | |
| 15290 | 15274 | '@yuuang/ffi-rs-android-arm64@1.3.2': |
| 15291 | 15275 | optional: true |
| 15292 | 15276 | |