| author | |
| committer | |
| log | acf761b2f511e1918260a8043e151bbc2849e29a |
| tree | ded5ee7fa16b7d21c504f5c3adb12a40523e6a9d |
| parent | ab8182cb4e5338caf93ee90a7bd57351a0c77283 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>28 files changed, 5194 insertions(+), 495 deletions(-)
apps/desktop/src/electron/ElectronProtocol.test.ts+2-1| ... | ... | @@ -57,7 +57,7 @@ describe("ElectronProtocol", () => { |
| 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", () => { |
| 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 | ]); |
apps/desktop/src/electron/ElectronProtocol.ts+1| ... | ... | @@ -71,6 +71,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat |
| 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 | ]; |
apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md+3-2| ... | ... | @@ -18,13 +18,14 @@ Ghostty's MIT license applies to the vendored framework. Keep this notice in syn |
| 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 |
apps/web/package.json+1-2| ... | ... | @@ -4,6 +4,7 @@ |
| 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 @@ |
| 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:", |
apps/web/scripts/build-libghostty-wasm.sh created+123| ... | ... | @@ -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" |
apps/web/scripts/ghostty-write-pty.zig created+5| ... | ... | @@ -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 | } |
apps/web/src/components/ChatView.tsx+35-20| ... | ... | @@ -704,6 +704,19 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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) { |
| 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(() => { |
apps/web/src/components/ThreadTerminalDrawer.test.ts+17| ... | ... | @@ -2,8 +2,10 @@ import { describe, expect, it } from "vite-plus/test"; |
| 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", () => { |
| 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 | }); |
apps/web/src/components/ThreadTerminalDrawer.tsx+406-439| ... | ... | @@ -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 { |
| 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"; |
| 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 { |
| 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 |
| 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 { |
| 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( |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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({ |
| 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 |
apps/web/src/index.css+30-14| ... | ... | @@ -1049,6 +1049,36 @@ code { |
| 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 { |
| 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; |
apps/web/src/main.tsx-1| ... | ... | @@ -8,7 +8,6 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" |
| 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"; |
apps/web/src/terminal/ghostty/README.md created+19| ... | ... | @@ -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. |
apps/web/src/terminal/ghostty/core.ts created+1189| ... | ... | @@ -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 | } |
apps/web/src/terminal/ghostty/fonts/LICENSE created+21| ... | ... | @@ -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. |
apps/web/src/terminal/ghostty/fonts/SymbolsNerdFontMono-Regular.woff2 created| Binary files /dev/null and b/apps/web/src/terminal/ghostty/fonts/SymbolsNerdFontMono-Regular.woff2 differ |
apps/web/src/terminal/ghostty/keyCodes.test.ts created+53| ... | ... | @@ -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 | }); |
apps/web/src/terminal/ghostty/keyCodes.ts created+258| ... | ... | @@ -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 | } |
apps/web/src/terminal/ghostty/renderer.test.ts created+175| ... | ... | @@ -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 | }); |
apps/web/src/terminal/ghostty/renderer.ts created+255| ... | ... | @@ -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 | } |
apps/web/src/terminal/ghostty/runtime.ts created+221| ... | ... | @@ -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 | } |
apps/web/src/terminal/ghostty/runtimeAbi.test.ts created+561| ... | ... | @@ -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 | }); |
apps/web/src/terminal/ghostty/surface.test.ts created+383| ... | ... | @@ -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 | }); |
apps/web/src/terminal/ghostty/surface.ts created+1393| ... | ... | @@ -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 | } |
apps/web/src/terminal/ghostty/vendor/ghostty-vt.wasm created| Binary files /dev/null and b/apps/web/src/terminal/ghostty/vendor/ghostty-vt.wasm differ |
apps/web/src/terminal/ghostty/vendor/ghostty-write-pty.wasm created| Binary files /dev/null and b/apps/web/src/terminal/ghostty/vendor/ghostty-write-pty.wasm differ |
apps/web/vite.config.ts+1| ... | ... | @@ -126,6 +126,7 @@ const allowedHosts = [".ts.net", ...configuredAllowedHosts]; |
| 126 | 126 | |
| 127 | 127 | export default defineConfig(() => { |
| 128 | 128 | return { |
| 129 | assetsInclude: ["**/*.wasm"], | |
| 129 | 130 | plugins: [ |
| 130 | 131 | tanstackRouter(), |
| 131 | 132 | react(), |
docs/architecture/terminal-renderers.md created+42| ... | ... | @@ -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. |
pnpm-lock.yaml-16| ... | ... | @@ -570,12 +570,6 @@ importers: |
| 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: |
| 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: |
| 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 |