authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-07-31 13:15:25+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-31 13:15:25+02:00
logacf761b2f511e1918260a8043e151bbc2849e29a
treeded5ee7fa16b7d21c504f5c3adb12a40523e6a9d
parentab8182cb4e5338caf93ee90a7bd57351a0c77283
signature Signed by PGP key B5690EEEBB952194

feat(web): render terminals with libghostty-vt (#4860)

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-vt18## Ghostty / libghostty-vt
1919
20The Android terminal renderer vendors upstream `libghostty-vt` shared libraries and C headers.20The Android terminal renderer vendors upstream `libghostty-vt` shared libraries and C headers.
21The web terminal vendors a WebAssembly build from the same revision and uses the same C ABI.
2122
22- Upstream project: https://github.com/ghostty-org/ghostty23- Upstream project: https://github.com/ghostty-org/ghostty
23- Vendored revision: `9f62873bf195e4d8a762d768a1405a5f2f7b1697`24- Vendored revision: `9f62873bf195e4d8a762d768a1405a5f2f7b1697`
24- License: MIT25- License: MIT
2526
26Ghostty's MIT license applies to the vendored Android libraries. Keep this notice in sync when27Ghostty's MIT license applies to the vendored Android and web libraries. Keep this notice and both
27updating `Vendor/libghostty-vt`.28artifacts in sync when updating `Vendor/libghostty-vt`.
2829
29## MesloLGS NF (Android terminal font)30## MesloLGS NF (Android terminal font)
3031
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
3set -euo pipefail
4
5SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6WEB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
7REPO_DIR="$(cd "${WEB_DIR}/../.." && pwd)"
8MOBILE_VENDOR_DIR="${REPO_DIR}/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt"
9VENDOR_DIR="${WEB_DIR}/src/terminal/ghostty/vendor"
10
11GHOSTTY_REVISION="$(tr -d '[:space:]' < "${MOBILE_VENDOR_DIR}/VERSION")"
12GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${HOME}/.cache/t3code/ghostty-${GHOSTTY_REVISION:0:8}}"
13GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}"
14GHOSTTY_ZIG="${GHOSTTY_ZIG:-}"
15
16log() {
17 printf '[libghostty-vt-wasm] %s\n' "$*"
18}
19
20die() {
21 printf '[libghostty-vt-wasm] error: %s\n' "$*" >&2
22 exit 1
23}
24
25require_cmd() {
26 command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
27}
28
29ensure_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
68ensure_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
92ensure_zig
93ensure_ghostty_source
94
95build_root="$(mktemp -d)"
96trap 'rm -rf "${build_root}"' EXIT
97
98log "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
113mkdir -p "${VENDOR_DIR}"
114cp "${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"
122chmod 0644 "${VENDOR_DIR}/ghostty-write-pty.wasm"
123log "wrote ${VENDOR_DIR}/ghostty-vt.wasm"
apps/web/scripts/ghostty-write-pty.zig created+5
...@@ -0,0 +1,5 @@
1extern "env" fn t3_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void;
2
3export 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 = shouldCreateNewTerminal2836 const targetTerminalId = shouldCreateNewTerminal
2821 ? nextTerminalId(activeKnownTerminalIds)2837 ? nextTerminalId(allocatableActiveTerminalIds)
2822 : baseTerminalId;2838 : baseTerminalId;
2823 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal2839 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 useRightPanelStore3202 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";
22
3import {3import {
4 resolveTerminalSelectionActionPosition,4 resolveTerminalSelectionActionPosition,
5 shouldHandleTerminalExit,
5 shouldHandleTerminalSelectionMouseUp,6 shouldHandleTerminalSelectionMouseUp,
6 terminalSelectionActionDelayForClickCount,7 terminalSelectionActionDelayForClickCount,
8 terminalSelectionLineRange,
7} from "./ThreadTerminalDrawer";9} from "./ThreadTerminalDrawer";
810
9describe("resolveTerminalSelectionActionPosition", () => {11describe("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 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { FitAddon } from "@xterm/addon-fit";
3import {2import {
4 isAtomCommandInterrupted,3 isAtomCommandInterrupted,
5 squashAtomCommandFailure,4 squashAtomCommandFailure,
6} from "@t3tools/client-runtime/state/runtime";5} from "@t3tools/client-runtime/state/runtime";
6import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal";
7import {7import {
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";
20import { getTerminalLabel } from "@t3tools/shared/terminalLabels";20import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
21import { Terminal, type ITheme } from "@xterm/xterm";
22import {21import {
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";
34import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";33import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
35import { cn } from "~/lib/utils";34import { cn } from "~/lib/utils";
36import { type TerminalContextSelection } from "~/lib/terminalContext";35import { type TerminalContextSelection } from "~/lib/terminalContext";
37import { useOpenInPreferredEditor } from "../editorPreferences";
38import {36import {
39 collectWrappedTerminalLinkLine,37 GhosttyTerminalSurface,
40 extractTerminalLinks,38 type GhosttyTerminalSurfaceOptions,
41 isTerminalLinkActivation,39} from "~/terminal/ghostty/surface";
42 resolvePathLinkTarget,40import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core";
43 resolveWrappedTerminalLinkRange,41import { useOpenInPreferredEditor } from "../editorPreferences";
44 wrappedTerminalLinkRangeIntersectsBufferLine,42import { isTerminalLinkActivation, resolvePathLinkTarget } from "../terminal-links";
45} from "../terminal-links";
46import {43import {
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}
8481
85function writeSystemMessage(terminal: Terminal, message: string): void {82function 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}
8885
89function writeTerminalBuffer(terminal: Terminal, buffer: string): void {86function 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}
9589
96function fitTerminalSafely(fitAddon: FitAddon): boolean {90function 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}
104111
105function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string {112function 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}
126133
127function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme {134function 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 );
145152
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}
200168
201function 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
226export function resolveTerminalSelectionActionPosition(options: {169export 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}
271214
215export 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
226export 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
272interface TerminalViewportProps {236interface 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 };
377365
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;
385373
386 const localApi = readLocalApi();374 const localApi = readLocalApi();
387375 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),
401389 };
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 === null403 if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error);
444 ? null404 // 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);
460420
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.contextMenu436 }
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 };
517513
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 };
527526
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 }
541540
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 }
549556
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 }
557563
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) {
564570 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 }
572604
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 }
580615
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 }
593634
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 });
662663
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());
669674
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 });
705695
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 });
718715
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]);
738723
739 useEffect(() => {724 useEffect(() => {
...@@ -750,6 +735,7 @@ export function TerminalViewport({...@@ -750,6 +735,7 @@ export function TerminalViewport({
750 }735 }
751736
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 }
770756
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({
809776
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({
12761243
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 <TerminalActionButton1247 <TerminalActionButton
1281 className={`p-1 text-foreground/90 transition-colors ${1248 className={`p-1 text-foreground/90 transition-colors ${
1282 hasReachedSplitLimit1249 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}
10511051
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}
11031133
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 */
1119label:has(> select#reasoning-effort) {1135label: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"
8import "@fontsource-variable/dm-sans/index.css";8import "@fontsource-variable/dm-sans/index.css";
9import "@fontsource/jetbrains-mono/400.css";9import "@fontsource/jetbrains-mono/400.css";
10import "@fontsource/jetbrains-mono/500.css";10import "@fontsource/jetbrains-mono/500.css";
11import "@xterm/xterm/css/xterm.css";
12import "./index.css";11import "./index.css";
1312
14import { isElectron } from "./env";13import { isElectron } from "./env";
apps/web/src/terminal/ghostty/README.md created+19
...@@ -0,0 +1,19 @@
1# Ghostty web terminal
2
3This directory is the browser adapter for the same official `libghostty-vt` C ABI used by Android.
4It 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
18Keep browser behavior here and terminal transport in the existing client runtime. Do not add React
19state 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 @@
1import {
2 type GhosttyKeyboardLayoutMap,
3 ghosttyKeyForCode,
4 ghosttyUnshiftedCodepoint,
5 loadGhosttyKeyboardLayoutMap,
6} from "./keyCodes";
7import { GhosttyRuntime, loadGhosttyRuntime } from "./runtime";
8
9const GHOSTTY_SUCCESS = 0;
10const GHOSTTY_OUT_OF_SPACE = -3;
11const MAX_SCROLLBACK_ROWS = 10_000;
12// wasm32 C ABI layout for GhosttyTerminalSelectionFormatOptions at the
13// libghostty-vt revision pinned alongside this module.
14const SELECTION_FORMAT_OPTIONS_SIZE = 16;
15
16const 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
33const ROW_DATA = {
34 dirty: 1,
35 raw: 2,
36 cells: 3,
37} as const;
38
39const 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
49const RAW_CELL_DATA = {
50 wide: 3,
51} as const;
52
53export const GHOSTTY_CELL_WIDE = {
54 narrow: 0,
55 wide: 1,
56 spacerTail: 2,
57 spacerHead: 3,
58} as const;
59
60export interface GhosttyColor {
61 readonly r: number;
62 readonly g: number;
63 readonly b: number;
64}
65
66export 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
74export 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
88export 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
96export 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
111export 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
122export 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. */
129export interface GhosttyPointInput {
130 readonly x: number;
131 readonly y: number;
132 readonly tag?: 1 | 2;
133}
134
135export 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
152const decoder = new TextDecoder();
153const encoder = new TextEncoder();
154
155function 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
164function sameColor(left: GhosttyColor, right: GhosttyColor): boolean {
165 return left.r === right.r && left.g === right.g && left.b === right.b;
166}
167
168export 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
1187export 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 @@
1The MIT License (MIT)
2
3Copyright (c) 2014 Ryan L McIntyre
4
5Permission is hereby granted, free of charge, to any person obtaining a copy
6of this software and associated documentation files (the "Software"), to deal
7in the Software without restriction, including without limitation the rights
8to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9copies of the Software, and to permit persons to whom the Software is
10furnished to do so, subject to the following conditions:
11
12The above copyright notice and this permission notice shall be included in all
13copies or substantial portions of the Software.
14
15THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21SOFTWARE.
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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { ghosttyKeyForCode, ghosttyUnshiftedCodepoint } from "./keyCodes";
4
5describe("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
14describe("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.
4const 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
183const codeToGhosttyKey = new Map<string, number>(
184 ghosttyKeyboardCodes.map((code, index) => [code, index]),
185);
186
187export function ghosttyKeyForCode(code: string): number {
188 return codeToGhosttyKey.get(code) ?? 0;
189}
190
191export interface GhosttyKeyboardLayoutMap {
192 get(code: string): string | undefined;
193}
194
195const 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
219let keyboardLayoutMapPromise: Promise<GhosttyKeyboardLayoutMap | undefined> | undefined;
220
221export 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
236export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { GHOSTTY_CELL_WIDE, type GhosttyCell, type GhosttySnapshot } from "./core";
4import {
5 ghosttyTextRunEnd,
6 measureGhosttyCell,
7 renderGhosttySnapshot,
8 terminalGridSize,
9} from "./renderer";
10
11const 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
25describe("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
41describe("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
60describe("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
73describe("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 @@
1import {
2 GHOSTTY_CELL_WIDE,
3 ghosttyColorsEqual,
4 type GhosttyCell,
5 type GhosttyColor,
6 type GhosttySnapshot,
7} from "./core";
8
9export interface GhosttyCellMetrics {
10 readonly width: number;
11 readonly height: number;
12 readonly baseline: number;
13}
14
15const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)";
16
17function cssColor(color: GhosttyColor): string {
18 return `rgb(${color.r}, ${color.g}, ${color.b})`;
19}
20
21function 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
33export 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
52function 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
58export 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
77export 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
89export 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 @@
1import ghosttyWasmUrl from "./vendor/ghostty-vt.wasm?url";
2import ghosttyWritePtyWasmUrl from "./vendor/ghostty-write-pty.wasm?url&no-inline";
3
4type WasmFunction = (...args: Array<number | bigint>) => number;
5
6interface TypeField {
7 readonly offset: number;
8 readonly size: number;
9 readonly type: string;
10}
11
12interface TypeLayout {
13 readonly size: number;
14 readonly align: number;
15 readonly fields: Readonly<Record<string, TypeField>>;
16}
17
18type TypeLayouts = Readonly<Record<string, TypeLayout>>;
19
20const textDecoder = new TextDecoder();
21
22export 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
213let runtimePromise: Promise<GhosttyRuntime> | null = null;
214
215export 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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import wasmDataUrl from "./vendor/ghostty-vt.wasm?inline";
4import writePtyWasmDataUrl from "./vendor/ghostty-write-pty.wasm?inline";
5import mobileVersion from "../../../../mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION?raw";
6import { ghosttyKeyForCode } from "./keyCodes";
7
8type WasmFunction = (...args: number[]) => number;
9
10function 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
16describe("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 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import type { GhosttyCell, GhosttyRow } from "./core";
4import {
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
26const 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
40describe("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
57describe("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
143describe("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
170describe("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
192describe("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
204describe("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
225describe("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
244describe("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
269describe("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
295describe("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
304describe("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
317describe("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
364describe("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 @@
1import { isMacPlatform } from "../../lib/utils";
2import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links";
3import {
4 GhosttyTerminalCore,
5 type GhosttyScrollbar,
6 type GhosttySnapshot,
7 type GhosttyTheme,
8} from "./core";
9import {
10 measureGhosttyCell,
11 renderGhosttySnapshot,
12 terminalGridSize,
13 type GhosttyCellMetrics,
14} from "./renderer";
15import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url";
16
17export const DEFAULT_TERMINAL_FONT_SIZE = 12;
18const MIN_TERMINAL_FONT_SIZE = 6;
19const 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.
24const 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.
30export const DEFAULT_TERMINAL_FONT_FAMILY =
31 '"SF Mono", "SFMono-Regular", "JetBrains Mono", ' + TERMINAL_GLYPH_FALLBACKS;
32const CONTENT_PADDING = 4;
33const MIN_SCROLLBAR_THUMB_HEIGHT = 18;
34
35/** Requested terminal font; omitted fields fall back to the defaults. */
36export interface GhosttyTerminalFont {
37 readonly family?: string;
38 readonly size?: number;
39}
40
41let 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 */
49function 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
62export 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
69export 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 */
81export 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
93export interface TerminalScrollbarGeometry {
94 readonly thumbHeight: number;
95 readonly thumbTop: number;
96 readonly maxOffset: number;
97}
98
99export 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
120export 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
134function 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
139function 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
147export 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
186export function terminalLinkAtColumn(row: GhosttySnapshot["rowData"][number], column: number) {
187 return terminalLinkAtPosition([row], 0, column);
188}
189
190export 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
198export 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
206export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputType">): boolean {
207 return (
208 event.inputType === "" ||
209 event.inputType === "insertCompositionText" ||
210 event.inputType === "insertFromComposition"
211 );
212}
213
214export function isTerminalAltGraphText(
215 event: Pick<KeyboardEvent, "getModifierState" | "key">,
216): boolean {
217 return event.getModifierState("AltGraph") && [...event.key].length === 1;
218}
219
220export 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
227export 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
245export 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
258export 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
267export 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
284export interface TerminalSelectionClickSequence {
285 readonly count: number;
286 readonly time: number;
287 readonly x: number;
288 readonly y: number;
289}
290
291export 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
307export interface GhosttySelectionPosition {
308 readonly start: { readonly x: number; readonly y: number };
309 readonly end: { readonly x: number; readonly y: number };
310}
311
312export 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
323export 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];
126126
127export default defineConfig(() => {127export 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
3Terminal sessions remain server-owned PTYs. Clients receive the existing raw byte stream and send
4input and resize events over the existing terminal contracts; renderer choices never cross the
5wire.
6
7## Ghostty alignment
8
9Android and web use the official `libghostty-vt` C ABI for parsing, terminal state, grapheme
10boundaries, 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
17The platform adapters deliberately own only platform behavior. Android owns its Kotlin Canvas and
18touch integration. Web owns browser font shaping, the hidden IME textarea, clipboard and DOM input,
19and its Canvas renderer. The web adapter also delegates application mouse encoding, word and line
20selection, and OSC 8 hyperlink metadata to the official ABI. Browser conventions remain available:
21holding Shift bypasses application mouse capture, and the platform link modifier opens hyperlinks.
22React does not participate in terminal frames.
23
24The web runtime is singleton-scoped per browser tab so split terminals share one compiled module
25and memory. Each visible terminal owns and frees its own terminal, render state, row iterator, cell
26iterator, key and mouse encoder, and input event handles. Restoring captured scrollback temporarily
27detaches the PTY callback so historical device queries cannot emit replies into the current shell.
28
29## Updating Ghostty
30
31Update and rebuild Android first, because mobile's `VERSION` file is the single source of truth for
32the upstream pin (the upstream `LICENSE` lives beside it). Then run:
33
34```sh
35pnpm --dir apps/web build:ghostty-wasm
36```
37
38Commit the regenerated web `wasm` artifacts. The build embeds the pinned revision into the binary as
39semver build metadata, and the focused web ABI test reads it back through `ghostty_build_info` and
40compares it against mobile's `VERSION` — so the web vendor directory holds only the artifacts, drift
41cannot hide, and there is no second pin to keep in sync. The same test enforces the artifact budget
42and 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.2571 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.1574 specifier: ^0.7.1
581 version: 0.7.1575 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'}
51585152
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:
1528315271
15284 '@xmldom/xmldom@0.9.10': {}15272 '@xmldom/xmldom@0.9.10': {}
1528515273
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: true15275 optional: true
1529215276