| 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,7 +57,7 @@ describe("ElectronProtocol", () => { |
| 57 | assert.equal(yield* Effect.promise(() => response.text()), "ok"); | 57 | assert.equal(yield* Effect.promise(() => response.text()), "ok"); |
| 58 | assert.include( | 58 | assert.include( |
| 59 | response.headers.get("content-security-policy") ?? "", | 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 | assert.include( | 62 | assert.include( |
| 63 | response.headers.get("content-security-policy") ?? "", | 63 | response.headers.get("content-security-policy") ?? "", |
| ... | @@ -212,6 +212,7 @@ describe("ElectronProtocol", () => { | ... | @@ -212,6 +212,7 @@ describe("ElectronProtocol", () => { |
| 212 | assert.deepEqual(directives["script-src"], [ | 212 | assert.deepEqual(directives["script-src"], [ |
| 213 | "'self'", | 213 | "'self'", |
| 214 | "'unsafe-inline'", | 214 | "'unsafe-inline'", |
| 215 | "'wasm-unsafe-eval'", | ||
| 215 | "https://clerk.t3.codes", | 216 | "https://clerk.t3.codes", |
| 216 | "https://challenges.cloudflare.com", | 217 | "https://challenges.cloudflare.com", |
| 217 | ]); | 218 | ]); |
apps/desktop/src/electron/ElectronProtocol.ts+1| ... | @@ -71,6 +71,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat | ... | @@ -71,6 +71,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat |
| 71 | const scriptSources = [ | 71 | const scriptSources = [ |
| 72 | "'self'", | 72 | "'self'", |
| 73 | "'unsafe-inline'", | 73 | "'unsafe-inline'", |
| 74 | "'wasm-unsafe-eval'", | ||
| 74 | ...(clerkOrigin ? [clerkOrigin] : []), | 75 | ...(clerkOrigin ? [clerkOrigin] : []), |
| 75 | "https://challenges.cloudflare.com", | 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,13 +18,14 @@ Ghostty's MIT license applies to the vendored framework. Keep this notice in syn |
| 18 | ## Ghostty / libghostty-vt | 18 | ## Ghostty / libghostty-vt |
| 19 | 19 | ||
| 20 | The Android terminal renderer vendors upstream `libghostty-vt` shared libraries and C headers. | 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 | - Upstream project: https://github.com/ghostty-org/ghostty | 23 | - Upstream project: https://github.com/ghostty-org/ghostty |
| 23 | - Vendored revision: `9f62873bf195e4d8a762d768a1405a5f2f7b1697` | 24 | - Vendored revision: `9f62873bf195e4d8a762d768a1405a5f2f7b1697` |
| 24 | - License: MIT | 25 | - License: MIT |
| 25 | 26 | ||
| 26 | Ghostty's MIT license applies to the vendored Android libraries. Keep this notice in sync when | 27 | Ghostty's MIT license applies to the vendored Android and web libraries. Keep this notice and both |
| 27 | updating `Vendor/libghostty-vt`. | 28 | artifacts in sync when updating `Vendor/libghostty-vt`. |
| 28 | 29 | ||
| 29 | ## MesloLGS NF (Android terminal font) | 30 | ## MesloLGS NF (Android terminal font) |
| 30 | 31 |
apps/web/package.json+1-2| ... | @@ -4,6 +4,7 @@ | ... | @@ -4,6 +4,7 @@ |
| 4 | "private": true, | 4 | "private": true, |
| 5 | "type": "module", | 5 | "type": "module", |
| 6 | "scripts": { | 6 | "scripts": { |
| 7 | "build:ghostty-wasm": "bash scripts/build-libghostty-wasm.sh", | ||
| 7 | "dev": "vp dev", | 8 | "dev": "vp dev", |
| 8 | "build": "vp build", | 9 | "build": "vp build", |
| 9 | "preview": "vp preview", | 10 | "preview": "vp preview", |
| ... | @@ -31,8 +32,6 @@ | ... | @@ -31,8 +32,6 @@ |
| 31 | "@t3tools/shared": "workspace:*", | 32 | "@t3tools/shared": "workspace:*", |
| 32 | "@tanstack/react-pacer": "^0.19.4", | 33 | "@tanstack/react-pacer": "^0.19.4", |
| 33 | "@tanstack/react-router": "^1.160.2", | 34 | "@tanstack/react-router": "^1.160.2", |
| 34 | "@xterm/addon-fit": "^0.11.0", | ||
| 35 | "@xterm/xterm": "^6.0.0", | ||
| 36 | "class-variance-authority": "^0.7.1", | 35 | "class-variance-authority": "^0.7.1", |
| 37 | "effect": "catalog:", | 36 | "effect": "catalog:", |
| 38 | "jose": "catalog:", | 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,6 +704,19 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 704 | () => drawerTerminalSessions.map((session) => session.target.terminalId), | 704 | () => drawerTerminalSessions.map((session) => session.target.terminalId), |
| 705 | [drawerTerminalSessions], | 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 | const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); | 720 | const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); |
| 708 | const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); | 721 | const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); |
| 709 | const storeSplitTerminalVertical = useTerminalUiStateStore( | 722 | const storeSplitTerminalVertical = useTerminalUiStateStore( |
| ... | @@ -773,7 +786,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -773,7 +786,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 773 | if (!cwd) { | 786 | if (!cwd) { |
| 774 | return; | 787 | return; |
| 775 | } | 788 | } |
| 776 | const terminalId = nextTerminalId(serverOrderedTerminalIds); | 789 | const terminalId = nextTerminalId(allocatableTerminalIds); |
| 777 | storeSplitTerminal(threadRef, terminalId); | 790 | storeSplitTerminal(threadRef, terminalId); |
| 778 | bumpFocusRequestId(); | 791 | bumpFocusRequestId(); |
| 779 | void openTerminal({ | 792 | void openTerminal({ |
| ... | @@ -787,11 +800,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -787,11 +800,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 787 | }, | 800 | }, |
| 788 | }); | 801 | }); |
| 789 | }, [ | 802 | }, [ |
| 803 | allocatableTerminalIds, | ||
| 790 | bumpFocusRequestId, | 804 | bumpFocusRequestId, |
| 791 | cwd, | 805 | cwd, |
| 792 | effectiveWorktreePath, | 806 | effectiveWorktreePath, |
| 793 | runtimeEnv, | 807 | runtimeEnv, |
| 794 | serverOrderedTerminalIds, | ||
| 795 | storeSplitTerminal, | 808 | storeSplitTerminal, |
| 796 | threadId, | 809 | threadId, |
| 797 | threadRef, | 810 | threadRef, |
| ... | @@ -801,7 +814,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -801,7 +814,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 801 | if (!cwd) { | 814 | if (!cwd) { |
| 802 | return; | 815 | return; |
| 803 | } | 816 | } |
| 804 | const terminalId = nextTerminalId(serverOrderedTerminalIds); | 817 | const terminalId = nextTerminalId(allocatableTerminalIds); |
| 805 | storeSplitTerminalVertical(threadRef, terminalId); | 818 | storeSplitTerminalVertical(threadRef, terminalId); |
| 806 | bumpFocusRequestId(); | 819 | bumpFocusRequestId(); |
| 807 | void openTerminal({ | 820 | void openTerminal({ |
| ... | @@ -815,12 +828,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -815,12 +828,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 815 | }, | 828 | }, |
| 816 | }); | 829 | }); |
| 817 | }, [ | 830 | }, [ |
| 831 | allocatableTerminalIds, | ||
| 818 | bumpFocusRequestId, | 832 | bumpFocusRequestId, |
| 819 | cwd, | 833 | cwd, |
| 820 | effectiveWorktreePath, | 834 | effectiveWorktreePath, |
| 821 | openTerminal, | 835 | openTerminal, |
| 822 | runtimeEnv, | 836 | runtimeEnv, |
| 823 | serverOrderedTerminalIds, | ||
| 824 | storeSplitTerminalVertical, | 837 | storeSplitTerminalVertical, |
| 825 | threadId, | 838 | threadId, |
| 826 | threadRef, | 839 | threadRef, |
| ... | @@ -830,7 +843,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -830,7 +843,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 830 | if (!cwd) { | 843 | if (!cwd) { |
| 831 | return; | 844 | return; |
| 832 | } | 845 | } |
| 833 | const terminalId = nextTerminalId(serverOrderedTerminalIds); | 846 | const terminalId = nextTerminalId(allocatableTerminalIds); |
| 834 | storeNewTerminal(threadRef, terminalId); | 847 | storeNewTerminal(threadRef, terminalId); |
| 835 | bumpFocusRequestId(); | 848 | bumpFocusRequestId(); |
| 836 | void openTerminal({ | 849 | void openTerminal({ |
| ... | @@ -847,8 +860,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra | ... | @@ -847,8 +860,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 847 | bumpFocusRequestId, | 860 | bumpFocusRequestId, |
| 848 | cwd, | 861 | cwd, |
| 849 | effectiveWorktreePath, | 862 | effectiveWorktreePath, |
| 863 | allocatableTerminalIds, | ||
| 850 | runtimeEnv, | 864 | runtimeEnv, |
| 851 | serverOrderedTerminalIds, | ||
| 852 | storeNewTerminal, | 865 | storeNewTerminal, |
| 853 | threadId, | 866 | threadId, |
| 854 | threadRef, | 867 | threadRef, |
| ... | @@ -1521,6 +1534,10 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -1521,6 +1534,10 @@ function ChatViewContent(props: ChatViewProps) { |
| 1521 | ), | 1534 | ), |
| 1522 | [rightPanelState.surfaces], | 1535 | [rightPanelState.surfaces], |
| 1523 | ); | 1536 | ); |
| 1537 | const allocatableActiveTerminalIds = useMemo( | ||
| 1538 | () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])], | ||
| 1539 | [activeKnownTerminalIds, panelTerminalIds], | ||
| 1540 | ); | ||
| 1524 | const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); | 1541 | const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); |
| 1525 | const rightPanelOpen = rightPanelState.isOpen; | 1542 | const rightPanelOpen = rightPanelState.isOpen; |
| 1526 | const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; | 1543 | const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; |
| ... | @@ -2628,7 +2645,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2628,7 +2645,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2628 | if (!cwdForOpen) { | 2645 | if (!cwdForOpen) { |
| 2629 | return; | 2646 | return; |
| 2630 | } | 2647 | } |
| 2631 | const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); | 2648 | const terminalId = nextTerminalId(allocatableActiveTerminalIds); |
| 2632 | storeEnsureTerminal(activeThreadRef, terminalId, { open: true }); | 2649 | storeEnsureTerminal(activeThreadRef, terminalId, { open: true }); |
| 2633 | void openTerminal({ | 2650 | void openTerminal({ |
| 2634 | environmentId, | 2651 | environmentId, |
| ... | @@ -2647,15 +2664,14 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2647,15 +2664,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 2647 | } | 2664 | } |
| 2648 | setTerminalOpen(nextOpen); | 2665 | setTerminalOpen(nextOpen); |
| 2649 | }, [ | 2666 | }, [ |
| 2650 | activeKnownTerminalIds, | ||
| 2651 | activeProject, | 2667 | activeProject, |
| 2652 | activeThreadId, | 2668 | activeThreadId, |
| 2653 | activeThreadRef, | 2669 | activeThreadRef, |
| 2654 | activeThreadWorktreePath, | 2670 | activeThreadWorktreePath, |
| 2671 | allocatableActiveTerminalIds, | ||
| 2655 | environmentId, | 2672 | environmentId, |
| 2656 | gitCwd, | 2673 | gitCwd, |
| 2657 | openTerminal, | 2674 | openTerminal, |
| 2658 | panelTerminalIds, | ||
| 2659 | setTerminalOpen, | 2675 | setTerminalOpen, |
| 2660 | storeEnsureTerminal, | 2676 | storeEnsureTerminal, |
| 2661 | terminalUiState.terminalIds.length, | 2677 | terminalUiState.terminalIds.length, |
| ... | @@ -2670,7 +2686,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2670,7 +2686,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2670 | if (!cwdForOpen) { | 2686 | if (!cwdForOpen) { |
| 2671 | return; | 2687 | return; |
| 2672 | } | 2688 | } |
| 2673 | const terminalId = nextTerminalId(activeKnownTerminalIds); | 2689 | const terminalId = nextTerminalId(allocatableActiveTerminalIds); |
| 2674 | if (direction === "vertical") { | 2690 | if (direction === "vertical") { |
| 2675 | storeSplitTerminalVertical(activeThreadRef, terminalId); | 2691 | storeSplitTerminalVertical(activeThreadRef, terminalId); |
| 2676 | } else { | 2692 | } else { |
| ... | @@ -2693,8 +2709,8 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2693,8 +2709,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 2693 | }, | 2709 | }, |
| 2694 | [ | 2710 | [ |
| 2695 | activeProject, | 2711 | activeProject, |
| 2696 | activeKnownTerminalIds, | ||
| 2697 | activeThreadId, | 2712 | activeThreadId, |
| 2713 | allocatableActiveTerminalIds, | ||
| 2698 | activeThreadRef, | 2714 | activeThreadRef, |
| 2699 | openTerminal, | 2715 | openTerminal, |
| 2700 | activeThreadWorktreePath, | 2716 | activeThreadWorktreePath, |
| ... | @@ -2713,7 +2729,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2713,7 +2729,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2713 | if (!cwdForOpen) { | 2729 | if (!cwdForOpen) { |
| 2714 | return; | 2730 | return; |
| 2715 | } | 2731 | } |
| 2716 | const terminalId = nextTerminalId(activeKnownTerminalIds); | 2732 | const terminalId = nextTerminalId(allocatableActiveTerminalIds); |
| 2717 | storeNewTerminal(activeThreadRef, terminalId); | 2733 | storeNewTerminal(activeThreadRef, terminalId); |
| 2718 | setTerminalFocusRequestId((value) => value + 1); | 2734 | setTerminalFocusRequestId((value) => value + 1); |
| 2719 | void openTerminal({ | 2735 | void openTerminal({ |
| ... | @@ -2731,8 +2747,8 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2731,8 +2747,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 2731 | }); | 2747 | }); |
| 2732 | }, [ | 2748 | }, [ |
| 2733 | activeProject, | 2749 | activeProject, |
| 2734 | activeKnownTerminalIds, | ||
| 2735 | activeThreadId, | 2750 | activeThreadId, |
| 2751 | allocatableActiveTerminalIds, | ||
| 2736 | activeThreadRef, | 2752 | activeThreadRef, |
| 2737 | openTerminal, | 2753 | openTerminal, |
| 2738 | activeThreadWorktreePath, | 2754 | activeThreadWorktreePath, |
| ... | @@ -2818,7 +2834,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2818,7 +2834,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2818 | ...(options?.env ? { extraEnv: options.env } : {}), | 2834 | ...(options?.env ? { extraEnv: options.env } : {}), |
| 2819 | }); | 2835 | }); |
| 2820 | const targetTerminalId = shouldCreateNewTerminal | 2836 | const targetTerminalId = shouldCreateNewTerminal |
| 2821 | ? nextTerminalId(activeKnownTerminalIds) | 2837 | ? nextTerminalId(allocatableActiveTerminalIds) |
| 2822 | : baseTerminalId; | 2838 | : baseTerminalId; |
| 2823 | const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal | 2839 | const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal |
| 2824 | ? { | 2840 | ? { |
| ... | @@ -2886,6 +2902,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -2886,6 +2902,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2886 | environmentId, | 2902 | environmentId, |
| 2887 | openTerminal, | 2903 | openTerminal, |
| 2888 | activeKnownTerminalIds, | 2904 | activeKnownTerminalIds, |
| 2905 | allocatableActiveTerminalIds, | ||
| 2889 | runningTerminalIds, | 2906 | runningTerminalIds, |
| 2890 | terminalUiState.activeTerminalId, | 2907 | terminalUiState.activeTerminalId, |
| 2891 | writeTerminal, | 2908 | writeTerminal, |
| ... | @@ -3144,7 +3161,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3144,7 +3161,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3144 | const addTerminalSurface = useCallback(() => { | 3161 | const addTerminalSurface = useCallback(() => { |
| 3145 | if (!activeThreadRef || !activeThreadId || !activeProject) return; | 3162 | if (!activeThreadRef || !activeThreadId || !activeProject) return; |
| 3146 | const cwd = gitCwd ?? activeProject.workspaceRoot; | 3163 | const cwd = gitCwd ?? activeProject.workspaceRoot; |
| 3147 | const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); | 3164 | const terminalId = nextTerminalId(allocatableActiveTerminalIds); |
| 3148 | useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); | 3165 | useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); |
| 3149 | setTerminalFocusRequestId((value) => value + 1); | 3166 | setTerminalFocusRequestId((value) => value + 1); |
| 3150 | void openTerminal({ | 3167 | void openTerminal({ |
| ... | @@ -3161,14 +3178,13 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3161,14 +3178,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 3161 | }, | 3178 | }, |
| 3162 | }); | 3179 | }); |
| 3163 | }, [ | 3180 | }, [ |
| 3164 | activeKnownTerminalIds, | ||
| 3165 | activeProject, | 3181 | activeProject, |
| 3166 | activeThreadId, | 3182 | activeThreadId, |
| 3167 | activeThreadRef, | 3183 | activeThreadRef, |
| 3168 | activeThreadWorktreePath, | 3184 | activeThreadWorktreePath, |
| 3185 | allocatableActiveTerminalIds, | ||
| 3169 | gitCwd, | 3186 | gitCwd, |
| 3170 | openTerminal, | 3187 | openTerminal, |
| 3171 | panelTerminalIds, | ||
| 3172 | ]); | 3188 | ]); |
| 3173 | const splitPanelTerminal = useCallback( | 3189 | const splitPanelTerminal = useCallback( |
| 3174 | (direction: "horizontal" | "vertical" = "horizontal") => { | 3190 | (direction: "horizontal" | "vertical" = "horizontal") => { |
| ... | @@ -3181,7 +3197,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3181,7 +3197,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3181 | ) { | 3197 | ) { |
| 3182 | return; | 3198 | return; |
| 3183 | } | 3199 | } |
| 3184 | const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); | 3200 | const terminalId = nextTerminalId(allocatableActiveTerminalIds); |
| 3185 | const cwd = gitCwd ?? activeProject.workspaceRoot; | 3201 | const cwd = gitCwd ?? activeProject.workspaceRoot; |
| 3186 | useRightPanelStore | 3202 | useRightPanelStore |
| 3187 | .getState() | 3203 | .getState() |
| ... | @@ -3202,15 +3218,14 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3202,15 +3218,14 @@ function ChatViewContent(props: ChatViewProps) { |
| 3202 | }); | 3218 | }); |
| 3203 | }, | 3219 | }, |
| 3204 | [ | 3220 | [ |
| 3205 | activeKnownTerminalIds, | ||
| 3206 | activeProject, | 3221 | activeProject, |
| 3207 | activeRightPanelSurface, | 3222 | activeRightPanelSurface, |
| 3208 | activeThreadId, | 3223 | activeThreadId, |
| 3209 | activeThreadRef, | 3224 | activeThreadRef, |
| 3210 | activeThreadWorktreePath, | 3225 | activeThreadWorktreePath, |
| 3226 | allocatableActiveTerminalIds, | ||
| 3211 | gitCwd, | 3227 | gitCwd, |
| 3212 | openTerminal, | 3228 | openTerminal, |
| 3213 | panelTerminalIds, | ||
| 3214 | ], | 3229 | ], |
| 3215 | ); | 3230 | ); |
| 3216 | const splitPanelTerminalVertical = useCallback(() => { | 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,8 +2,10 @@ import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | resolveTerminalSelectionActionPosition, | 4 | resolveTerminalSelectionActionPosition, |
| 5 | shouldHandleTerminalExit, | ||
| 5 | shouldHandleTerminalSelectionMouseUp, | 6 | shouldHandleTerminalSelectionMouseUp, |
| 6 | terminalSelectionActionDelayForClickCount, | 7 | terminalSelectionActionDelayForClickCount, |
| 8 | terminalSelectionLineRange, | ||
| 7 | } from "./ThreadTerminalDrawer"; | 9 | } from "./ThreadTerminalDrawer"; |
| 8 | 10 | ||
| 9 | describe("resolveTerminalSelectionActionPosition", () => { | 11 | describe("resolveTerminalSelectionActionPosition", () => { |
| ... | @@ -72,4 +74,19 @@ describe("resolveTerminalSelectionActionPosition", () => { | ... | @@ -72,4 +74,19 @@ describe("resolveTerminalSelectionActionPosition", () => { |
| 72 | expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false); | 74 | expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false); |
| 73 | expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false); | 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,9 +1,9 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { FitAddon } from "@xterm/addon-fit"; | ||
| 3 | import { | 2 | import { |
| 4 | isAtomCommandInterrupted, | 3 | isAtomCommandInterrupted, |
| 5 | squashAtomCommandFailure, | 4 | squashAtomCommandFailure, |
| 6 | } from "@t3tools/client-runtime/state/runtime"; | 5 | } from "@t3tools/client-runtime/state/runtime"; |
| 6 | import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; | ||
| 7 | import { | 7 | import { |
| 8 | Plus, | 8 | Plus, |
| 9 | SquareSplitHorizontal, | 9 | SquareSplitHorizontal, |
| ... | @@ -18,7 +18,6 @@ import { | ... | @@ -18,7 +18,6 @@ import { |
| 18 | type ThreadId, | 18 | type ThreadId, |
| 19 | } from "@t3tools/contracts"; | 19 | } from "@t3tools/contracts"; |
| 20 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; | 20 | import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; |
| 21 | import { Terminal, type ITheme } from "@xterm/xterm"; | ||
| 22 | import { | 21 | import { |
| 23 | type PointerEvent as ReactPointerEvent, | 22 | type PointerEvent as ReactPointerEvent, |
| 24 | type ReactNode, | 23 | type ReactNode, |
| ... | @@ -34,15 +33,13 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; | ... | @@ -34,15 +33,13 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; |
| 34 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | 33 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 35 | import { cn } from "~/lib/utils"; | 34 | import { cn } from "~/lib/utils"; |
| 36 | import { type TerminalContextSelection } from "~/lib/terminalContext"; | 35 | import { type TerminalContextSelection } from "~/lib/terminalContext"; |
| 37 | import { useOpenInPreferredEditor } from "../editorPreferences"; | ||
| 38 | import { | 36 | import { |
| 39 | collectWrappedTerminalLinkLine, | 37 | GhosttyTerminalSurface, |
| 40 | extractTerminalLinks, | 38 | type GhosttyTerminalSurfaceOptions, |
| 41 | isTerminalLinkActivation, | 39 | } from "~/terminal/ghostty/surface"; |
| 42 | resolvePathLinkTarget, | 40 | import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; |
| 43 | resolveWrappedTerminalLinkRange, | 41 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 44 | wrappedTerminalLinkRangeIntersectsBufferLine, | 42 | import { isTerminalLinkActivation, resolvePathLinkTarget } from "../terminal-links"; |
| 45 | } from "../terminal-links"; | ||
| 46 | import { | 43 | import { |
| 47 | isDiffToggleShortcut, | 44 | isDiffToggleShortcut, |
| 48 | isTerminalClearShortcut, | 45 | isTerminalClearShortcut, |
| ... | @@ -82,24 +79,34 @@ function clampDrawerHeight(height: number): number { | ... | @@ -82,24 +79,34 @@ function clampDrawerHeight(height: number): number { |
| 82 | return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight); | 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 | terminal.write(`\r\n[terminal] ${message}\r\n`); | 83 | terminal.write(`\r\n[terminal] ${message}\r\n`); |
| 87 | } | 84 | } |
| 88 | 85 | ||
| 89 | function writeTerminalBuffer(terminal: Terminal, buffer: string): void { | 86 | function writeTerminalBuffer(terminal: GhosttyTerminalSurface, buffer: string): void { |
| 90 | terminal.write("\u001bc"); | 87 | terminal.resetAndWrite(buffer); |
| 91 | if (buffer.length > 0) { | ||
| 92 | terminal.write(buffer); | ||
| 93 | } | ||
| 94 | } | 88 | } |
| 95 | 89 | ||
| 96 | function fitTerminalSafely(fitAddon: FitAddon): boolean { | 90 | function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor { |
| 97 | try { | 91 | if (typeof document === "undefined") return fallback; |
| 98 | fitAddon.fit(); | 92 | |
| 99 | return true; | 93 | const canvas = document.createElement("canvas"); |
| 100 | } catch { | 94 | canvas.width = 1; |
| 101 | return false; | 95 | canvas.height = 1; |
| 102 | } | 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 | function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string { | 112 | function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string { |
| ... | @@ -124,7 +131,7 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri | ... | @@ -124,7 +131,7 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri |
| 124 | return value ?? fallback; | 131 | return value ?? fallback; |
| 125 | } | 132 | } |
| 126 | 133 | ||
| 127 | function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { | 134 | function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { |
| 128 | const isDark = document.documentElement.classList.contains("dark"); | 135 | const isDark = document.documentElement.classList.contains("dark"); |
| 129 | const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; | 136 | const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; |
| 130 | const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; | 137 | const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; |
| ... | @@ -143,86 +150,22 @@ function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { | ... | @@ -143,86 +150,22 @@ function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { |
| 143 | normalizeComputedColor(bodyStyles.color, fallbackForeground), | 150 | normalizeComputedColor(bodyStyles.color, fallbackForeground), |
| 144 | ); | 151 | ); |
| 145 | 152 | ||
| 146 | if (isDark) { | 153 | return { |
| 147 | return { | 154 | background: parseTerminalColor( |
| 148 | background, | 155 | background, |
| 156 | isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 }, | ||
| 157 | ), | ||
| 158 | foreground: parseTerminalColor( | ||
| 149 | foreground, | 159 | foreground, |
| 150 | cursor: "rgb(180, 203, 255)", | 160 | isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 }, |
| 151 | selectionBackground: "rgba(180, 203, 255, 0.25)", | 161 | ), |
| 152 | scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)", | 162 | cursor: isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, |
| 153 | scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)", | 163 | // Matches the xterm selection overlays this renderer replaced; the text |
| 154 | scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)", | 164 | // color underneath is left unchanged for contrast in both themes. |
| 155 | black: "rgb(24, 30, 38)", | 165 | selectionBackground: isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)", |
| 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)", | ||
| 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 | export function resolveTerminalSelectionActionPosition(options: { | 169 | export function resolveTerminalSelectionActionPosition(options: { |
| 227 | bounds: { left: number; top: number; width: number; height: number }; | 170 | bounds: { left: number; top: number; width: number; height: number }; |
| 228 | selectionRect: { right: number; bottom: number } | null; | 171 | selectionRect: { right: number; bottom: number } | null; |
| ... | @@ -269,6 +212,27 @@ export function shouldHandleTerminalSelectionMouseUp( | ... | @@ -269,6 +212,27 @@ export function shouldHandleTerminalSelectionMouseUp( |
| 269 | return selectionGestureActive && button === 0; | 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 | interface TerminalViewportProps { | 236 | interface TerminalViewportProps { |
| 273 | threadRef: ScopedThreadRef; | 237 | threadRef: ScopedThreadRef; |
| 274 | threadId: ThreadId; | 238 | threadId: ThreadId; |
| ... | @@ -309,8 +273,7 @@ export function TerminalViewport({ | ... | @@ -309,8 +273,7 @@ export function TerminalViewport({ |
| 309 | keybindings, | 273 | keybindings, |
| 310 | }: TerminalViewportProps) { | 274 | }: TerminalViewportProps) { |
| 311 | const containerRef = useRef<HTMLDivElement>(null); | 275 | const containerRef = useRef<HTMLDivElement>(null); |
| 312 | const terminalRef = useRef<Terminal | null>(null); | 276 | const terminalRef = useRef<GhosttyTerminalSurface | null>(null); |
| 313 | const fitAddonRef = useRef<FitAddon | null>(null); | ||
| 314 | const environmentId = threadRef.environmentId; | 277 | const environmentId = threadRef.environmentId; |
| 315 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); | 278 | const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); |
| 316 | const openInPreferredEditor = useOpenInPreferredEditor( | 279 | const openInPreferredEditor = useOpenInPreferredEditor( |
| ... | @@ -367,6 +330,24 @@ export function TerminalViewport({ | ... | @@ -367,6 +330,24 @@ export function TerminalViewport({ |
| 367 | const terminalBuffer = terminalSession.buffer; | 330 | const terminalBuffer = terminalSession.buffer; |
| 368 | const terminalError = terminalSession.error; | 331 | const terminalError = terminalSession.error; |
| 369 | const terminalStatus = terminalSession.status; | 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 | const terminalVersion = terminalSession.version; | 351 | const terminalVersion = terminalSession.version; |
| 371 | const previousSessionRef = useRef({ | 352 | const previousSessionRef = useRef({ |
| 372 | buffer: terminalBuffer, | 353 | buffer: terminalBuffer, |
| ... | @@ -374,6 +355,13 @@ export function TerminalViewport({ | ... | @@ -374,6 +355,13 @@ export function TerminalViewport({ |
| 374 | status: terminalStatus, | 355 | status: terminalStatus, |
| 375 | version: terminalVersion, | 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 | useEffect(() => { | 366 | useEffect(() => { |
| 379 | keybindingsRef.current = keybindings; | 367 | keybindingsRef.current = keybindings; |
| ... | @@ -384,356 +372,353 @@ export function TerminalViewport({ | ... | @@ -384,356 +372,353 @@ export function TerminalViewport({ |
| 384 | if (!mount) return; | 372 | if (!mount) return; |
| 385 | 373 | ||
| 386 | const localApi = readLocalApi(); | 374 | const localApi = readLocalApi(); |
| 387 | 375 | let cancelled = false; | |
| 388 | const fitAddon = new FitAddon(); | 376 | let teardown: (() => void) | null = null; |
| 389 | const terminal = new Terminal({ | 377 | let setupTerminal: GhosttyTerminalSurface | null = null; |
| 390 | cursorBlink: true, | 378 | let setupCleanups: Array<() => void> = []; |
| 391 | lineHeight: 1, | 379 | |
| 392 | fontSize: 12, | 380 | const setup = async (): Promise<(() => void) | null> => { |
| 393 | scrollback: 5_000, | 381 | const terminalOptions: GhosttyTerminalSurfaceOptions = { |
| 394 | fontFamily: | 382 | theme: terminalThemeFromApp(mount), |
| 395 | '"SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace', | 383 | onData: (data) => handleData(data), |
| 396 | theme: terminalThemeFromApp(mount), | 384 | onResize: (cols, rows) => void resizeTerminal(cols, rows), |
| 397 | }); | 385 | onSelectionChange: () => handleSelectionChange(), |
| 398 | terminal.loadAddon(fitAddon); | 386 | onCopy: (text) => handleCopy(text), |
| 399 | terminal.open(mount); | 387 | beforeKey: (event) => handleBeforeKey(event), |
| 400 | fitTerminalSafely(fitAddon); | 388 | onLinkActivate: (text, event) => handleLinkActivate(text, event), |
| 401 | 389 | }; | |
| 402 | terminalRef.current = terminal; | 390 | const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions); |
| 403 | fitAddonRef.current = fitAddon; | 391 | if (cancelled) { |
| 404 | previousSessionRef.current = { | 392 | terminal.dispose(); |
| 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) { | ||
| 433 | return null; | 393 | return null; |
| 434 | } | 394 | } |
| 435 | const lineStart = selectionPosition.start.y + 1; | 395 | // The theme observer is not installed yet, so re-read the theme in case |
| 436 | const lineCount = normalizedText.split("\n").length; | 396 | // the app toggled light/dark while the WASM surface was loading. |
| 437 | const lineEnd = Math.max(lineStart, lineStart + lineCount - 1); | 397 | terminal.setTheme(terminalThemeFromApp(mount)); |
| 438 | const bounds = mountElement.getBoundingClientRect(); | 398 | setupTerminal = terminal; |
| 439 | const selectionRect = getTerminalSelectionRect(mountElement); | 399 | terminalRef.current = terminal; |
| 440 | const position = resolveTerminalSelectionActionPosition({ | 400 | const latestSession = latestSessionRef.current; |
| 441 | bounds, | 401 | previousSessionRef.current = latestSession; |
| 442 | selectionRect: | 402 | if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer); |
| 443 | selectionRect === null | 403 | if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error); |
| 444 | ? null | 404 | // Attaching to a session that already exited must still run exit handling |
| 445 | : { right: selectionRect.right, bottom: selectionRect.bottom }, | 405 | // once, so mount synchronization starts from the empty "closed" state. |
| 446 | pointer: selectionPointerRef.current, | 406 | // (A session that is "closed" at mount is indistinguishable from one that |
| 447 | }); | 407 | // never started, so only "exited" triggers the message — as with xterm.) |
| 448 | return { | 408 | synchronizedStatusRef.current = "closed"; |
| 449 | position, | 409 | synchronizeTerminalStatus(terminal, latestSession.status); |
| 450 | clipboardText: selectionText, | 410 | if (autoFocus) window.requestAnimationFrame(() => terminal.focus()); |
| 451 | selection: { | 411 | |
| 452 | terminalId, | 412 | const clearSelectionAction = () => { |
| 453 | terminalLabel: readTerminalLabel(), | 413 | selectionActionRequestIdRef.current += 1; |
| 454 | lineStart, | 414 | if (selectionActionTimerRef.current !== null) { |
| 455 | lineEnd, | 415 | window.clearTimeout(selectionActionTimerRef.current); |
| 456 | text: normalizedText, | 416 | selectionActionTimerRef.current = null; |
| 457 | }, | 417 | } |
| 458 | }; | 418 | }; |
| 459 | }; | 419 | setupCleanups.push(clearSelectionAction); |
| 460 | 420 | ||
| 461 | const showSelectionAction = async () => { | 421 | const readSelectionAction = (): { |
| 462 | if (!localApi) { | 422 | position: { x: number; y: number }; |
| 463 | clearSelectionAction(); | 423 | clipboardText: string; |
| 464 | return; | 424 | selection: TerminalContextSelection; |
| 465 | } | 425 | } | null => { |
| 466 | if (selectionActionMenuOpenRef.current) { | 426 | const activeTerminal = terminalRef.current; |
| 467 | return; | 427 | const mountElement = containerRef.current; |
| 468 | } | 428 | if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { |
| 469 | const nextAction = readSelectionAction(); | 429 | return null; |
| 470 | if (!nextAction) { | 430 | } |
| 471 | clearSelectionAction(); | 431 | const selectionText = activeTerminal.getSelection(); |
| 472 | return; | 432 | const selectionPosition = activeTerminal.getSelectionPosition(); |
| 473 | } | 433 | const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); |
| 474 | const requestId = ++selectionActionRequestIdRef.current; | 434 | if (!selectionPosition || normalizedText.length === 0) { |
| 475 | selectionActionMenuOpenRef.current = true; | 435 | return null; |
| 476 | const clicked = await localApi.contextMenu | 436 | } |
| 477 | .show( | 437 | const { lineStart, lineEnd } = terminalSelectionLineRange(selectionPosition); |
| 478 | [ | 438 | const bounds = mountElement.getBoundingClientRect(); |
| 479 | { id: "add-to-chat", label: "Add to chat" }, | 439 | const position = resolveTerminalSelectionActionPosition({ |
| 480 | { id: "copy", label: "Copy" }, | 440 | bounds, |
| 481 | ], | 441 | selectionRect: activeTerminal.getSelectionEndClientRect(), |
| 482 | nextAction.position, | 442 | pointer: selectionPointerRef.current, |
| 483 | ) | ||
| 484 | .finally(() => { | ||
| 485 | selectionActionMenuOpenRef.current = false; | ||
| 486 | }); | 443 | }); |
| 487 | if (requestId !== selectionActionRequestIdRef.current || clicked === null) { | 444 | return { |
| 488 | return; | 445 | position, |
| 489 | } | 446 | clipboardText: selectionText, |
| 490 | switch (clicked) { | 447 | selection: { |
| 491 | case "add-to-chat": | 448 | terminalId, |
| 492 | handleAddTerminalContext(nextAction.selection); | 449 | terminalLabel: readTerminalLabel(), |
| 493 | terminalRef.current?.clearSelection(); | 450 | lineStart, |
| 494 | terminalRef.current?.focus(); | 451 | lineEnd, |
| 452 | text: normalizedText, | ||
| 453 | }, | ||
| 454 | }; | ||
| 455 | }; | ||
| 456 | |||
| 457 | const showSelectionAction = async () => { | ||
| 458 | if (!localApi) { | ||
| 459 | clearSelectionAction(); | ||
| 495 | return; | 460 | return; |
| 496 | case "copy": | 461 | } |
| 497 | try { | 462 | if (selectionActionMenuOpenRef.current) { |
| 498 | await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); | 463 | return; |
| 499 | } catch (error) { | 464 | } |
| 500 | if (requestId !== selectionActionRequestIdRef.current) { | 465 | const nextAction = readSelectionAction(); |
| 501 | return; | 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; | 507 | if (requestId === selectionActionRequestIdRef.current) { |
| 504 | if (activeTerminal) { | 508 | terminalRef.current?.focus(); |
| 505 | writeSystemMessage( | ||
| 506 | activeTerminal, | ||
| 507 | error instanceof Error ? error.message : "Unable to copy terminal selection", | ||
| 508 | ); | ||
| 509 | } | 509 | } |
| 510 | } | 510 | return; |
| 511 | if (requestId === selectionActionRequestIdRef.current) { | 511 | } |
| 512 | terminalRef.current?.focus(); | 512 | }; |
| 513 | } | ||
| 514 | return; | ||
| 515 | } | ||
| 516 | }; | ||
| 517 | 513 | ||
| 518 | const sendTerminalInput = async (data: string, fallbackError: string) => { | 514 | const sendTerminalInput = async (data: string, fallbackError: string) => { |
| 519 | const activeTerminal = terminalRef.current; | 515 | const activeTerminal = terminalRef.current; |
| 520 | if (!activeTerminal) return; | 516 | if (!activeTerminal) return; |
| 521 | const result = await writeTerminal(data); | 517 | const result = await writeTerminal(data); |
| 522 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | 518 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 523 | const error = squashAtomCommandFailure(result); | 519 | const error = squashAtomCommandFailure(result); |
| 524 | writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); | 520 | writeSystemMessage( |
| 525 | } | 521 | activeTerminal, |
| 526 | }; | 522 | error instanceof Error ? error.message : fallbackError, |
| 523 | ); | ||
| 524 | } | ||
| 525 | }; | ||
| 527 | 526 | ||
| 528 | terminal.attachCustomKeyEventHandler((event) => { | 527 | function handleBeforeKey(event: KeyboardEvent): boolean { |
| 529 | const currentKeybindings = keybindingsRef.current; | 528 | const currentKeybindings = keybindingsRef.current; |
| 530 | const options = { context: { terminalFocus: true, terminalOpen: true } }; | 529 | const options = { context: { terminalFocus: true, terminalOpen: true } }; |
| 531 | if ( | 530 | if ( |
| 532 | isTerminalToggleShortcut(event, currentKeybindings, options) || | 531 | isTerminalToggleShortcut(event, currentKeybindings, options) || |
| 533 | isTerminalSplitShortcut(event, currentKeybindings, options) || | 532 | isTerminalSplitShortcut(event, currentKeybindings, options) || |
| 534 | isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || | 533 | isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || |
| 535 | isTerminalNewShortcut(event, currentKeybindings, options) || | 534 | isTerminalNewShortcut(event, currentKeybindings, options) || |
| 536 | isTerminalCloseShortcut(event, currentKeybindings, options) || | 535 | isTerminalCloseShortcut(event, currentKeybindings, options) || |
| 537 | isDiffToggleShortcut(event, currentKeybindings, options) | 536 | isDiffToggleShortcut(event, currentKeybindings, options) |
| 538 | ) { | 537 | ) { |
| 539 | return false; | 538 | return false; |
| 540 | } | 539 | } |
| 541 | 540 | ||
| 542 | const navigationData = terminalNavigationShortcutData(event); | 541 | const navigationData = terminalNavigationShortcutData(event); |
| 543 | if (navigationData !== null) { | 542 | if (navigationData !== null) { |
| 544 | event.preventDefault(); | 543 | event.preventDefault(); |
| 545 | event.stopPropagation(); | 544 | event.stopPropagation(); |
| 546 | void sendTerminalInput(navigationData, "Failed to move cursor"); | 545 | void sendTerminalInput(navigationData, "Failed to move cursor"); |
| 547 | return false; | 546 | return false; |
| 548 | } | 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); | 557 | if (!isTerminalClearShortcut(event)) return true; |
| 551 | if (deleteData !== null) { | ||
| 552 | event.preventDefault(); | 558 | event.preventDefault(); |
| 553 | event.stopPropagation(); | 559 | event.stopPropagation(); |
| 554 | void sendTerminalInput(deleteData, "Failed to delete terminal input"); | 560 | void sendTerminalInput("\u000c", "Failed to clear terminal"); |
| 555 | return false; | 561 | return false; |
| 556 | } | 562 | } |
| 557 | 563 | ||
| 558 | if (!isTerminalClearShortcut(event)) return true; | 564 | function handleLinkActivate(text: string, event: MouseEvent): void { |
| 559 | event.preventDefault(); | 565 | if (!isTerminalLinkActivation(event)) return; |
| 560 | event.stopPropagation(); | 566 | const latestTerminal = terminalRef.current; |
| 561 | void sendTerminalInput("\u000c", "Failed to clear terminal"); | 567 | if (!latestTerminal) return; |
| 562 | return false; | 568 | if (/^https?:\/\//u.test(text)) { |
| 563 | }); | 569 | if (!localApi) { |
| 564 | 570 | writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); | |
| 565 | const terminalLinksDisposable = terminal.registerLinkProvider({ | 571 | return; |
| 566 | provideLinks: (bufferLineNumber, callback) => { | 572 | } |
| 567 | const activeTerminal = terminalRef.current; | 573 | const fallbackToBrowser = () => { |
| 568 | if (!activeTerminal) { | 574 | void localApi.shell.openExternal(text).catch((error: unknown) => { |
| 569 | callback(undefined); | 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 | return; | 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) => | 605 | function handleCopy(text: string): void { |
| 574 | activeTerminal.buffer.active.getLine(bufferLineIndex), | 606 | void writeTextToClipboard(text, "terminal selection").catch((error: unknown) => { |
| 575 | ); | 607 | const activeTerminal = terminalRef.current; |
| 576 | if (!wrappedLine) { | 608 | if (!activeTerminal) return; |
| 577 | callback(undefined); | 609 | writeSystemMessage( |
| 578 | return; | 610 | activeTerminal, |
| 579 | } | 611 | error instanceof Error ? error.message : "Unable to copy terminal selection", |
| 612 | ); | ||
| 613 | }); | ||
| 614 | } | ||
| 580 | 615 | ||
| 581 | const links = extractTerminalLinks(wrappedLine.text) | 616 | function handleData(data: string): void { |
| 582 | .map((match) => ({ | 617 | void (async () => { |
| 583 | match, | 618 | const result = await writeTerminal(data); |
| 584 | range: resolveWrappedTerminalLinkRange(wrappedLine, match), | 619 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; |
| 585 | })) | 620 | const error = squashAtomCommandFailure(result); |
| 586 | .filter(({ range }) => | 621 | writeSystemMessage( |
| 587 | wrappedTerminalLinkRangeIntersectsBufferLine(range, bufferLineNumber), | 622 | terminal, |
| 623 | error instanceof Error ? error.message : "Terminal write failed", | ||
| 588 | ); | 624 | ); |
| 589 | if (links.length === 0) { | 625 | })(); |
| 590 | callback(undefined); | 626 | } |
| 627 | |||
| 628 | function handleSelectionChange(): void { | ||
| 629 | if (terminalRef.current?.hasSelection()) { | ||
| 591 | return; | 630 | return; |
| 592 | } | 631 | } |
| 632 | clearSelectionAction(); | ||
| 633 | } | ||
| 593 | 634 | ||
| 594 | callback( | 635 | const handleMouseUp = (event: MouseEvent) => { |
| 595 | links.map(({ match, range }) => ({ | 636 | const shouldHandle = shouldHandleTerminalSelectionMouseUp( |
| 596 | text: match.text, | 637 | selectionGestureActiveRef.current, |
| 597 | range, | 638 | event.button, |
| 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 | })), | ||
| 645 | ); | 639 | ); |
| 646 | }, | 640 | selectionGestureActiveRef.current = false; |
| 647 | }); | 641 | if (!shouldHandle) { |
| 648 | |||
| 649 | const inputDisposable = terminal.onData((data) => { | ||
| 650 | void (async () => { | ||
| 651 | const result = await writeTerminal(data); | ||
| 652 | if (result._tag === "Success" || isAtomCommandInterrupted(result)) { | ||
| 653 | return; | 642 | return; |
| 654 | } | 643 | } |
| 655 | const error = squashAtomCommandFailure(result); | 644 | selectionPointerRef.current = { x: event.clientX, y: event.clientY }; |
| 656 | writeSystemMessage( | 645 | const delay = terminalSelectionActionDelayForClickCount(event.detail); |
| 657 | terminal, | 646 | selectionActionTimerRef.current = window.setTimeout(() => { |
| 658 | error instanceof Error ? error.message : "Terminal write failed", | 647 | selectionActionTimerRef.current = null; |
| 659 | ); | 648 | window.requestAnimationFrame(() => { |
| 660 | })(); | 649 | void showSelectionAction(); |
| 661 | }); | 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 | const themeObserver = new MutationObserver(() => { |
| 664 | if (terminalRef.current?.hasSelection()) { | 665 | const activeTerminal = terminalRef.current; |
| 665 | return; | 666 | if (!activeTerminal) return; |
| 666 | } | 667 | activeTerminal.setTheme(terminalThemeFromApp(containerRef.current)); |
| 667 | clearSelectionAction(); | 668 | }); |
| 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) => { | 675 | const fitTimer = window.setTimeout(() => { |
| 671 | const shouldHandle = shouldHandleTerminalSelectionMouseUp( | 676 | const activeTerminal = terminalRef.current; |
| 672 | selectionGestureActiveRef.current, | 677 | if (!activeTerminal) return; |
| 673 | event.button, | 678 | const wasAtBottom = activeTerminal.isAtBottom(); |
| 674 | ); | 679 | activeTerminal.fit(); |
| 675 | selectionGestureActiveRef.current = false; | 680 | if (wasAtBottom) { |
| 676 | if (!shouldHandle) { | 681 | activeTerminal.scrollToBottom(); |
| 677 | return; | 682 | } |
| 678 | } | 683 | }, 30); |
| 679 | selectionPointerRef.current = { x: event.clientX, y: event.clientY }; | 684 | setupCleanups.push(() => window.clearTimeout(fitTimer)); |
| 680 | const delay = terminalSelectionActionDelayForClickCount(event.detail); | 685 | |
| 681 | selectionActionTimerRef.current = window.setTimeout(() => { | 686 | const cleanups = setupCleanups; |
| 682 | selectionActionTimerRef.current = null; | 687 | setupCleanups = []; |
| 683 | window.requestAnimationFrame(() => { | 688 | setupTerminal = null; |
| 684 | void showSelectionAction(); | 689 | return () => { |
| 685 | }); | 690 | for (const cleanup of cleanups.toReversed()) cleanup(); |
| 686 | }, delay); | 691 | if (terminalRef.current === terminal) terminalRef.current = null; |
| 687 | }; | 692 | terminal.dispose(); |
| 688 | const handlePointerDown = (event: PointerEvent) => { | 693 | }; |
| 689 | clearSelectionAction(); | ||
| 690 | selectionGestureActiveRef.current = event.button === 0; | ||
| 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(() => { | 696 | void setup() |
| 707 | const activeTerminal = terminalRef.current; | 697 | .then((nextTeardown) => { |
| 708 | const activeFitAddon = fitAddonRef.current; | 698 | if (cancelled) { |
| 709 | if (!activeTerminal || !activeFitAddon) return; | 699 | nextTeardown?.(); |
| 710 | const wasAtBottom = | 700 | return; |
| 711 | activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; | 701 | } |
| 712 | fitTerminalSafely(activeFitAddon); | 702 | teardown = nextTeardown; |
| 713 | if (wasAtBottom) { | 703 | }) |
| 714 | activeTerminal.scrollToBottom(); | 704 | .catch((error: unknown) => { |
| 715 | } | 705 | for (const cleanup of setupCleanups.toReversed()) cleanup(); |
| 716 | void resizeTerminal(activeTerminal.cols, activeTerminal.rows); | 706 | setupCleanups = []; |
| 717 | }, 30); | 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 | return () => { | 716 | return () => { |
| 720 | window.clearTimeout(fitTimer); | 717 | cancelled = true; |
| 721 | inputDisposable.dispose(); | 718 | teardown?.(); |
| 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(); | ||
| 733 | }; | 719 | }; |
| 734 | // autoFocus is intentionally omitted; | 720 | // autoFocus is intentionally omitted; |
| 735 | // it is only read at mount time and must not trigger terminal teardown/recreation. | 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 | }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); | 722 | }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); |
| 738 | 723 | ||
| 739 | useEffect(() => { | 724 | useEffect(() => { |
| ... | @@ -750,6 +735,7 @@ export function TerminalViewport({ | ... | @@ -750,6 +735,7 @@ export function TerminalViewport({ |
| 750 | } | 735 | } |
| 751 | 736 | ||
| 752 | const previous = previousSessionRef.current; | 737 | const previous = previousSessionRef.current; |
| 738 | synchronizeTerminalStatus(terminal, current.status); | ||
| 753 | if (current.version === previous.version) { | 739 | if (current.version === previous.version) { |
| 754 | return; | 740 | return; |
| 755 | } | 741 | } |
| ... | @@ -768,25 +754,6 @@ export function TerminalViewport({ | ... | @@ -768,25 +754,6 @@ export function TerminalViewport({ |
| 768 | writeSystemMessage(terminal, current.error); | 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 | if (previous.version === 0 && autoFocus) { | 757 | if (previous.version === 0 && autoFocus) { |
| 791 | window.requestAnimationFrame(() => { | 758 | window.requestAnimationFrame(() => { |
| 792 | terminal.focus(); | 759 | terminal.focus(); |
| ... | @@ -809,15 +776,15 @@ export function TerminalViewport({ | ... | @@ -809,15 +776,15 @@ export function TerminalViewport({ |
| 809 | 776 | ||
| 810 | useEffect(() => { | 777 | useEffect(() => { |
| 811 | const terminal = terminalRef.current; | 778 | const terminal = terminalRef.current; |
| 812 | const fitAddon = fitAddonRef.current; | 779 | if (!terminal) return; |
| 813 | if (!terminal || !fitAddon) return; | 780 | const wasAtBottom = terminal.isAtBottom(); |
| 814 | const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; | 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 | const frame = window.requestAnimationFrame(() => { | 783 | const frame = window.requestAnimationFrame(() => { |
| 816 | fitTerminalSafely(fitAddon); | 784 | terminal.fit(); |
| 817 | if (wasAtBottom) { | 785 | if (wasAtBottom) { |
| 818 | terminal.scrollToBottom(); | 786 | terminal.scrollToBottom(); |
| 819 | } | 787 | } |
| 820 | void resizeTerminal(terminal.cols, terminal.rows); | ||
| 821 | }); | 788 | }); |
| 822 | return () => { | 789 | return () => { |
| 823 | window.cancelAnimationFrame(frame); | 790 | window.cancelAnimationFrame(frame); |
| ... | @@ -1276,7 +1243,7 @@ export default function ThreadTerminalDrawer({ | ... | @@ -1276,7 +1243,7 @@ export default function ThreadTerminalDrawer({ |
| 1276 | 1243 | ||
| 1277 | {!hasTerminalSidebar && ( | 1244 | {!hasTerminalSidebar && ( |
| 1278 | <div className="pointer-events-none absolute right-2 top-2 z-20"> | 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 | <TerminalActionButton | 1247 | <TerminalActionButton |
| 1281 | className={`p-1 text-foreground/90 transition-colors ${ | 1248 | className={`p-1 text-foreground/90 transition-colors ${ |
| 1282 | hasReachedSplitLimit | 1249 | hasReachedSplitLimit |
apps/web/src/index.css+30-14| ... | @@ -1049,6 +1049,36 @@ code { | ... | @@ -1049,6 +1049,36 @@ code { |
| 1049 | background: var(--app-scrollbar-thumb-hover); | 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 | .model-picker-list::-webkit-scrollbar-track { | 1082 | .model-picker-list::-webkit-scrollbar-track { |
| 1053 | margin-block: 0.5rem; | 1083 | margin-block: 0.5rem; |
| 1054 | } | 1084 | } |
| ... | @@ -1101,20 +1131,6 @@ code { | ... | @@ -1101,20 +1131,6 @@ code { |
| 1101 | display: none; | 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 | /* Reasoning select -- clickable label surface */ | 1134 | /* Reasoning select -- clickable label surface */ |
| 1119 | label:has(> select#reasoning-effort) { | 1135 | label:has(> select#reasoning-effort) { |
| 1120 | position: relative; | 1136 | position: relative; |
apps/web/src/main.tsx-1| ... | @@ -8,7 +8,6 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" | ... | @@ -8,7 +8,6 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" |
| 8 | import "@fontsource-variable/dm-sans/index.css"; | 8 | import "@fontsource-variable/dm-sans/index.css"; |
| 9 | import "@fontsource/jetbrains-mono/400.css"; | 9 | import "@fontsource/jetbrains-mono/400.css"; |
| 10 | import "@fontsource/jetbrains-mono/500.css"; | 10 | import "@fontsource/jetbrains-mono/500.css"; |
| 11 | import "@xterm/xterm/css/xterm.css"; | ||
| 12 | import "./index.css"; | 11 | import "./index.css"; |
| 13 | 12 | ||
| 14 | import { isElectron } from "./env"; | 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,6 +126,7 @@ const allowedHosts = [".ts.net", ...configuredAllowedHosts]; |
| 126 | 126 | ||
| 127 | export default defineConfig(() => { | 127 | export default defineConfig(() => { |
| 128 | return { | 128 | return { |
| 129 | assetsInclude: ["**/*.wasm"], | ||
| 129 | plugins: [ | 130 | plugins: [ |
| 130 | tanstackRouter(), | 131 | tanstackRouter(), |
| 131 | react(), | 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,12 +570,6 @@ importers: |
| 570 | '@tanstack/react-router': | 570 | '@tanstack/react-router': |
| 571 | specifier: ^1.160.2 | 571 | specifier: ^1.160.2 |
| 572 | version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 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 | class-variance-authority: | 573 | class-variance-authority: |
| 580 | specifier: ^0.7.1 | 574 | specifier: ^0.7.1 |
| 581 | version: 0.7.1 | 575 | version: 0.7.1 |
| ... | @@ -5156,12 +5150,6 @@ packages: | ... | @@ -5156,12 +5150,6 @@ packages: |
| 5156 | resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} | 5150 | resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} |
| 5157 | engines: {node: '>=14.6'} | 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 | '@yuuang/ffi-rs-android-arm64@1.3.2': | 5153 | '@yuuang/ffi-rs-android-arm64@1.3.2': |
| 5166 | resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==} | 5154 | resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==} |
| 5167 | engines: {node: '>= 12'} | 5155 | engines: {node: '>= 12'} |
| ... | @@ -15283,10 +15271,6 @@ snapshots: | ... | @@ -15283,10 +15271,6 @@ snapshots: |
| 15283 | 15271 | ||
| 15284 | '@xmldom/xmldom@0.9.10': {} | 15272 | '@xmldom/xmldom@0.9.10': {} |
| 15285 | 15273 | ||
| 15286 | '@xterm/addon-fit@0.11.0': {} | ||
| 15287 | |||
| 15288 | '@xterm/xterm@6.0.0': {} | ||
| 15289 | |||
| 15290 | '@yuuang/ffi-rs-android-arm64@1.3.2': | 15274 | '@yuuang/ffi-rs-android-arm64@1.3.2': |
| 15291 | optional: true | 15275 | optional: true |
| 15292 | 15276 |