History

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

Wout Stiens committed 3 months ago

Commit acf761b1 parent ab8182c28 files · +5191 −492Browse files

Unified · Split

28 files
  1. apps/desktop/src/electron/ElectronProtocol.test.ts +2 −1
  2. apps/desktop/src/electron/ElectronProtocol.ts +1 −0
  3. apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md +3 −2
  4. apps/web/package.json +1 −2
  5. apps/web/scripts/build-libghostty-wasm.sh +123 −0
  6. apps/web/scripts/ghostty-write-pty.zig +5 −0
  7. apps/web/src/components/ChatView.tsx +35 −20
  8. apps/web/src/components/ThreadTerminalDrawer.test.ts +17 −0
  9. apps/web/src/components/ThreadTerminalDrawer.tsx +406 −439
  10. apps/web/src/index.css +30 −14
  11. apps/web/src/main.tsx +0 −1
  12. apps/web/src/terminal/ghostty/README.md +19 −0
  13. apps/web/src/terminal/ghostty/core.ts +1189 −0
  14. apps/web/src/terminal/ghostty/fonts/LICENSE +21 −0
  15. apps/web/src/terminal/ghostty/fonts/SymbolsNerdFontMono-Regular.woff2 +0 −0
  16. apps/web/src/terminal/ghostty/keyCodes.test.ts +53 −0
  17. apps/web/src/terminal/ghostty/keyCodes.ts +258 −0
  18. apps/web/src/terminal/ghostty/renderer.test.ts +175 −0
  19. apps/web/src/terminal/ghostty/renderer.ts +255 −0
  20. apps/web/src/terminal/ghostty/runtime.ts +221 −0
  21. apps/web/src/terminal/ghostty/runtimeAbi.test.ts +561 −0
  22. apps/web/src/terminal/ghostty/surface.test.ts +383 −0
  23. apps/web/src/terminal/ghostty/surface.ts +1393 −0
  24. apps/web/src/terminal/ghostty/vendor/ghostty-vt.wasm +0 −0
  25. apps/web/src/terminal/ghostty/vendor/ghostty-write-pty.wasm +0 −0
  26. apps/web/vite.config.ts +1 −0
  27. docs/architecture/terminal-renderers.md +42 −0
  28. pnpm-lock.yaml +0 −16
apps/desktop/src/electron/ElectronProtocol.test.ts+2−1
@@ -57,7 +57,7 @@ describe("ElectronProtocol", () => { · 56 unmodified lines
5757 assert.equal(yield* Effect.promise(() => response.text()), "ok");
5858 assert.include(
5959 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",
6161 );
6262 assert.include(
6363 response.headers.get("content-security-policy") ?? "",
@@ -212,6 +212,7 @@ describe("ElectronProtocol", () => { · 148 unmodified lines
212212 assert.deepEqual(directives["script-src"], [
213213 "'self'",
214214 "'unsafe-inline'",
215+ "'wasm-unsafe-eval'",
215216 "https://clerk.t3.codes",
216217 "https://challenges.cloudflare.com",
217218 ]);
apps/desktop/src/electron/ElectronProtocol.ts+1−0
@@ -71,6 +71,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat · 70 unmodified lines
7171 const scriptSources = [
7272 "'self'",
7373 "'unsafe-inline'",
74+ "'wasm-unsafe-eval'",
7475 ...(clerkOrigin ? [clerkOrigin] : []),
7576 "https://challenges.cloudflare.com",
7677 ];
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 · 17 unmodified lines
1818 ## Ghostty / libghostty-vt
1919
2020 The Android terminal renderer vendors upstream `libghostty-vt` shared libraries and C headers.
21+The web terminal vendors a WebAssembly build from the same revision and uses the same C ABI.
2122
2223 - Upstream project: https://github.com/ghostty-org/ghostty
2324 - Vendored revision: `9f62873bf195e4d8a762d768a1405a5f2f7b1697`
2425 - License: MIT
2526
26−Ghostty's MIT license applies to the vendored Android libraries. Keep this notice in sync when
27−updating `Vendor/libghostty-vt`.
27+Ghostty's MIT license applies to the vendored Android and web libraries. Keep this notice and both
28+artifacts in sync when updating `Vendor/libghostty-vt`.
2829
2930 ## MesloLGS NF (Android terminal font)
3031
apps/web/package.json+1−2
@@ -4,6 +4,7 @@ · 3 unmodified lines
44 "private": true,
55 "type": "module",
66 "scripts": {
7+ "build:ghostty-wasm": "bash scripts/build-libghostty-wasm.sh",
78 "dev": "vp dev",
89 "build": "vp build",
910 "preview": "vp preview",
@@ -31,8 +32,6 @@ · 21 unmodified lines
3132 "@t3tools/shared": "workspace:*",
3233 "@tanstack/react-pacer": "^0.19.4",
3334 "@tanstack/react-router": "^1.160.2",
34− "@xterm/addon-fit": "^0.11.0",
35− "@xterm/xterm": "^6.0.0",
3635 "class-variance-authority": "^0.7.1",
3736 "effect": "catalog:",
3837 "jose": "catalog:",
apps/web/scripts/build-libghostty-wasm.sh+123−0
@@ -0,0 +1,123 @@
1+#!/usr/bin/env bash
2+
3+set -euo pipefail
4+
5+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6+WEB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
7+REPO_DIR="$(cd "${WEB_DIR}/../.." && pwd)"
8+MOBILE_VENDOR_DIR="${REPO_DIR}/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt"
9+VENDOR_DIR="${WEB_DIR}/src/terminal/ghostty/vendor"
10+
11+GHOSTTY_REVISION="$(tr -d '[:space:]' < "${MOBILE_VENDOR_DIR}/VERSION")"
12+GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${HOME}/.cache/t3code/ghostty-${GHOSTTY_REVISION:0:8}}"
13+GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}"
14+GHOSTTY_ZIG="${GHOSTTY_ZIG:-}"
15+
16+log() {
17+ printf '[libghostty-vt-wasm] %s\n' "$*"
18+}
19+
20+die() {
21+ printf '[libghostty-vt-wasm] error: %s\n' "$*" >&2
22+ exit 1
23+}
24+
25+require_cmd() {
26+ command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
27+}
28+
29+ensure_zig() {
30+ if [[ -n "${GHOSTTY_ZIG}" ]]; then
31+ [[ -x "${GHOSTTY_ZIG}" ]] || die "GHOSTTY_ZIG is not executable: ${GHOSTTY_ZIG}"
32+ return
33+ fi
34+ if command -v zig >/dev/null 2>&1 && [[ "$(zig version)" == "${GHOSTTY_ZIG_VERSION}" ]]; then
35+ GHOSTTY_ZIG="$(command -v zig)"
36+ return
37+ fi
38+
39+ local host_os host_arch cache_dir
40+ host_os="$(uname -s | tr '[:upper:]' '[:lower:]')"
41+ host_arch="$(uname -m)"
42+ case "${host_os}" in
43+ darwin) host_os="macos" ;;
44+ linux) ;;
45+ *) die "unsupported host OS for Zig download: ${host_os}" ;;
46+ esac
47+ case "${host_arch}" in
48+ arm64) host_arch="aarch64" ;;
49+ aarch64 | x86_64) ;;
50+ *) die "unsupported host architecture: ${host_arch}" ;;
51+ esac
52+
53+ cache_dir="${HOME}/.cache/t3code/zig-${GHOSTTY_ZIG_VERSION}"
54+ GHOSTTY_ZIG="${cache_dir}/zig"
55+ if [[ -x "${GHOSTTY_ZIG}" ]]; then
56+ return
57+ fi
58+
59+ require_cmd curl
60+ require_cmd tar
61+ mkdir -p "${cache_dir}"
62+ log "downloading Zig ${GHOSTTY_ZIG_VERSION}"
63+ curl -fsSL \
64+ "https://ziglang.org/download/${GHOSTTY_ZIG_VERSION}/zig-${host_arch}-${host_os}-${GHOSTTY_ZIG_VERSION}.tar.xz" \
65+ | tar -xJ --strip-components=1 -C "${cache_dir}"
66+}
67+
68+ensure_ghostty_source() {
69+ require_cmd git
70+ if [[ ! -d "${GHOSTTY_SOURCE_DIR}/.git" ]]; then
71+ mkdir -p "$(dirname "${GHOSTTY_SOURCE_DIR}")"
72+ log "cloning Ghostty ${GHOSTTY_REVISION}"
73+ git clone --filter=blob:none --no-checkout https://github.com/ghostty-org/ghostty.git \
74+ "${GHOSTTY_SOURCE_DIR}"
75+ fi
76+
77+ # A cached checkout may still be on a previously pinned revision; converge on
78+ # the pinned one instead of failing the rebuild.
79+ local actual_revision
80+ actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD 2>/dev/null || echo none)"
81+ if [[ "${actual_revision}" != "${GHOSTTY_REVISION}" ]]; then
82+ log "checking out Ghostty ${GHOSTTY_REVISION}"
83+ git -C "${GHOSTTY_SOURCE_DIR}" fetch --depth=1 origin "${GHOSTTY_REVISION}"
84+ git -C "${GHOSTTY_SOURCE_DIR}" checkout --detach "${GHOSTTY_REVISION}"
85+ fi
86+
87+ actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD)"
88+ [[ "${actual_revision}" == "${GHOSTTY_REVISION}" ]] || \
89+ die "expected Ghostty ${GHOSTTY_REVISION}, found ${actual_revision}"
90+}
91+
92+ensure_zig
93+ensure_ghostty_source
94+
95+build_root="$(mktemp -d)"
96+trap 'rm -rf "${build_root}"' EXIT
97+
98+log "building ${GHOSTTY_REVISION} for wasm32-freestanding"
99+(
100+ cd "${GHOSTTY_SOURCE_DIR}"
101+ # The pinned revision rides along as semver build metadata so the artifact
102+ # identifies its own provenance through ghostty_build_info(); mobile's
103+ # VERSION file stays the single source of truth for the pin.
104+ "${GHOSTTY_ZIG}" build \
105+ -Demit-lib-vt \
106+ -Dtarget=wasm32-freestanding \
107+ -Doptimize=ReleaseSmall \
108+ -Dstrip=true \
109+ -Dlib-version-string="0.1.0-dev+${GHOSTTY_REVISION}" \
110+ -p "${build_root}"
111+)
112+
113+mkdir -p "${VENDOR_DIR}"
114+cp "${build_root}/bin/ghostty-vt.wasm" "${VENDOR_DIR}/ghostty-vt.wasm"
115+"${GHOSTTY_ZIG}" build-exe \
116+ "${SCRIPT_DIR}/ghostty-write-pty.zig" \
117+ -target wasm32-freestanding \
118+ -O ReleaseSmall \
119+ -fno-entry \
120+ -rdynamic \
121+ -femit-bin="${VENDOR_DIR}/ghostty-write-pty.wasm"
122+chmod 0644 "${VENDOR_DIR}/ghostty-write-pty.wasm"
123+log "wrote ${VENDOR_DIR}/ghostty-vt.wasm"
apps/web/scripts/ghostty-write-pty.zig+5−0
@@ -0,0 +1,5 @@
1+extern "env" fn t3_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void;
2+
3+export fn ghostty_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void {
4+ t3_write_pty(terminal, userdata, data, len);
5+}
apps/web/src/components/ChatView.tsx+35−20
@@ -704,6 +704,19 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 703 unmodified lines
704704 () => drawerTerminalSessions.map((session) => session.target.terminalId),
705705 [drawerTerminalSessions],
706706 );
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+ );
707720 const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight);
708721 const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal);
709722 const storeSplitTerminalVertical = useTerminalUiStateStore(
@@ -773,7 +786,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 63 unmodified lines
773786 if (!cwd) {
774787 return;
775788 }
776− const terminalId = nextTerminalId(serverOrderedTerminalIds);
789+ const terminalId = nextTerminalId(allocatableTerminalIds);
777790 storeSplitTerminal(threadRef, terminalId);
778791 bumpFocusRequestId();
779792 void openTerminal({
@@ -787,11 +800,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 7 unmodified lines
787800 },
788801 });
789802 }, [
803+ allocatableTerminalIds,
790804 bumpFocusRequestId,
791805 cwd,
792806 effectiveWorktreePath,
793807 runtimeEnv,
794− serverOrderedTerminalIds,
795808 storeSplitTerminal,
796809 threadId,
797810 threadRef,
@@ -801,7 +814,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 3 unmodified lines
801814 if (!cwd) {
802815 return;
803816 }
804− const terminalId = nextTerminalId(serverOrderedTerminalIds);
817+ const terminalId = nextTerminalId(allocatableTerminalIds);
805818 storeSplitTerminalVertical(threadRef, terminalId);
806819 bumpFocusRequestId();
807820 void openTerminal({
@@ -815,12 +828,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 7 unmodified lines
815828 },
816829 });
817830 }, [
831+ allocatableTerminalIds,
818832 bumpFocusRequestId,
819833 cwd,
820834 effectiveWorktreePath,
821835 openTerminal,
822836 runtimeEnv,
823− serverOrderedTerminalIds,
824837 storeSplitTerminalVertical,
825838 threadId,
826839 threadRef,
@@ -830,7 +843,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 3 unmodified lines
830843 if (!cwd) {
831844 return;
832845 }
833− const terminalId = nextTerminalId(serverOrderedTerminalIds);
846+ const terminalId = nextTerminalId(allocatableTerminalIds);
834847 storeNewTerminal(threadRef, terminalId);
835848 bumpFocusRequestId();
836849 void openTerminal({
@@ -847,8 +860,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra · 10 unmodified lines
847860 bumpFocusRequestId,
848861 cwd,
849862 effectiveWorktreePath,
863+ allocatableTerminalIds,
850864 runtimeEnv,
851− serverOrderedTerminalIds,
852865 storeNewTerminal,
853866 threadId,
854867 threadRef,
@@ -1521,6 +1534,10 @@ function ChatViewContent(props: ChatViewProps) { · 666 unmodified lines
15211534 ),
15221535 [rightPanelState.surfaces],
15231536 );
1537+ const allocatableActiveTerminalIds = useMemo(
1538+ () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])],
1539+ [activeKnownTerminalIds, panelTerminalIds],
1540+ );
15241541 const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime();
15251542 const rightPanelOpen = rightPanelState.isOpen;
15261543 const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet;
@@ -2628,7 +2645,7 @@ function ChatViewContent(props: ChatViewProps) { · 1101 unmodified lines
26282645 if (!cwdForOpen) {
26292646 return;
26302647 }
2631− const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]);
2648+ const terminalId = nextTerminalId(allocatableActiveTerminalIds);
26322649 storeEnsureTerminal(activeThreadRef, terminalId, { open: true });
26332650 void openTerminal({
26342651 environmentId,
@@ -2647,15 +2664,14 @@ function ChatViewContent(props: ChatViewProps) { · 12 unmodified lines
26472664 }
26482665 setTerminalOpen(nextOpen);
26492666 }, [
2650− activeKnownTerminalIds,
26512667 activeProject,
26522668 activeThreadId,
26532669 activeThreadRef,
26542670 activeThreadWorktreePath,
2671+ allocatableActiveTerminalIds,
26552672 environmentId,
26562673 gitCwd,
26572674 openTerminal,
2658− panelTerminalIds,
26592675 setTerminalOpen,
26602676 storeEnsureTerminal,
26612677 terminalUiState.terminalIds.length,
@@ -2670,7 +2686,7 @@ function ChatViewContent(props: ChatViewProps) { · 8 unmodified lines
26702686 if (!cwdForOpen) {
26712687 return;
26722688 }
2673− const terminalId = nextTerminalId(activeKnownTerminalIds);
2689+ const terminalId = nextTerminalId(allocatableActiveTerminalIds);
26742690 if (direction === "vertical") {
26752691 storeSplitTerminalVertical(activeThreadRef, terminalId);
26762692 } else {
@@ -2693,8 +2709,8 @@ function ChatViewContent(props: ChatViewProps) { · 16 unmodified lines
26932709 },
26942710 [
26952711 activeProject,
2696− activeKnownTerminalIds,
26972712 activeThreadId,
2713+ allocatableActiveTerminalIds,
26982714 activeThreadRef,
26992715 openTerminal,
27002716 activeThreadWorktreePath,
@@ -2713,7 +2729,7 @@ function ChatViewContent(props: ChatViewProps) { · 12 unmodified lines
27132729 if (!cwdForOpen) {
27142730 return;
27152731 }
2716− const terminalId = nextTerminalId(activeKnownTerminalIds);
2732+ const terminalId = nextTerminalId(allocatableActiveTerminalIds);
27172733 storeNewTerminal(activeThreadRef, terminalId);
27182734 setTerminalFocusRequestId((value) => value + 1);
27192735 void openTerminal({
@@ -2731,8 +2747,8 @@ function ChatViewContent(props: ChatViewProps) { · 11 unmodified lines
27312747 });
27322748 }, [
27332749 activeProject,
2734− activeKnownTerminalIds,
27352750 activeThreadId,
2751+ allocatableActiveTerminalIds,
27362752 activeThreadRef,
27372753 openTerminal,
27382754 activeThreadWorktreePath,
@@ -2818,7 +2834,7 @@ function ChatViewContent(props: ChatViewProps) { · 79 unmodified lines
28182834 ...(options?.env ? { extraEnv: options.env } : {}),
28192835 });
28202836 const targetTerminalId = shouldCreateNewTerminal
2821− ? nextTerminalId(activeKnownTerminalIds)
2837+ ? nextTerminalId(allocatableActiveTerminalIds)
28222838 : baseTerminalId;
28232839 const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal
28242840 ? {
@@ -2886,6 +2902,7 @@ function ChatViewContent(props: ChatViewProps) { · 61 unmodified lines
28862902 environmentId,
28872903 openTerminal,
28882904 activeKnownTerminalIds,
2905+ allocatableActiveTerminalIds,
28892906 runningTerminalIds,
28902907 terminalUiState.activeTerminalId,
28912908 writeTerminal,
@@ -3144,7 +3161,7 @@ function ChatViewContent(props: ChatViewProps) { · 252 unmodified lines
31443161 const addTerminalSurface = useCallback(() => {
31453162 if (!activeThreadRef || !activeThreadId || !activeProject) return;
31463163 const cwd = gitCwd ?? activeProject.workspaceRoot;
3147− const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]);
3164+ const terminalId = nextTerminalId(allocatableActiveTerminalIds);
31483165 useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId);
31493166 setTerminalFocusRequestId((value) => value + 1);
31503167 void openTerminal({
@@ -3161,14 +3178,13 @@ function ChatViewContent(props: ChatViewProps) { · 10 unmodified lines
31613178 },
31623179 });
31633180 }, [
3164− activeKnownTerminalIds,
31653181 activeProject,
31663182 activeThreadId,
31673183 activeThreadRef,
31683184 activeThreadWorktreePath,
3185+ allocatableActiveTerminalIds,
31693186 gitCwd,
31703187 openTerminal,
3171− panelTerminalIds,
31723188 ]);
31733189 const splitPanelTerminal = useCallback(
31743190 (direction: "horizontal" | "vertical" = "horizontal") => {
@@ -3181,7 +3197,7 @@ function ChatViewContent(props: ChatViewProps) { · 6 unmodified lines
31813197 ) {
31823198 return;
31833199 }
3184− const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]);
3200+ const terminalId = nextTerminalId(allocatableActiveTerminalIds);
31853201 const cwd = gitCwd ?? activeProject.workspaceRoot;
31863202 useRightPanelStore
31873203 .getState()
@@ -3202,15 +3218,14 @@ function ChatViewContent(props: ChatViewProps) { · 14 unmodified lines
32023218 });
32033219 },
32043220 [
3205− activeKnownTerminalIds,
32063221 activeProject,
32073222 activeRightPanelSurface,
32083223 activeThreadId,
32093224 activeThreadRef,
32103225 activeThreadWorktreePath,
3226+ allocatableActiveTerminalIds,
32113227 gitCwd,
32123228 openTerminal,
3213− panelTerminalIds,
32143229 ],
32153230 );
32163231 const splitPanelTerminalVertical = useCallback(() => {
apps/web/src/components/ThreadTerminalDrawer.test.ts+17−0
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vite-plus/test"; · 1 unmodified line
22
33 import {
44 resolveTerminalSelectionActionPosition,
5+ shouldHandleTerminalExit,
56 shouldHandleTerminalSelectionMouseUp,
67 terminalSelectionActionDelayForClickCount,
8+ terminalSelectionLineRange,
79 } from "./ThreadTerminalDrawer";
810
911 describe("resolveTerminalSelectionActionPosition", () => {
@@ -72,4 +74,19 @@ describe("resolveTerminalSelectionActionPosition", () => { · 62 unmodified lines
7274 expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false);
7375 expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false);
7476 });
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+ });
7592 });
apps/web/src/components/ThreadTerminalDrawer.tsx+406−439
@@ -1,9 +1,9 @@
11 import { useAtomValue } from "@effect/atom-react";
2−import { FitAddon } from "@xterm/addon-fit";
32 import {
43 isAtomCommandInterrupted,
54 squashAtomCommandFailure,
65 } from "@t3tools/client-runtime/state/runtime";
6+import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal";
77 import {
88 Plus,
99 SquareSplitHorizontal,
@@ -18,7 +18,6 @@ import { · 8 unmodified lines
1818 type ThreadId,
1919 } from "@t3tools/contracts";
2020 import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
21−import { Terminal, type ITheme } from "@xterm/xterm";
2221 import {
2322 type PointerEvent as ReactPointerEvent,
2423 type ReactNode,
@@ -34,15 +33,13 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; · 9 unmodified lines
3433 import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
3534 import { cn } from "~/lib/utils";
3635 import { type TerminalContextSelection } from "~/lib/terminalContext";
37−import { useOpenInPreferredEditor } from "../editorPreferences";
3836 import {
39− collectWrappedTerminalLinkLine,
40− extractTerminalLinks,
41− isTerminalLinkActivation,
42− resolvePathLinkTarget,
43− resolveWrappedTerminalLinkRange,
44− wrappedTerminalLinkRangeIntersectsBufferLine,
45−} from "../terminal-links";
37+ GhosttyTerminalSurface,
38+ type GhosttyTerminalSurfaceOptions,
39+} from "~/terminal/ghostty/surface";
40+import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core";
41+import { useOpenInPreferredEditor } from "../editorPreferences";
42+import { isTerminalLinkActivation, resolvePathLinkTarget } from "../terminal-links";
4643 import {
4744 isDiffToggleShortcut,
4845 isTerminalClearShortcut,
@@ -82,24 +79,34 @@ function clampDrawerHeight(height: number): number { · 33 unmodified lines
8279 return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight);
8380 }
8481
85−function writeSystemMessage(terminal: Terminal, message: string): void {
82+function writeSystemMessage(terminal: GhosttyTerminalSurface, message: string): void {
8683 terminal.write(`\r\n[terminal] ${message}\r\n`);
8784 }
8885
89−function writeTerminalBuffer(terminal: Terminal, buffer: string): void {
90− terminal.write("\u001bc");
91− if (buffer.length > 0) {
92− terminal.write(buffer);
93− }
86+function writeTerminalBuffer(terminal: GhosttyTerminalSurface, buffer: string): void {
87+ terminal.resetAndWrite(buffer);
9488 }
9589
96−function fitTerminalSafely(fitAddon: FitAddon): boolean {
97− try {
98− fitAddon.fit();
99− return true;
100− } catch {
101− return false;
102− }
90+function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor {
91+ if (typeof document === "undefined") return fallback;
92+
93+ const canvas = document.createElement("canvas");
94+ canvas.width = 1;
95+ canvas.height = 1;
96+ const context = canvas.getContext("2d", { willReadFrequently: true });
97+ if (!context) return fallback;
98+
99+ context.clearRect(0, 0, 1, 1);
100+ context.fillStyle = value;
101+ context.fillRect(0, 0, 1, 1);
102+ const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data;
103+ if (alpha === 0) return fallback;
104+
105+ return {
106+ r: red ?? fallback.r,
107+ g: green ?? fallback.g,
108+ b: blue ?? fallback.b,
109+ };
103110 }
104111
105112 function runtimeEnvSignature(runtimeEnv: Record<string, string> | undefined): string {
@@ -124,7 +131,7 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri · 18 unmodified lines
124131 return value ?? fallback;
125132 }
126133
127−function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme {
134+function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme {
128135 const isDark = document.documentElement.classList.contains("dark");
129136 const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)";
130137 const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)";
@@ -143,86 +150,22 @@ function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { · 12 unmodified lines
143150 normalizeComputedColor(bodyStyles.color, fallbackForeground),
144151 );
145152
146− if (isDark) {
147− return {
153+ return {
154+ background: parseTerminalColor(
148155 background,
156+ isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 },
157+ ),
158+ foreground: parseTerminalColor(
149159 foreground,
150− cursor: "rgb(180, 203, 255)",
151− selectionBackground: "rgba(180, 203, 255, 0.25)",
152− scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)",
153− scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)",
154− scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)",
155− black: "rgb(24, 30, 38)",
156− red: "rgb(255, 122, 142)",
157− green: "rgb(134, 231, 149)",
158− yellow: "rgb(244, 205, 114)",
159− blue: "rgb(137, 190, 255)",
160− magenta: "rgb(208, 176, 255)",
161− cyan: "rgb(124, 232, 237)",
162− white: "rgb(210, 218, 230)",
163− brightBlack: "rgb(110, 120, 136)",
164− brightRed: "rgb(255, 168, 180)",
165− brightGreen: "rgb(176, 245, 186)",
166− brightYellow: "rgb(255, 224, 149)",
167− brightBlue: "rgb(174, 210, 255)",
168− brightMagenta: "rgb(229, 203, 255)",
169− brightCyan: "rgb(167, 244, 247)",
170− brightWhite: "rgb(244, 247, 252)",
171− };
172− }
173−
174− return {
175− background,
176− foreground,
177− cursor: "rgb(38, 56, 78)",
178− selectionBackground: "rgba(37, 63, 99, 0.2)",
179− scrollbarSliderBackground: "rgba(0, 0, 0, 0.15)",
180− scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)",
181− scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.3)",
182− black: "rgb(44, 53, 66)",
183− red: "rgb(191, 70, 87)",
184− green: "rgb(60, 126, 86)",
185− yellow: "rgb(146, 112, 35)",
186− blue: "rgb(72, 102, 163)",
187− magenta: "rgb(132, 86, 149)",
188− cyan: "rgb(53, 127, 141)",
189− white: "rgb(210, 215, 223)",
190− brightBlack: "rgb(112, 123, 140)",
191− brightRed: "rgb(212, 95, 112)",
192− brightGreen: "rgb(85, 148, 111)",
193− brightYellow: "rgb(173, 133, 45)",
194− brightBlue: "rgb(91, 124, 194)",
195− brightMagenta: "rgb(153, 107, 172)",
196− brightCyan: "rgb(70, 149, 164)",
197− brightWhite: "rgb(236, 240, 246)",
160+ isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 },
161+ ),
162+ cursor: isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 },
163+ // Matches the xterm selection overlays this renderer replaced; the text
164+ // color underneath is left unchanged for contrast in both themes.
165+ selectionBackground: isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)",
198166 };
199167 }
200168
201−function getTerminalSelectionRect(mountElement: HTMLElement): DOMRect | null {
202− const selection = window.getSelection();
203− if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
204− return null;
205− }
206−
207− const range = selection.getRangeAt(0);
208− const commonAncestor = range.commonAncestorContainer;
209− const selectionRoot =
210− commonAncestor instanceof Element ? commonAncestor : commonAncestor.parentElement;
211− if (!(selectionRoot instanceof Element) || !mountElement.contains(selectionRoot)) {
212− return null;
213− }
214−
215− const rects = Array.from(range.getClientRects()).filter(
216− (rect) => rect.width > 0 || rect.height > 0,
217− );
218− if (rects.length > 0) {
219− return rects[rects.length - 1] ?? null;
220− }
221−
222− const boundingRect = range.getBoundingClientRect();
223− return boundingRect.width > 0 || boundingRect.height > 0 ? boundingRect : null;
224−}
225−
226169 export function resolveTerminalSelectionActionPosition(options: {
227170 bounds: { left: number; top: number; width: number; height: number };
228171 selectionRect: { right: number; bottom: number } | null;
@@ -269,6 +212,27 @@ export function shouldHandleTerminalSelectionMouseUp( · 40 unmodified lines
269212 return selectionGestureActive && button === 0;
270213 }
271214
215+export function terminalSelectionLineRange(position: {
216+ start: { y: number };
217+ end: { y: number };
218+}): { lineStart: number; lineEnd: number } {
219+ const lineStart = position.start.y + 1;
220+ return {
221+ lineStart,
222+ lineEnd: Math.max(lineStart, position.end.y + 1),
223+ };
224+}
225+
226+export function shouldHandleTerminalExit(
227+ current: TerminalSessionState["status"],
228+ synchronized: TerminalSessionState["status"],
229+ alreadyHandled: boolean,
230+): boolean {
231+ return (
232+ (current === "closed" || current === "exited") && current !== synchronized && !alreadyHandled
233+ );
234+}
235+
272236 interface TerminalViewportProps {
273237 threadRef: ScopedThreadRef;
274238 threadId: ThreadId;
@@ -309,8 +273,7 @@ export function TerminalViewport({ · 34 unmodified lines
309273 keybindings,
310274 }: TerminalViewportProps) {
311275 const containerRef = useRef<HTMLDivElement>(null);
312− const terminalRef = useRef<Terminal | null>(null);
313− const fitAddonRef = useRef<FitAddon | null>(null);
276+ const terminalRef = useRef<GhosttyTerminalSurface | null>(null);
314277 const environmentId = threadRef.environmentId;
315278 const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId));
316279 const openInPreferredEditor = useOpenInPreferredEditor(
@@ -367,6 +330,24 @@ export function TerminalViewport({ · 50 unmodified lines
367330 const terminalBuffer = terminalSession.buffer;
368331 const terminalError = terminalSession.error;
369332 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+ );
370351 const terminalVersion = terminalSession.version;
371352 const previousSessionRef = useRef({
372353 buffer: terminalBuffer,
@@ -374,6 +355,13 @@ export function TerminalViewport({ · 1 unmodified line
374355 status: terminalStatus,
375356 version: terminalVersion,
376357 });
358+ const latestSessionRef = useRef(previousSessionRef.current);
359+ latestSessionRef.current = {
360+ buffer: terminalBuffer,
361+ error: terminalError,
362+ status: terminalStatus,
363+ version: terminalVersion,
364+ };
377365
378366 useEffect(() => {
379367 keybindingsRef.current = keybindings;
@@ -384,356 +372,353 @@ export function TerminalViewport({ · 4 unmodified lines
384372 if (!mount) return;
385373
386374 const localApi = readLocalApi();
387−
388− const fitAddon = new FitAddon();
389− const terminal = new Terminal({
390− cursorBlink: true,
391− lineHeight: 1,
392− fontSize: 12,
393− scrollback: 5_000,
394− fontFamily:
395− '"SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace',
396− theme: terminalThemeFromApp(mount),
397− });
398− terminal.loadAddon(fitAddon);
399− terminal.open(mount);
400− fitTerminalSafely(fitAddon);
401−
402− terminalRef.current = terminal;
403− fitAddonRef.current = fitAddon;
404− previousSessionRef.current = {
405− buffer: "",
406− status: "closed",
407− error: null,
408− version: 0,
409− };
410−
411− const clearSelectionAction = () => {
412− selectionActionRequestIdRef.current += 1;
413− if (selectionActionTimerRef.current !== null) {
414− window.clearTimeout(selectionActionTimerRef.current);
415− selectionActionTimerRef.current = null;
416− }
417− };
418−
419− const readSelectionAction = (): {
420− position: { x: number; y: number };
421− clipboardText: string;
422− selection: TerminalContextSelection;
423− } | null => {
424− const activeTerminal = terminalRef.current;
425− const mountElement = containerRef.current;
426− if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) {
427− return null;
428− }
429− const selectionText = activeTerminal.getSelection();
430− const selectionPosition = activeTerminal.getSelectionPosition();
431− const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, "");
432− if (!selectionPosition || normalizedText.length === 0) {
375+ let cancelled = false;
376+ let teardown: (() => void) | null = null;
377+ let setupTerminal: GhosttyTerminalSurface | null = null;
378+ let setupCleanups: Array<() => void> = [];
379+
380+ const setup = async (): Promise<(() => void) | null> => {
381+ const terminalOptions: GhosttyTerminalSurfaceOptions = {
382+ theme: terminalThemeFromApp(mount),
383+ onData: (data) => handleData(data),
384+ onResize: (cols, rows) => void resizeTerminal(cols, rows),
385+ onSelectionChange: () => handleSelectionChange(),
386+ onCopy: (text) => handleCopy(text),
387+ beforeKey: (event) => handleBeforeKey(event),
388+ onLinkActivate: (text, event) => handleLinkActivate(text, event),
389+ };
390+ const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions);
391+ if (cancelled) {
392+ terminal.dispose();
433393 return null;
434394 }
435− const lineStart = selectionPosition.start.y + 1;
436− const lineCount = normalizedText.split("\n").length;
437− const lineEnd = Math.max(lineStart, lineStart + lineCount - 1);
438− const bounds = mountElement.getBoundingClientRect();
439− const selectionRect = getTerminalSelectionRect(mountElement);
440− const position = resolveTerminalSelectionActionPosition({
441− bounds,
442− selectionRect:
443− selectionRect === null
444− ? null
445− : { right: selectionRect.right, bottom: selectionRect.bottom },
446− pointer: selectionPointerRef.current,
447− });
448− return {
449− position,
450− clipboardText: selectionText,
451− selection: {
452− terminalId,
453− terminalLabel: readTerminalLabel(),
454− lineStart,
455− lineEnd,
456− text: normalizedText,
457− },
395+ // The theme observer is not installed yet, so re-read the theme in case
396+ // the app toggled light/dark while the WASM surface was loading.
397+ terminal.setTheme(terminalThemeFromApp(mount));
398+ setupTerminal = terminal;
399+ terminalRef.current = terminal;
400+ const latestSession = latestSessionRef.current;
401+ previousSessionRef.current = latestSession;
402+ if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer);
403+ if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error);
404+ // Attaching to a session that already exited must still run exit handling
405+ // once, so mount synchronization starts from the empty "closed" state.
406+ // (A session that is "closed" at mount is indistinguishable from one that
407+ // never started, so only "exited" triggers the message — as with xterm.)
408+ synchronizedStatusRef.current = "closed";
409+ synchronizeTerminalStatus(terminal, latestSession.status);
410+ if (autoFocus) window.requestAnimationFrame(() => terminal.focus());
411+
412+ const clearSelectionAction = () => {
413+ selectionActionRequestIdRef.current += 1;
414+ if (selectionActionTimerRef.current !== null) {
415+ window.clearTimeout(selectionActionTimerRef.current);
416+ selectionActionTimerRef.current = null;
417+ }
458418 };
459− };
419+ setupCleanups.push(clearSelectionAction);
460420
461− const showSelectionAction = async () => {
462− if (!localApi) {
463− clearSelectionAction();
464− return;
465− }
466− if (selectionActionMenuOpenRef.current) {
467− return;
468− }
469− const nextAction = readSelectionAction();
470− if (!nextAction) {
471− clearSelectionAction();
472− return;
473− }
474− const requestId = ++selectionActionRequestIdRef.current;
475− selectionActionMenuOpenRef.current = true;
476− const clicked = await localApi.contextMenu
477− .show(
478− [
479− { id: "add-to-chat", label: "Add to chat" },
480− { id: "copy", label: "Copy" },
481− ],
482− nextAction.position,
483− )
484− .finally(() => {
485− selectionActionMenuOpenRef.current = false;
421+ const readSelectionAction = (): {
422+ position: { x: number; y: number };
423+ clipboardText: string;
424+ selection: TerminalContextSelection;
425+ } | null => {
426+ const activeTerminal = terminalRef.current;
427+ const mountElement = containerRef.current;
428+ if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) {
429+ return null;
430+ }
431+ const selectionText = activeTerminal.getSelection();
432+ const selectionPosition = activeTerminal.getSelectionPosition();
433+ const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, "");
434+ if (!selectionPosition || normalizedText.length === 0) {
435+ return null;
436+ }
437+ const { lineStart, lineEnd } = terminalSelectionLineRange(selectionPosition);
438+ const bounds = mountElement.getBoundingClientRect();
439+ const position = resolveTerminalSelectionActionPosition({
440+ bounds,
441+ selectionRect: activeTerminal.getSelectionEndClientRect(),
442+ pointer: selectionPointerRef.current,
486443 });
487− if (requestId !== selectionActionRequestIdRef.current || clicked === null) {
488− return;
489− }
490− switch (clicked) {
491− case "add-to-chat":
492− handleAddTerminalContext(nextAction.selection);
493− terminalRef.current?.clearSelection();
494− terminalRef.current?.focus();
444+ return {
445+ position,
446+ clipboardText: selectionText,
447+ selection: {
448+ terminalId,
449+ terminalLabel: readTerminalLabel(),
450+ lineStart,
451+ lineEnd,
452+ text: normalizedText,
453+ },
454+ };
455+ };
456+
457+ const showSelectionAction = async () => {
458+ if (!localApi) {
459+ clearSelectionAction();
495460 return;
496− case "copy":
497− try {
498− await writeTextToClipboard(nextAction.clipboardText, "terminal selection");
499− } catch (error) {
500− if (requestId !== selectionActionRequestIdRef.current) {
501− return;
461+ }
462+ if (selectionActionMenuOpenRef.current) {
463+ return;
464+ }
465+ const nextAction = readSelectionAction();
466+ if (!nextAction) {
467+ clearSelectionAction();
468+ return;
469+ }
470+ const requestId = ++selectionActionRequestIdRef.current;
471+ selectionActionMenuOpenRef.current = true;
472+ const clicked = await localApi.contextMenu
473+ .show(
474+ [
475+ { id: "add-to-chat", label: "Add to chat" },
476+ { id: "copy", label: "Copy" },
477+ ],
478+ nextAction.position,
479+ )
480+ .finally(() => {
481+ selectionActionMenuOpenRef.current = false;
482+ });
483+ if (requestId !== selectionActionRequestIdRef.current || clicked === null) {
484+ return;
485+ }
486+ switch (clicked) {
487+ case "add-to-chat":
488+ handleAddTerminalContext(nextAction.selection);
489+ terminalRef.current?.clearSelection();
490+ terminalRef.current?.focus();
491+ return;
492+ case "copy":
493+ try {
494+ await writeTextToClipboard(nextAction.clipboardText, "terminal selection");
495+ } catch (error) {
496+ if (requestId !== selectionActionRequestIdRef.current) {
497+ return;
498+ }
499+ const activeTerminal = terminalRef.current;
500+ if (activeTerminal) {
501+ writeSystemMessage(
502+ activeTerminal,
503+ error instanceof Error ? error.message : "Unable to copy terminal selection",
504+ );
505+ }
502506 }
503− const activeTerminal = terminalRef.current;
504− if (activeTerminal) {
505− writeSystemMessage(
506− activeTerminal,
507− error instanceof Error ? error.message : "Unable to copy terminal selection",
508− );
507+ if (requestId === selectionActionRequestIdRef.current) {
508+ terminalRef.current?.focus();
509509 }
510− }
511− if (requestId === selectionActionRequestIdRef.current) {
512− terminalRef.current?.focus();
513− }
514− return;
515− }
516− };
510+ return;
511+ }
512+ };
517513
518− const sendTerminalInput = async (data: string, fallbackError: string) => {
519− const activeTerminal = terminalRef.current;
520− if (!activeTerminal) return;
521− const result = await writeTerminal(data);
522− if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
523− const error = squashAtomCommandFailure(result);
524− writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError);
525− }
526− };
514+ const sendTerminalInput = async (data: string, fallbackError: string) => {
515+ const activeTerminal = terminalRef.current;
516+ if (!activeTerminal) return;
517+ const result = await writeTerminal(data);
518+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
519+ const error = squashAtomCommandFailure(result);
520+ writeSystemMessage(
521+ activeTerminal,
522+ error instanceof Error ? error.message : fallbackError,
523+ );
524+ }
525+ };
527526
528− terminal.attachCustomKeyEventHandler((event) => {
529− const currentKeybindings = keybindingsRef.current;
530− const options = { context: { terminalFocus: true, terminalOpen: true } };
531− if (
532− isTerminalToggleShortcut(event, currentKeybindings, options) ||
533− isTerminalSplitShortcut(event, currentKeybindings, options) ||
534− isTerminalSplitVerticalShortcut(event, currentKeybindings, options) ||
535− isTerminalNewShortcut(event, currentKeybindings, options) ||
536− isTerminalCloseShortcut(event, currentKeybindings, options) ||
537− isDiffToggleShortcut(event, currentKeybindings, options)
538− ) {
539− return false;
540− }
527+ function handleBeforeKey(event: KeyboardEvent): boolean {
528+ const currentKeybindings = keybindingsRef.current;
529+ const options = { context: { terminalFocus: true, terminalOpen: true } };
530+ if (
531+ isTerminalToggleShortcut(event, currentKeybindings, options) ||
532+ isTerminalSplitShortcut(event, currentKeybindings, options) ||
533+ isTerminalSplitVerticalShortcut(event, currentKeybindings, options) ||
534+ isTerminalNewShortcut(event, currentKeybindings, options) ||
535+ isTerminalCloseShortcut(event, currentKeybindings, options) ||
536+ isDiffToggleShortcut(event, currentKeybindings, options)
537+ ) {
538+ return false;
539+ }
541540
542− const navigationData = terminalNavigationShortcutData(event);
543− if (navigationData !== null) {
544− event.preventDefault();
545− event.stopPropagation();
546− void sendTerminalInput(navigationData, "Failed to move cursor");
547− return false;
548− }
541+ const navigationData = terminalNavigationShortcutData(event);
542+ if (navigationData !== null) {
543+ event.preventDefault();
544+ event.stopPropagation();
545+ void sendTerminalInput(navigationData, "Failed to move cursor");
546+ return false;
547+ }
548+
549+ const deleteData = terminalDeleteShortcutData(event);
550+ if (deleteData !== null) {
551+ event.preventDefault();
552+ event.stopPropagation();
553+ void sendTerminalInput(deleteData, "Failed to delete terminal input");
554+ return false;
555+ }
549556
550− const deleteData = terminalDeleteShortcutData(event);
551− if (deleteData !== null) {
557+ if (!isTerminalClearShortcut(event)) return true;
552558 event.preventDefault();
553559 event.stopPropagation();
554− void sendTerminalInput(deleteData, "Failed to delete terminal input");
560+ void sendTerminalInput("\u000c", "Failed to clear terminal");
555561 return false;
556562 }
557563
558− if (!isTerminalClearShortcut(event)) return true;
559− event.preventDefault();
560− event.stopPropagation();
561− void sendTerminalInput("\u000c", "Failed to clear terminal");
562− return false;
563− });
564−
565− const terminalLinksDisposable = terminal.registerLinkProvider({
566− provideLinks: (bufferLineNumber, callback) => {
567− const activeTerminal = terminalRef.current;
568− if (!activeTerminal) {
569− callback(undefined);
564+ function handleLinkActivate(text: string, event: MouseEvent): void {
565+ if (!isTerminalLinkActivation(event)) return;
566+ const latestTerminal = terminalRef.current;
567+ if (!latestTerminal) return;
568+ if (/^https?:\/\//u.test(text)) {
569+ if (!localApi) {
570+ writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser.");
571+ return;
572+ }
573+ const fallbackToBrowser = () => {
574+ void localApi.shell.openExternal(text).catch((error: unknown) => {
575+ writeSystemMessage(
576+ latestTerminal,
577+ error instanceof Error ? error.message : "Unable to open link",
578+ );
579+ });
580+ };
581+ void openTerminalLinkInPreview({
582+ url: text,
583+ position: { x: event.clientX, y: event.clientY },
584+ threadRef,
585+ openPreview,
586+ localApi,
587+ fallbackToBrowser,
588+ });
570589 return;
571590 }
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) =>
574− activeTerminal.buffer.active.getLine(bufferLineIndex),
575− );
576− if (!wrappedLine) {
577− callback(undefined);
578− return;
579− }
605+ function handleCopy(text: string): void {
606+ void writeTextToClipboard(text, "terminal selection").catch((error: unknown) => {
607+ const activeTerminal = terminalRef.current;
608+ if (!activeTerminal) return;
609+ writeSystemMessage(
610+ activeTerminal,
611+ error instanceof Error ? error.message : "Unable to copy terminal selection",
612+ );
613+ });
614+ }
580615
581− const links = extractTerminalLinks(wrappedLine.text)
582− .map((match) => ({
583− match,
584− range: resolveWrappedTerminalLinkRange(wrappedLine, match),
585− }))
586− .filter(({ range }) =>
587− wrappedTerminalLinkRangeIntersectsBufferLine(range, bufferLineNumber),
616+ function handleData(data: string): void {
617+ void (async () => {
618+ const result = await writeTerminal(data);
619+ if (result._tag === "Success" || isAtomCommandInterrupted(result)) return;
620+ const error = squashAtomCommandFailure(result);
621+ writeSystemMessage(
622+ terminal,
623+ error instanceof Error ? error.message : "Terminal write failed",
588624 );
589− if (links.length === 0) {
590− callback(undefined);
625+ })();
626+ }
627+
628+ function handleSelectionChange(): void {
629+ if (terminalRef.current?.hasSelection()) {
591630 return;
592631 }
632+ clearSelectionAction();
633+ }
593634
594− callback(
595− links.map(({ match, range }) => ({
596− text: match.text,
597− range,
598− activate: (event: MouseEvent) => {
599− if (!isTerminalLinkActivation(event)) return;
600−
601− const latestTerminal = terminalRef.current;
602− if (!latestTerminal) return;
603−
604− if (match.kind === "url") {
605− if (!localApi) {
606− writeSystemMessage(
607− latestTerminal,
608− "Opening links is unavailable in this browser.",
609− );
610− return;
611− }
612− const fallbackToBrowser = () => {
613− void localApi.shell.openExternal(match.text).catch((error: unknown) => {
614− writeSystemMessage(
615− latestTerminal,
616− error instanceof Error ? error.message : "Unable to open link",
617− );
618− });
619− };
620− void openTerminalLinkInPreview({
621− url: match.text,
622− position: { x: event.clientX, y: event.clientY },
623− threadRef,
624− openPreview,
625− localApi,
626− fallbackToBrowser,
627− });
628− return;
629− }
630−
631− const target = resolvePathLinkTarget(match.text, cwd);
632− void (async () => {
633− const result = await openTerminalPath(target);
634− if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
635− return;
636− }
637− const error = squashAtomCommandFailure(result);
638− writeSystemMessage(
639− latestTerminal,
640− error instanceof Error ? error.message : "Unable to open path",
641− );
642− })();
643− },
644− })),
635+ const handleMouseUp = (event: MouseEvent) => {
636+ const shouldHandle = shouldHandleTerminalSelectionMouseUp(
637+ selectionGestureActiveRef.current,
638+ event.button,
645639 );
646− },
647− });
648−
649− const inputDisposable = terminal.onData((data) => {
650− void (async () => {
651− const result = await writeTerminal(data);
652− if (result._tag === "Success" || isAtomCommandInterrupted(result)) {
640+ selectionGestureActiveRef.current = false;
641+ if (!shouldHandle) {
653642 return;
654643 }
655− const error = squashAtomCommandFailure(result);
656− writeSystemMessage(
657− terminal,
658− error instanceof Error ? error.message : "Terminal write failed",
659− );
660− })();
661− });
644+ selectionPointerRef.current = { x: event.clientX, y: event.clientY };
645+ const delay = terminalSelectionActionDelayForClickCount(event.detail);
646+ selectionActionTimerRef.current = window.setTimeout(() => {
647+ selectionActionTimerRef.current = null;
648+ window.requestAnimationFrame(() => {
649+ void showSelectionAction();
650+ });
651+ }, delay);
652+ };
653+ const handlePointerDown = (event: PointerEvent) => {
654+ clearSelectionAction();
655+ selectionGestureActiveRef.current = event.button === 0;
656+ };
657+ window.addEventListener("mouseup", handleMouseUp);
658+ mount.addEventListener("pointerdown", handlePointerDown);
659+ setupCleanups.push(() => {
660+ window.removeEventListener("mouseup", handleMouseUp);
661+ mount.removeEventListener("pointerdown", handlePointerDown);
662+ });
662663
663− const selectionDisposable = terminal.onSelectionChange(() => {
664− if (terminalRef.current?.hasSelection()) {
665− return;
666− }
667− clearSelectionAction();
668− });
664+ const themeObserver = new MutationObserver(() => {
665+ const activeTerminal = terminalRef.current;
666+ if (!activeTerminal) return;
667+ activeTerminal.setTheme(terminalThemeFromApp(containerRef.current));
668+ });
669+ themeObserver.observe(document.documentElement, {
670+ attributes: true,
671+ attributeFilter: ["class", "style"],
672+ });
673+ setupCleanups.push(() => themeObserver.disconnect());
669674
670− const handleMouseUp = (event: MouseEvent) => {
671− const shouldHandle = shouldHandleTerminalSelectionMouseUp(
672− selectionGestureActiveRef.current,
673− event.button,
674− );
675− selectionGestureActiveRef.current = false;
676− if (!shouldHandle) {
677− return;
678− }
679− selectionPointerRef.current = { x: event.clientX, y: event.clientY };
680− const delay = terminalSelectionActionDelayForClickCount(event.detail);
681− selectionActionTimerRef.current = window.setTimeout(() => {
682− selectionActionTimerRef.current = null;
683− window.requestAnimationFrame(() => {
684− void showSelectionAction();
685− });
686− }, delay);
687− };
688− const handlePointerDown = (event: PointerEvent) => {
689− clearSelectionAction();
690− selectionGestureActiveRef.current = event.button === 0;
675+ const fitTimer = window.setTimeout(() => {
676+ const activeTerminal = terminalRef.current;
677+ if (!activeTerminal) return;
678+ const wasAtBottom = activeTerminal.isAtBottom();
679+ activeTerminal.fit();
680+ if (wasAtBottom) {
681+ activeTerminal.scrollToBottom();
682+ }
683+ }, 30);
684+ setupCleanups.push(() => window.clearTimeout(fitTimer));
685+
686+ const cleanups = setupCleanups;
687+ setupCleanups = [];
688+ setupTerminal = null;
689+ return () => {
690+ for (const cleanup of cleanups.toReversed()) cleanup();
691+ if (terminalRef.current === terminal) terminalRef.current = null;
692+ terminal.dispose();
693+ };
691694 };
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(() => {
707− const activeTerminal = terminalRef.current;
708− const activeFitAddon = fitAddonRef.current;
709− if (!activeTerminal || !activeFitAddon) return;
710− const wasAtBottom =
711− activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY;
712− fitTerminalSafely(activeFitAddon);
713− if (wasAtBottom) {
714− activeTerminal.scrollToBottom();
715− }
716− void resizeTerminal(activeTerminal.cols, activeTerminal.rows);
717− }, 30);
696+ void setup()
697+ .then((nextTeardown) => {
698+ if (cancelled) {
699+ nextTeardown?.();
700+ return;
701+ }
702+ teardown = nextTeardown;
703+ })
704+ .catch((error: unknown) => {
705+ for (const cleanup of setupCleanups.toReversed()) cleanup();
706+ setupCleanups = [];
707+ if (terminalRef.current === setupTerminal) terminalRef.current = null;
708+ setupTerminal?.dispose();
709+ setupTerminal = null;
710+ if (cancelled) return;
711+ const message =
712+ error instanceof Error ? error.message : "Unable to initialize libghostty-vt";
713+ mount.textContent = `${message} — close and reopen the terminal to retry.`;
714+ });
718715
719716 return () => {
720− window.clearTimeout(fitTimer);
721− inputDisposable.dispose();
722− selectionDisposable.dispose();
723− terminalLinksDisposable.dispose();
724− if (selectionActionTimerRef.current !== null) {
725− window.clearTimeout(selectionActionTimerRef.current);
726− }
727− window.removeEventListener("mouseup", handleMouseUp);
728− mount.removeEventListener("pointerdown", handlePointerDown);
729− themeObserver.disconnect();
730− terminalRef.current = null;
731− fitAddonRef.current = null;
732− terminal.dispose();
717+ cancelled = true;
718+ teardown?.();
733719 };
734720 // autoFocus is intentionally omitted;
735721 // it is only read at mount time and must not trigger terminal teardown/recreation.
736− // eslint-disable-next-line react-hooks/exhaustive-deps
737722 }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]);
738723
739724 useEffect(() => {
@@ -750,6 +735,7 @@ export function TerminalViewport({ · 10 unmodified lines
750735 }
751736
752737 const previous = previousSessionRef.current;
738+ synchronizeTerminalStatus(terminal, current.status);
753739 if (current.version === previous.version) {
754740 return;
755741 }
@@ -768,25 +754,6 @@ export function TerminalViewport({ · 12 unmodified lines
768754 writeSystemMessage(terminal, current.error);
769755 }
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−
790757 if (previous.version === 0 && autoFocus) {
791758 window.requestAnimationFrame(() => {
792759 terminal.focus();
@@ -809,15 +776,15 @@ export function TerminalViewport({ · 16 unmodified lines
809776
810777 useEffect(() => {
811778 const terminal = terminalRef.current;
812− const fitAddon = fitAddonRef.current;
813− if (!terminal || !fitAddon) return;
814− const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY;
779+ if (!terminal) return;
780+ const wasAtBottom = terminal.isAtBottom();
781+ // The surface reports grid changes through onResize, which is the single
782+ // channel for PTY resize RPCs; fitting here only refreshes the layout.
815783 const frame = window.requestAnimationFrame(() => {
816− fitTerminalSafely(fitAddon);
784+ terminal.fit();
817785 if (wasAtBottom) {
818786 terminal.scrollToBottom();
819787 }
820− void resizeTerminal(terminal.cols, terminal.rows);
821788 });
822789 return () => {
823790 window.cancelAnimationFrame(frame);
@@ -1276,7 +1243,7 @@ export default function ThreadTerminalDrawer({ · 452 unmodified lines
12761243
12771244 {!hasTerminalSidebar && (
12781245 <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">
12801247 <TerminalActionButton
12811248 className={`p-1 text-foreground/90 transition-colors ${
12821249 hasReachedSplitLimit
apps/web/src/index.css+30−14
@@ -1049,6 +1049,36 @@ code { · 1048 unmodified lines
10491049 background: var(--app-scrollbar-thumb-hover);
10501050 }
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+
10521082 .model-picker-list::-webkit-scrollbar-track {
10531083 margin-block: 0.5rem;
10541084 }
@@ -1101,20 +1131,6 @@ code { · 46 unmodified lines
11011131 display: none;
11021132 }
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−
11181134 /* Reasoning select -- clickable label surface */
11191135 label:has(> select#reasoning-effort) {
11201136 position: relative;
apps/web/src/main.tsx+0−1
@@ -8,7 +8,6 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" · 7 unmodified lines
88 import "@fontsource-variable/dm-sans/index.css";
99 import "@fontsource/jetbrains-mono/400.css";
1010 import "@fontsource/jetbrains-mono/500.css";
11−import "@xterm/xterm/css/xterm.css";
1211 import "./index.css";
1312
1413 import { isElectron } from "./env";
apps/web/src/terminal/ghostty/README.md+19−0
@@ -0,0 +1,19 @@
1+# Ghostty web terminal
2+
3+This directory is the browser adapter for the same official `libghostty-vt` C ABI used by Android.
4+It is intentionally not an xterm compatibility layer.
5+
6+- `runtime.ts` owns the singleton WebAssembly instance and runtime ABI layouts.
7+- `ghostty-write-pty.wasm` is a 112-byte callback trampoline for terminal-generated PTY replies.
8+- `core.ts` owns per-terminal Ghostty handles and translates the C ABI into render snapshots.
9+- `renderer.ts` batches backgrounds and style runs into a Canvas 2D frame.
10+- `surface.ts` owns browser input, IME, selection, scrolling, sizing, links, and cursor blinking.
11+- `fonts/` vendors the symbols-only Nerd Font (MIT) the surface registers lazily, so
12+ prompt glyphs render without a locally installed Nerd Font.
13+- `vendor/` holds only the artifacts, reproducibly generated by
14+ `apps/web/scripts/build-libghostty-wasm.sh`. The upstream pin and license live once, at
15+ `apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/`; the wasm embeds the pinned revision
16+ in its build info and the ABI test verifies it against mobile's `VERSION`.
17+
18+Keep browser behavior here and terminal transport in the existing client runtime. Do not add React
19+state to the render loop. Both WASM artifacts are ordinary read-only assets, not executables.
apps/web/src/terminal/ghostty/core.ts+1189−0
@@ -0,0 +1,1189 @@
1+import {
2+ type GhosttyKeyboardLayoutMap,
3+ ghosttyKeyForCode,
4+ ghosttyUnshiftedCodepoint,
5+ loadGhosttyKeyboardLayoutMap,
6+} from "./keyCodes";
7+import { GhosttyRuntime, loadGhosttyRuntime } from "./runtime";
8+
9+const GHOSTTY_SUCCESS = 0;
10+const GHOSTTY_OUT_OF_SPACE = -3;
11+const MAX_SCROLLBACK_ROWS = 10_000;
12+// wasm32 C ABI layout for GhosttyTerminalSelectionFormatOptions at the
13+// libghostty-vt revision pinned alongside this module.
14+const SELECTION_FORMAT_OPTIONS_SIZE = 16;
15+
16+const RENDER_DATA = {
17+ cols: 1,
18+ rows: 2,
19+ dirty: 3,
20+ rowIterator: 4,
21+ background: 5,
22+ foreground: 6,
23+ cursor: 7,
24+ cursorHasValue: 8,
25+ cursorStyle: 10,
26+ cursorVisible: 11,
27+ cursorBlinking: 12,
28+ cursorInViewport: 14,
29+ cursorX: 15,
30+ cursorY: 16,
31+} as const;
32+
33+const ROW_DATA = {
34+ dirty: 1,
35+ raw: 2,
36+ cells: 3,
37+} as const;
38+
39+const CELL_DATA = {
40+ raw: 1,
41+ style: 2,
42+ graphemesLength: 3,
43+ graphemes: 4,
44+ background: 5,
45+ foreground: 6,
46+ selected: 7,
47+} as const;
48+
49+const RAW_CELL_DATA = {
50+ wide: 3,
51+} as const;
52+
53+export const GHOSTTY_CELL_WIDE = {
54+ narrow: 0,
55+ wide: 1,
56+ spacerTail: 2,
57+ spacerHead: 3,
58+} as const;
59+
60+export interface GhosttyColor {
61+ readonly r: number;
62+ readonly g: number;
63+ readonly b: number;
64+}
65+
66+export interface GhosttyTheme {
67+ readonly foreground: GhosttyColor;
68+ readonly background: GhosttyColor;
69+ readonly cursor: GhosttyColor;
70+ /** CSS color the renderer overlays on selected cells; not sent to Ghostty. */
71+ readonly selectionBackground?: string;
72+}
73+
74+export interface GhosttyCell {
75+ readonly text: string;
76+ readonly wide: number;
77+ readonly foreground: GhosttyColor;
78+ readonly background: GhosttyColor;
79+ readonly bold: boolean;
80+ readonly italic: boolean;
81+ readonly invisible: boolean;
82+ readonly strikethrough: boolean;
83+ readonly overline: boolean;
84+ readonly underline: boolean;
85+ readonly selected: boolean;
86+}
87+
88+export interface GhosttyRow {
89+ readonly cells: readonly GhosttyCell[];
90+ readonly text: string;
91+ readonly isWrapContinuation: boolean;
92+ /** Whether this row soft-wraps onto the next row. */
93+ readonly wrapsToNext: boolean;
94+}
95+
96+export interface GhosttySnapshot {
97+ readonly cols: number;
98+ readonly rows: number;
99+ readonly foreground: GhosttyColor;
100+ readonly background: GhosttyColor;
101+ readonly cursor: GhosttyColor;
102+ readonly cursorX: number;
103+ readonly cursorY: number;
104+ readonly cursorVisible: boolean;
105+ readonly cursorBlinking: boolean;
106+ readonly cursorStyle: number;
107+ readonly dirtyRows: ReadonlySet<number>;
108+ readonly rowData: readonly GhosttyRow[];
109+}
110+
111+export interface GhosttySelectionRange {
112+ readonly viewport: {
113+ readonly start: { readonly x: number; readonly y: number };
114+ readonly end: { readonly x: number; readonly y: number };
115+ };
116+ readonly screen: {
117+ readonly start: { readonly x: number; readonly y: number };
118+ readonly end: { readonly x: number; readonly y: number };
119+ };
120+}
121+
122+export interface GhosttyScrollbar {
123+ readonly total: number;
124+ readonly offset: number;
125+ readonly len: number;
126+}
127+
128+/** Grid position tagged with its Ghostty coordinate space: 1 viewport, 2 screen. */
129+export interface GhosttyPointInput {
130+ readonly x: number;
131+ readonly y: number;
132+ readonly tag?: 1 | 2;
133+}
134+
135+export interface GhosttyMouseInput {
136+ readonly action: "press" | "release" | "motion";
137+ readonly button: number | null;
138+ readonly mods: number;
139+ readonly x: number;
140+ readonly y: number;
141+ readonly screenWidth: number;
142+ readonly screenHeight: number;
143+ readonly cellWidth: number;
144+ readonly cellHeight: number;
145+ readonly paddingLeft: number;
146+ readonly paddingRight: number;
147+ readonly paddingTop: number;
148+ readonly paddingBottom: number;
149+ readonly anyButtonPressed: boolean;
150+}
151+
152+const decoder = new TextDecoder();
153+const encoder = new TextEncoder();
154+
155+function blend(foreground: GhosttyColor, background: GhosttyColor): GhosttyColor {
156+ const channel = (front: number, back: number) => Math.floor((front * 155 + back * 100) / 255);
157+ return {
158+ r: channel(foreground.r, background.r),
159+ g: channel(foreground.g, background.g),
160+ b: channel(foreground.b, background.b),
161+ };
162+}
163+
164+function sameColor(left: GhosttyColor, right: GhosttyColor): boolean {
165+ return left.r === right.r && left.g === right.g && left.b === right.b;
166+}
167+
168+export class GhosttyTerminalCore {
169+ private readonly runtime: GhosttyRuntime;
170+ private terminalSlot = 0;
171+ private terminal = 0;
172+ private renderStateSlot = 0;
173+ private renderState = 0;
174+ private rowIteratorSlot = 0;
175+ private rowCellsSlot = 0;
176+ private keyEncoderSlot = 0;
177+ private keyEncoder = 0;
178+ private keyEventSlot = 0;
179+ private keyEvent = 0;
180+ private mouseEncoderSlot = 0;
181+ private mouseEncoder = 0;
182+ private mouseEventSlot = 0;
183+ private mouseEvent = 0;
184+ private ptyWriterId = 0;
185+ private ptyWriter: ((data: string) => void) | null = null;
186+ private scratch = 0;
187+ private style = 0;
188+ private scrollbar = 0;
189+ private rows: GhosttyRow[] = [];
190+ private disposed = false;
191+ private keyboardLayoutMap: GhosttyKeyboardLayoutMap | undefined;
192+
193+ private constructor(runtime: GhosttyRuntime) {
194+ this.runtime = runtime;
195+ void loadGhosttyKeyboardLayoutMap().then((layoutMap) => {
196+ if (!this.disposed) this.keyboardLayoutMap = layoutMap;
197+ });
198+ }
199+
200+ static async create(
201+ cols: number,
202+ rows: number,
203+ cellWidth: number,
204+ cellHeight: number,
205+ theme: GhosttyTheme,
206+ onPtyData: (data: string) => void,
207+ ): Promise<GhosttyTerminalCore> {
208+ const core = new GhosttyTerminalCore(await loadGhosttyRuntime());
209+ try {
210+ core.initialize(cols, rows, cellWidth, cellHeight, theme, onPtyData);
211+ return core;
212+ } catch (error) {
213+ core.dispose();
214+ throw error;
215+ }
216+ }
217+
218+ private initialize(
219+ cols: number,
220+ rows: number,
221+ cellWidth: number,
222+ cellHeight: number,
223+ theme: GhosttyTheme,
224+ onPtyData: (data: string) => void,
225+ ): void {
226+ const optionsSize = this.runtime.layout("GhosttyTerminalOptions").size;
227+ const options = this.runtime.alloc(optionsSize);
228+ this.runtime.setField(options, "GhosttyTerminalOptions", "cols", cols);
229+ this.runtime.setField(options, "GhosttyTerminalOptions", "rows", rows);
230+ this.runtime.setField(options, "GhosttyTerminalOptions", "max_scrollback", MAX_SCROLLBACK_ROWS);
231+ this.terminalSlot = this.runtime.allocOpaque();
232+ const terminalResult = this.runtime.call("ghostty_terminal_new", 0, this.terminalSlot, options);
233+ this.runtime.free(options, optionsSize);
234+ this.assertSuccess("ghostty_terminal_new", terminalResult);
235+ this.terminal = this.runtime.readPointer(this.terminalSlot);
236+ this.ptyWriter = onPtyData;
237+ this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, onPtyData);
238+
239+ this.renderStateSlot = this.runtime.allocOpaque();
240+ this.assertSuccess(
241+ "ghostty_render_state_new",
242+ this.runtime.call("ghostty_render_state_new", 0, this.renderStateSlot),
243+ );
244+ this.renderState = this.runtime.readPointer(this.renderStateSlot);
245+
246+ this.rowIteratorSlot = this.runtime.allocOpaque();
247+ this.assertSuccess(
248+ "ghostty_render_state_row_iterator_new",
249+ this.runtime.call("ghostty_render_state_row_iterator_new", 0, this.rowIteratorSlot),
250+ );
251+ this.rowCellsSlot = this.runtime.allocOpaque();
252+ this.assertSuccess(
253+ "ghostty_render_state_row_cells_new",
254+ this.runtime.call("ghostty_render_state_row_cells_new", 0, this.rowCellsSlot),
255+ );
256+
257+ this.keyEncoderSlot = this.runtime.allocOpaque();
258+ this.assertSuccess(
259+ "ghostty_key_encoder_new",
260+ this.runtime.call("ghostty_key_encoder_new", 0, this.keyEncoderSlot),
261+ );
262+ this.keyEncoder = this.runtime.readPointer(this.keyEncoderSlot);
263+ this.keyEventSlot = this.runtime.allocOpaque();
264+ this.assertSuccess(
265+ "ghostty_key_event_new",
266+ this.runtime.call("ghostty_key_event_new", 0, this.keyEventSlot),
267+ );
268+ this.keyEvent = this.runtime.readPointer(this.keyEventSlot);
269+
270+ this.mouseEncoderSlot = this.runtime.allocOpaque();
271+ this.assertSuccess(
272+ "ghostty_mouse_encoder_new",
273+ this.runtime.call("ghostty_mouse_encoder_new", 0, this.mouseEncoderSlot),
274+ );
275+ this.mouseEncoder = this.runtime.readPointer(this.mouseEncoderSlot);
276+ this.mouseEventSlot = this.runtime.allocOpaque();
277+ this.assertSuccess(
278+ "ghostty_mouse_event_new",
279+ this.runtime.call("ghostty_mouse_event_new", 0, this.mouseEventSlot),
280+ );
281+ this.mouseEvent = this.runtime.readPointer(this.mouseEventSlot);
282+
283+ this.scratch = this.runtime.alloc(16);
284+ const styleSize = this.runtime.layout("GhosttyStyle").size;
285+ this.style = this.runtime.alloc(styleSize);
286+ this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize);
287+ this.scrollbar = this.runtime.alloc(this.runtime.layout("GhosttyTerminalScrollbar").size);
288+ this.setTheme(theme);
289+ this.resize(cols, rows, cellWidth, cellHeight);
290+ }
291+
292+ write(data: string | Uint8Array): void {
293+ this.ensureActive();
294+ const bytes = typeof data === "string" ? encoder.encode(data) : data;
295+ if (bytes.length === 0) return;
296+ const pointer = this.runtime.alloc(bytes.length);
297+ this.runtime.bytes(pointer, bytes.length).set(bytes);
298+ this.runtime.call("ghostty_terminal_vt_write", this.terminal, pointer, bytes.length);
299+ this.runtime.free(pointer, bytes.length);
300+ }
301+
302+ resetAndWrite(data: string): void {
303+ this.ensureActive();
304+ this.runtime.call("ghostty_terminal_reset", this.terminal);
305+ this.rows = [];
306+ if (data.length === 0) return;
307+ const writer = this.ptyWriter;
308+ if (this.ptyWriterId !== 0) {
309+ this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId);
310+ this.ptyWriterId = 0;
311+ }
312+ try {
313+ this.write(data);
314+ } finally {
315+ if (writer !== null && !this.disposed) {
316+ this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, writer);
317+ }
318+ }
319+ }
320+
321+ resize(cols: number, rows: number, cellWidth: number, cellHeight: number): void {
322+ this.ensureActive();
323+ this.assertSuccess(
324+ "ghostty_terminal_resize",
325+ this.runtime.call(
326+ "ghostty_terminal_resize",
327+ this.terminal,
328+ Math.max(1, Math.min(65_535, cols)),
329+ Math.max(1, Math.min(65_535, rows)),
330+ Math.max(1, Math.round(cellWidth)),
331+ Math.max(1, Math.round(cellHeight)),
332+ ),
333+ );
334+ }
335+
336+ setTheme(theme: GhosttyTheme): void {
337+ this.ensureActive();
338+ const color = this.runtime.alloc(3);
339+ for (const [option, value] of [
340+ [11, theme.foreground],
341+ [12, theme.background],
342+ [13, theme.cursor],
343+ ] as const) {
344+ this.runtime.bytes(color, 3).set([value.r, value.g, value.b]);
345+ this.runtime.call("ghostty_terminal_set", this.terminal, option, color);
346+ }
347+ this.runtime.free(color, 3);
348+ }
349+
350+ scroll(deltaRows: number): void {
351+ this.ensureActive();
352+ const layout = this.runtime.layout("GhosttyTerminalScrollViewport");
353+ const scroll = this.runtime.alloc(layout.size);
354+ this.runtime.setField(scroll, "GhosttyTerminalScrollViewport", "tag", 2);
355+ const value = layout.fields.value!;
356+ this.runtime.view(scroll + value.offset, value.size).setInt32(0, deltaRows, true);
357+ this.runtime.call("ghostty_terminal_scroll_viewport", this.terminal, scroll);
358+ this.runtime.free(scroll, layout.size);
359+ }
360+
361+ scrollToBottom(): void {
362+ this.ensureActive();
363+ const layout = this.runtime.layout("GhosttyTerminalScrollViewport");
364+ const scroll = this.runtime.alloc(layout.size);
365+ this.runtime.setField(scroll, "GhosttyTerminalScrollViewport", "tag", 1);
366+ this.runtime.call("ghostty_terminal_scroll_viewport", this.terminal, scroll);
367+ this.runtime.free(scroll, layout.size);
368+ }
369+
370+ isViewportActive(): boolean {
371+ this.ensureActive();
372+ this.runtime.bytes(this.scratch, 1)[0] = 0;
373+ return (
374+ this.runtime.call("ghostty_terminal_get", this.terminal, 32, this.scratch) ===
375+ GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0
376+ );
377+ }
378+
379+ scrollbarState(): GhosttyScrollbar | null {
380+ this.ensureActive();
381+ const layout = this.runtime.layout("GhosttyTerminalScrollbar");
382+ this.runtime.bytes(this.scrollbar, layout.size).fill(0);
383+ if (
384+ this.runtime.call("ghostty_terminal_get", this.terminal, 9, this.scrollbar) !==
385+ GHOSTTY_SUCCESS
386+ ) {
387+ return null;
388+ }
389+ return {
390+ total: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "total"),
391+ offset: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "offset"),
392+ len: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "len"),
393+ };
394+ }
395+
396+ isMouseTracking(): boolean {
397+ this.ensureActive();
398+ this.runtime.bytes(this.scratch, 1)[0] = 0;
399+ return (
400+ this.runtime.call("ghostty_terminal_get", this.terminal, 11, this.scratch) ===
401+ GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0
402+ );
403+ }
404+
405+ isMouseAnyEventTracking(): boolean {
406+ this.ensureActive();
407+ this.runtime.bytes(this.scratch, 1)[0] = 0;
408+ return (
409+ this.runtime.call("ghostty_terminal_mode_get", this.terminal, 1003, this.scratch) ===
410+ GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0
411+ );
412+ }
413+
414+ isAlternateScreen(): boolean {
415+ this.ensureActive();
416+ this.runtime.bytes(this.scratch, 4).fill(0);
417+ return (
418+ this.runtime.call("ghostty_terminal_get", this.terminal, 6, this.scratch) ===
419+ GHOSTTY_SUCCESS && this.runtime.view(this.scratch, 4).getUint32(0, true) === 1
420+ );
421+ }
422+
423+ isApplicationCursorKeys(): boolean {
424+ this.ensureActive();
425+ this.runtime.bytes(this.scratch, 1)[0] = 0;
426+ return (
427+ this.runtime.call("ghostty_terminal_mode_get", this.terminal, 1, this.scratch) ===
428+ GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0
429+ );
430+ }
431+
432+ encodeKey(event: KeyboardEvent, action: "press" | "release" = "press"): string {
433+ this.ensureActive();
434+ this.runtime.call("ghostty_key_encoder_setopt_from_terminal", this.keyEncoder, this.terminal);
435+ this.runtime.call(
436+ "ghostty_key_event_set_action",
437+ this.keyEvent,
438+ action === "release" ? 0 : event.repeat ? 2 : 1,
439+ );
440+ this.runtime.call("ghostty_key_event_set_key", this.keyEvent, ghosttyKeyForCode(event.code));
441+ const mods =
442+ (event.shiftKey ? 1 : 0) |
443+ (event.ctrlKey ? 1 << 1 : 0) |
444+ (event.altKey ? 1 << 2 : 0) |
445+ (event.metaKey ? 1 << 3 : 0) |
446+ (event.getModifierState("CapsLock") ? 1 << 4 : 0) |
447+ (event.getModifierState("NumLock") ? 1 << 5 : 0);
448+ this.runtime.call("ghostty_key_event_set_mods", this.keyEvent, mods);
449+ this.runtime.call("ghostty_key_event_set_consumed_mods", this.keyEvent, 0);
450+ this.runtime.call("ghostty_key_event_set_composing", this.keyEvent, event.isComposing ? 1 : 0);
451+ this.runtime.call(
452+ "ghostty_key_event_set_unshifted_codepoint",
453+ this.keyEvent,
454+ ghosttyUnshiftedCodepoint(event, this.keyboardLayoutMap),
455+ );
456+
457+ const text = event.key.length === 1 ? event.key : "";
458+ const textBytes = encoder.encode(text);
459+ const textPointer = textBytes.length === 0 ? 0 : this.runtime.alloc(textBytes.length);
460+ if (textPointer !== 0) this.runtime.bytes(textPointer, textBytes.length).set(textBytes);
461+ this.runtime.call("ghostty_key_event_set_utf8", this.keyEvent, textPointer, textBytes.length);
462+
463+ const written = this.runtime.call("ghostty_wasm_alloc_usize");
464+ const encoded = this.encodeOutput(written, (output, outputSize) =>
465+ this.runtime.call(
466+ "ghostty_key_encoder_encode",
467+ this.keyEncoder,
468+ this.keyEvent,
469+ output,
470+ outputSize,
471+ written,
472+ ),
473+ );
474+ this.runtime.call("ghostty_wasm_free_usize", written);
475+ if (textPointer !== 0) this.runtime.free(textPointer, textBytes.length);
476+ return encoded;
477+ }
478+
479+ encodePaste(data: string): string {
480+ this.ensureActive();
481+ const input = encoder.encode(data);
482+ if (input.length === 0) return "";
483+ const inputPointer = this.runtime.alloc(input.length);
484+ this.runtime.bytes(inputPointer, input.length).set(input);
485+ this.runtime.bytes(this.scratch, 1)[0] = 0;
486+ const bracketed =
487+ this.runtime.call("ghostty_terminal_mode_get", this.terminal, 2004, this.scratch) ===
488+ GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0;
489+ const written = this.runtime.call("ghostty_wasm_alloc_usize");
490+ let encoded = "";
491+ const sizeResult = this.runtime.call(
492+ "ghostty_paste_encode",
493+ inputPointer,
494+ input.length,
495+ bracketed ? 1 : 0,
496+ 0,
497+ 0,
498+ written,
499+ );
500+ const outputSize = this.runtime.view(written, 4).getUint32(0, true);
501+ if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) {
502+ const output = this.runtime.alloc(outputSize);
503+ const result = this.runtime.call(
504+ "ghostty_paste_encode",
505+ inputPointer,
506+ input.length,
507+ bracketed ? 1 : 0,
508+ output,
509+ outputSize,
510+ written,
511+ );
512+ const outputLength = this.runtime.view(written, 4).getUint32(0, true);
513+ encoded =
514+ result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : "";
515+ this.runtime.free(output, outputSize);
516+ }
517+ this.runtime.call("ghostty_wasm_free_usize", written);
518+ this.runtime.free(inputPointer, input.length);
519+ return encoded;
520+ }
521+
522+ encodeMouse(input: GhosttyMouseInput): string {
523+ this.ensureActive();
524+ this.runtime.call(
525+ "ghostty_mouse_encoder_setopt_from_terminal",
526+ this.mouseEncoder,
527+ this.terminal,
528+ );
529+
530+ const sizeLayout = this.runtime.layout("GhosttyMouseEncoderSize");
531+ const size = this.runtime.alloc(sizeLayout.size);
532+ for (const [field, value] of [
533+ ["size", sizeLayout.size],
534+ ["screen_width", input.screenWidth],
535+ ["screen_height", input.screenHeight],
536+ ["cell_width", input.cellWidth],
537+ ["cell_height", input.cellHeight],
538+ ["padding_top", input.paddingTop],
539+ ["padding_bottom", input.paddingBottom],
540+ ["padding_right", input.paddingRight],
541+ ["padding_left", input.paddingLeft],
542+ ] as const) {
543+ this.runtime.setField(size, "GhosttyMouseEncoderSize", field, Math.max(0, Math.round(value)));
544+ }
545+ this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 2, size);
546+ this.runtime.free(size, sizeLayout.size);
547+
548+ this.runtime.bytes(this.scratch, 1)[0] = input.anyButtonPressed ? 1 : 0;
549+ this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 3, this.scratch);
550+ this.runtime.bytes(this.scratch, 1)[0] = 1;
551+ this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 4, this.scratch);
552+
553+ this.runtime.call(
554+ "ghostty_mouse_event_set_action",
555+ this.mouseEvent,
556+ input.action === "press" ? 0 : input.action === "release" ? 1 : 2,
557+ );
558+ if (input.button === null) {
559+ this.runtime.call("ghostty_mouse_event_clear_button", this.mouseEvent);
560+ } else {
561+ this.runtime.call("ghostty_mouse_event_set_button", this.mouseEvent, input.button);
562+ }
563+ this.runtime.call("ghostty_mouse_event_set_mods", this.mouseEvent, input.mods);
564+ const positionLayout = this.runtime.layout("GhosttyMousePosition");
565+ const position = this.runtime.alloc(positionLayout.size);
566+ const positionView = this.runtime.view(position, positionLayout.size);
567+ positionView.setFloat32(positionLayout.fields.x!.offset, input.x, true);
568+ positionView.setFloat32(positionLayout.fields.y!.offset, input.y, true);
569+ this.runtime.call("ghostty_mouse_event_set_position", this.mouseEvent, position);
570+ this.runtime.free(position, positionLayout.size);
571+
572+ const written = this.runtime.call("ghostty_wasm_alloc_usize");
573+ const encoded = this.encodeOutput(written, (output, outputSize) =>
574+ this.runtime.call(
575+ "ghostty_mouse_encoder_encode",
576+ this.mouseEncoder,
577+ this.mouseEvent,
578+ output,
579+ outputSize,
580+ written,
581+ ),
582+ );
583+ this.runtime.call("ghostty_wasm_free_usize", written);
584+ return encoded;
585+ }
586+
587+ setSelection(anchor: GhosttyPointInput, end: GhosttyPointInput): void {
588+ this.ensureActive();
589+ const selectionLayout = this.runtime.layout("GhosttySelection");
590+ const gridRefSize = this.runtime.layout("GhosttyGridRef").size;
591+ const selection = this.runtime.alloc(selectionLayout.size);
592+ let start = 0;
593+ let endRef = 0;
594+ try {
595+ this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size);
596+ start = this.gridRef(anchor.x, anchor.y, anchor.tag ?? 1);
597+ endRef = this.gridRef(end.x, end.y, end.tag ?? 1);
598+ const startField = selectionLayout.fields.start!;
599+ const endField = selectionLayout.fields.end!;
600+ this.runtime
601+ .bytes(selection + startField.offset, startField.size)
602+ .set(this.runtime.bytes(start, startField.size));
603+ this.runtime
604+ .bytes(selection + endField.offset, endField.size)
605+ .set(this.runtime.bytes(endRef, endField.size));
606+ this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection);
607+ } finally {
608+ this.runtime.free(start, gridRefSize);
609+ this.runtime.free(endRef, gridRefSize);
610+ this.runtime.free(selection, selectionLayout.size);
611+ }
612+ }
613+
614+ selectAll(): void {
615+ this.ensureActive();
616+ const layout = this.runtime.layout("GhosttySelection");
617+ const selection = this.runtime.alloc(layout.size);
618+ this.runtime.setField(selection, "GhosttySelection", "size", layout.size);
619+ if (
620+ this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) === GHOSTTY_SUCCESS
621+ ) {
622+ this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection);
623+ }
624+ this.runtime.free(selection, layout.size);
625+ }
626+
627+ selectWord(col: number, row: number): GhosttySelectionRange | null {
628+ return this.selectAt(
629+ "GhosttyTerminalSelectWordOptions",
630+ "ghostty_terminal_select_word",
631+ col,
632+ row,
633+ );
634+ }
635+
636+ selectLine(col: number, row: number): GhosttySelectionRange | null {
637+ return this.selectAt(
638+ "GhosttyTerminalSelectLineOptions",
639+ "ghostty_terminal_select_line",
640+ col,
641+ row,
642+ );
643+ }
644+
645+ hyperlinkAt(col: number, row: number): string | null {
646+ this.ensureActive();
647+ const ref = this.gridRef(col, row);
648+ const written = this.runtime.call("ghostty_wasm_alloc_usize");
649+ const sizeResult = this.runtime.call("ghostty_grid_ref_hyperlink_uri", ref, 0, 0, written);
650+ const outputSize = this.runtime.view(written, 4).getUint32(0, true);
651+ let hyperlink: string | null = null;
652+ if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) {
653+ const output = this.runtime.alloc(outputSize);
654+ const result = this.runtime.call(
655+ "ghostty_grid_ref_hyperlink_uri",
656+ ref,
657+ output,
658+ outputSize,
659+ written,
660+ );
661+ const outputLength = this.runtime.view(written, 4).getUint32(0, true);
662+ if (result === GHOSTTY_SUCCESS && outputLength > 0) {
663+ hyperlink = decoder.decode(this.runtime.bytes(output, outputLength));
664+ }
665+ this.runtime.free(output, outputSize);
666+ }
667+ this.runtime.call("ghostty_wasm_free_usize", written);
668+ this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size);
669+ return hyperlink;
670+ }
671+
672+ clearSelection(): void {
673+ this.ensureActive();
674+ this.runtime.call("ghostty_terminal_set", this.terminal, 21, 0);
675+ }
676+
677+ snapshot(): GhosttySnapshot {
678+ this.ensureActive();
679+ this.assertSuccess(
680+ "ghostty_render_state_update",
681+ this.runtime.call("ghostty_render_state_update", this.renderState, this.terminal),
682+ );
683+ const cols = this.getU16(RENDER_DATA.cols);
684+ const rowCount = this.getU16(RENDER_DATA.rows);
685+ const dirty = this.getU32(RENDER_DATA.dirty);
686+ const foreground = this.getColor(RENDER_DATA.foreground, { r: 229, g: 231, b: 235 });
687+ const background = this.getColor(RENDER_DATA.background, { r: 0, g: 0, b: 0 });
688+ const cursorHasValue = this.getBool(RENDER_DATA.cursorHasValue);
689+ const cursor = cursorHasValue ? this.getColor(RENDER_DATA.cursor, foreground) : foreground;
690+ const cursorInViewport = this.getBool(RENDER_DATA.cursorInViewport);
691+ const cursorVisible = this.getBool(RENDER_DATA.cursorVisible) && cursorInViewport;
692+ const cursorX = cursorInViewport ? this.getU16(RENDER_DATA.cursorX) : -1;
693+ const cursorY = cursorInViewport ? this.getU16(RENDER_DATA.cursorY) : -1;
694+
695+ if (this.rows.length !== rowCount || this.rows.some((row) => row.cells.length !== cols)) {
696+ this.rows = Array.from({ length: rowCount }, () => ({
697+ cells: Array.from({ length: cols }, () => this.emptyCell(foreground, background)),
698+ text: "",
699+ isWrapContinuation: false,
700+ wrapsToNext: false,
701+ }));
702+ }
703+
704+ const dirtyRows = new Set<number>();
705+ if (dirty !== 0) {
706+ this.assertSuccess(
707+ "ghostty_render_state_get(row iterator)",
708+ this.runtime.call(
709+ "ghostty_render_state_get",
710+ this.renderState,
711+ RENDER_DATA.rowIterator,
712+ this.rowIteratorSlot,
713+ ),
714+ );
715+ const iterator = this.runtime.readPointer(this.rowIteratorSlot);
716+ let rowIndex = 0;
717+ while (
718+ rowIndex < rowCount &&
719+ this.runtime.call("ghostty_render_state_row_iterator_next", iterator) !== 0
720+ ) {
721+ const rowDirty = dirty === 2 || this.getRowBool(iterator, ROW_DATA.dirty);
722+ if (rowDirty) {
723+ this.rows[rowIndex] = this.readRow(iterator, cols, foreground, background);
724+ dirtyRows.add(rowIndex);
725+ this.runtime.bytes(this.scratch, 1)[0] = 0;
726+ this.runtime.call("ghostty_render_state_row_set", iterator, 0, this.scratch);
727+ }
728+ rowIndex += 1;
729+ }
730+ this.runtime.view(this.scratch, 4).setUint32(0, 0, true);
731+ this.runtime.call("ghostty_render_state_set", this.renderState, 0, this.scratch);
732+ }
733+
734+ return {
735+ cols,
736+ rows: rowCount,
737+ foreground,
738+ background,
739+ cursor,
740+ cursorX,
741+ cursorY,
742+ cursorVisible,
743+ cursorBlinking: this.getBool(RENDER_DATA.cursorBlinking),
744+ cursorStyle: this.getU32(RENDER_DATA.cursorStyle),
745+ dirtyRows,
746+ rowData: this.rows,
747+ };
748+ }
749+
750+ selectionText(): string {
751+ this.ensureActive();
752+ const options = this.runtime.alloc(SELECTION_FORMAT_OPTIONS_SIZE);
753+ const optionsView = this.runtime.view(options, SELECTION_FORMAT_OPTIONS_SIZE);
754+ optionsView.setUint32(0, SELECTION_FORMAT_OPTIONS_SIZE, true);
755+ optionsView.setUint32(4, 0, true);
756+ optionsView.setUint8(8, 1);
757+ optionsView.setUint8(9, 1);
758+ optionsView.setUint32(12, 0, true);
759+ const written = this.runtime.call("ghostty_wasm_alloc_usize");
760+ const sizeResult = this.runtime.call(
761+ "ghostty_terminal_selection_format_buf",
762+ this.terminal,
763+ options,
764+ 0,
765+ 0,
766+ written,
767+ );
768+ const outputSize = this.runtime.view(written, 4).getUint32(0, true);
769+ let text = "";
770+ if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) {
771+ const output = this.runtime.alloc(outputSize);
772+ const result = this.runtime.call(
773+ "ghostty_terminal_selection_format_buf",
774+ this.terminal,
775+ options,
776+ output,
777+ outputSize,
778+ written,
779+ );
780+ const outputLength = this.runtime.view(written, 4).getUint32(0, true);
781+ if (result === GHOSTTY_SUCCESS) {
782+ text = decoder.decode(this.runtime.bytes(output, outputLength));
783+ }
784+ this.runtime.free(output, outputSize);
785+ }
786+ this.runtime.call("ghostty_wasm_free_usize", written);
787+ this.runtime.free(options, SELECTION_FORMAT_OPTIONS_SIZE);
788+ return text;
789+ }
790+
791+ viewportPointToScreen(col: number, row: number): { x: number; y: number } | null {
792+ return this.convertPoint(col, row, 1, 2);
793+ }
794+
795+ screenPointToViewport(col: number, row: number): { x: number; y: number } | null {
796+ return this.convertPoint(col, row, 2, 1);
797+ }
798+
799+ private convertPoint(
800+ col: number,
801+ row: number,
802+ fromTag: 1 | 2,
803+ toTag: 1 | 2,
804+ ): { x: number; y: number } | null {
805+ this.ensureActive();
806+ const ref = this.gridRef(col, row, fromTag);
807+ const point = this.pointFromGridRef(ref, toTag);
808+ this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size);
809+ return point;
810+ }
811+
812+ dispose(): void {
813+ if (this.disposed) return;
814+ this.disposed = true;
815+ if (this.mouseEvent) this.runtime.call("ghostty_mouse_event_free", this.mouseEvent);
816+ if (this.mouseEncoder) this.runtime.call("ghostty_mouse_encoder_free", this.mouseEncoder);
817+ if (this.keyEvent) this.runtime.call("ghostty_key_event_free", this.keyEvent);
818+ if (this.keyEncoder) this.runtime.call("ghostty_key_encoder_free", this.keyEncoder);
819+ if (this.rowCellsSlot) {
820+ const cells = this.runtime.readPointer(this.rowCellsSlot);
821+ if (cells) this.runtime.call("ghostty_render_state_row_cells_free", cells);
822+ }
823+ if (this.rowIteratorSlot) {
824+ const iterator = this.runtime.readPointer(this.rowIteratorSlot);
825+ if (iterator) this.runtime.call("ghostty_render_state_row_iterator_free", iterator);
826+ }
827+ if (this.renderState) this.runtime.call("ghostty_render_state_free", this.renderState);
828+ if (this.terminal) {
829+ if (this.ptyWriterId) this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId);
830+ this.runtime.call("ghostty_terminal_free", this.terminal);
831+ }
832+ if (this.style) this.runtime.free(this.style, this.runtime.layout("GhosttyStyle").size);
833+ if (this.scrollbar) {
834+ this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size);
835+ }
836+ if (this.scratch) this.runtime.free(this.scratch, 16);
837+ for (const slot of [
838+ this.mouseEventSlot,
839+ this.mouseEncoderSlot,
840+ this.keyEventSlot,
841+ this.keyEncoderSlot,
842+ this.rowCellsSlot,
843+ this.rowIteratorSlot,
844+ this.renderStateSlot,
845+ this.terminalSlot,
846+ ]) {
847+ this.runtime.freeOpaque(slot);
848+ }
849+ }
850+
851+ private encodeOutput(
852+ written: number,
853+ encode: (output: number, outputSize: number) => number,
854+ ): string {
855+ const sizeResult = encode(0, 0);
856+ const outputSize = this.runtime.view(written, 4).getUint32(0, true);
857+ if (sizeResult === GHOSTTY_SUCCESS && outputSize === 0) return "";
858+ if (sizeResult !== GHOSTTY_OUT_OF_SPACE || outputSize === 0) return "";
859+
860+ const output = this.runtime.alloc(outputSize);
861+ const result = encode(output, outputSize);
862+ const outputLength = this.runtime.view(written, 4).getUint32(0, true);
863+ const encoded =
864+ result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : "";
865+ this.runtime.free(output, outputSize);
866+ return encoded;
867+ }
868+
869+ private readRow(
870+ iterator: number,
871+ cols: number,
872+ defaultForeground: GhosttyColor,
873+ defaultBackground: GhosttyColor,
874+ ): GhosttyRow {
875+ this.assertSuccess(
876+ "ghostty_render_state_row_get(raw)",
877+ this.runtime.call("ghostty_render_state_row_get", iterator, ROW_DATA.raw, this.scratch),
878+ );
879+ const rawRow = this.runtime.view(this.scratch, 8).getBigUint64(0, true);
880+ this.runtime.bytes(this.scratch + 8, 1)[0] = 0;
881+ this.assertSuccess(
882+ "ghostty_row_get(wrap continuation)",
883+ this.runtime.call("ghostty_row_get", rawRow, 2, this.scratch + 8),
884+ );
885+ const isWrapContinuation = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0;
886+ this.runtime.bytes(this.scratch + 8, 1)[0] = 0;
887+ this.assertSuccess(
888+ "ghostty_row_get(wrap)",
889+ this.runtime.call("ghostty_row_get", rawRow, 1, this.scratch + 8),
890+ );
891+ const wrapsToNext = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0;
892+
893+ this.assertSuccess(
894+ "ghostty_render_state_row_get(cells)",
895+ this.runtime.call(
896+ "ghostty_render_state_row_get",
897+ iterator,
898+ ROW_DATA.cells,
899+ this.rowCellsSlot,
900+ ),
901+ );
902+ const cellsIterator = this.runtime.readPointer(this.rowCellsSlot);
903+ const cells: GhosttyCell[] = [];
904+ while (
905+ cells.length < cols &&
906+ this.runtime.call("ghostty_render_state_row_cells_next", cellsIterator) !== 0
907+ ) {
908+ let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground);
909+ let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground);
910+ const styleSize = this.runtime.layout("GhosttyStyle").size;
911+ this.runtime.bytes(this.style, styleSize).fill(0);
912+ this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize);
913+ this.runtime.call(
914+ "ghostty_render_state_row_cells_get",
915+ cellsIterator,
916+ CELL_DATA.style,
917+ this.style,
918+ );
919+ const inverse = this.runtime.readField(this.style, "GhosttyStyle", "inverse") !== 0;
920+ if (inverse) [foreground, background] = [background, foreground];
921+ if (this.runtime.readField(this.style, "GhosttyStyle", "faint") !== 0) {
922+ foreground = blend(foreground, background);
923+ }
924+ const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength);
925+ let text = "";
926+ if (graphemeLength > 0) {
927+ const bufferSize = graphemeLength * 4;
928+ const codepoints = this.runtime.alloc(bufferSize);
929+ if (
930+ this.runtime.call(
931+ "ghostty_render_state_row_cells_get",
932+ cellsIterator,
933+ CELL_DATA.graphemes,
934+ codepoints,
935+ ) === GHOSTTY_SUCCESS
936+ ) {
937+ // Read through a DataView: the byte-array allocator guarantees no
938+ // 4-byte alignment, which a Uint32Array view would require.
939+ const codepointView = this.runtime.view(codepoints, bufferSize);
940+ const codes: number[] = [];
941+ for (let index = 0; index < graphemeLength; index += 1) {
942+ codes.push(codepointView.getUint32(index * 4, true));
943+ }
944+ text = String.fromCodePoint(...codes);
945+ }
946+ this.runtime.free(codepoints, bufferSize);
947+ }
948+ let wide = 0;
949+ if (text.length === 0 && cells.at(-1)?.text.length) {
950+ this.assertSuccess(
951+ "ghostty_render_state_row_cells_get(raw)",
952+ this.runtime.call(
953+ "ghostty_render_state_row_cells_get",
954+ cellsIterator,
955+ CELL_DATA.raw,
956+ this.scratch,
957+ ),
958+ );
959+ const rawCell = this.runtime.view(this.scratch, 8).getBigUint64(0, true);
960+ this.runtime.view(this.scratch + 8, 4).setUint32(0, 0, true);
961+ this.assertSuccess(
962+ "ghostty_cell_get(wide)",
963+ this.runtime.call("ghostty_cell_get", rawCell, RAW_CELL_DATA.wide, this.scratch + 8),
964+ );
965+ wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true);
966+ }
967+ cells.push({
968+ text,
969+ wide,
970+ foreground,
971+ background,
972+ bold: this.runtime.readField(this.style, "GhosttyStyle", "bold") !== 0,
973+ italic: this.runtime.readField(this.style, "GhosttyStyle", "italic") !== 0,
974+ invisible: this.runtime.readField(this.style, "GhosttyStyle", "invisible") !== 0,
975+ strikethrough: this.runtime.readField(this.style, "GhosttyStyle", "strikethrough") !== 0,
976+ overline: this.runtime.readField(this.style, "GhosttyStyle", "overline") !== 0,
977+ underline: this.runtime.readField(this.style, "GhosttyStyle", "underline") !== 0,
978+ selected: this.getCellBool(cellsIterator, CELL_DATA.selected),
979+ });
980+ }
981+ while (cells.length < cols) cells.push(this.emptyCell(defaultForeground, defaultBackground));
982+ return {
983+ cells,
984+ text: cells
985+ .map((cell) => cell.text || " ")
986+ .join("")
987+ .trimEnd(),
988+ isWrapContinuation,
989+ wrapsToNext,
990+ };
991+ }
992+
993+ private gridRef(col: number, row: number, tag: 1 | 2 = 1): number {
994+ const pointLayout = this.runtime.layout("GhosttyPoint");
995+ const point = this.runtime.alloc(pointLayout.size);
996+ this.runtime.setField(point, "GhosttyPoint", "tag", tag);
997+ const pointValue = pointLayout.fields.value!;
998+ const valueOffset = pointValue.offset;
999+ const view = this.runtime.view(point + valueOffset, pointValue.size);
1000+ view.setUint16(0, Math.max(0, col), true);
1001+ view.setUint32(4, Math.max(0, row), true);
1002+ const gridRefSize = this.runtime.layout("GhosttyGridRef").size;
1003+ const gridRef = this.runtime.alloc(gridRefSize);
1004+ this.runtime.setField(gridRef, "GhosttyGridRef", "size", gridRefSize);
1005+ const result = this.runtime.call("ghostty_terminal_grid_ref", this.terminal, point, gridRef);
1006+ this.runtime.free(point, pointLayout.size);
1007+ if (result !== GHOSTTY_SUCCESS) {
1008+ this.runtime.free(gridRef, gridRefSize);
1009+ this.assertSuccess("ghostty_terminal_grid_ref", result);
1010+ }
1011+ return gridRef;
1012+ }
1013+
1014+ private selectAt(
1015+ optionsName: "GhosttyTerminalSelectWordOptions" | "GhosttyTerminalSelectLineOptions",
1016+ operation: "ghostty_terminal_select_word" | "ghostty_terminal_select_line",
1017+ col: number,
1018+ row: number,
1019+ ): GhosttySelectionRange | null {
1020+ this.ensureActive();
1021+ const optionsLayout = this.runtime.layout(optionsName);
1022+ const selectionLayout = this.runtime.layout("GhosttySelection");
1023+ const options = this.runtime.alloc(optionsLayout.size);
1024+ let ref = 0;
1025+ let selection = 0;
1026+ let range: GhosttySelectionRange | null = null;
1027+ try {
1028+ this.runtime.setField(options, optionsName, "size", optionsLayout.size);
1029+ ref = this.gridRef(col, row);
1030+ const refField = optionsLayout.fields.ref!;
1031+ this.runtime
1032+ .bytes(options + refField.offset, refField.size)
1033+ .set(this.runtime.bytes(ref, refField.size));
1034+ selection = this.runtime.alloc(selectionLayout.size);
1035+ this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size);
1036+ const result = this.runtime.call(operation, this.terminal, options, selection);
1037+ if (result === GHOSTTY_SUCCESS) {
1038+ const start = selection + selectionLayout.fields.start!.offset;
1039+ const end = selection + selectionLayout.fields.end!.offset;
1040+ const viewportStart = this.pointFromGridRef(start, 1);
1041+ const viewportEnd = this.pointFromGridRef(end, 1);
1042+ const screenStart = this.pointFromGridRef(start, 2);
1043+ const screenEnd = this.pointFromGridRef(end, 2);
1044+ if (viewportStart && viewportEnd && screenStart && screenEnd) {
1045+ range = {
1046+ viewport: { start: viewportStart, end: viewportEnd },
1047+ screen: { start: screenStart, end: screenEnd },
1048+ };
1049+ }
1050+ this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection);
1051+ }
1052+ } finally {
1053+ this.runtime.free(selection, selectionLayout.size);
1054+ this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size);
1055+ this.runtime.free(options, optionsLayout.size);
1056+ }
1057+ return range;
1058+ }
1059+
1060+ private pointFromGridRef(ref: number, tag: 1 | 2): { x: number; y: number } | null {
1061+ const coordinateLayout = this.runtime.layout("GhosttyPointCoordinate");
1062+ const coordinate = this.runtime.alloc(coordinateLayout.size);
1063+ const result = this.runtime.call(
1064+ "ghostty_terminal_point_from_grid_ref",
1065+ this.terminal,
1066+ ref,
1067+ tag,
1068+ coordinate,
1069+ );
1070+ const point =
1071+ result === GHOSTTY_SUCCESS
1072+ ? {
1073+ x: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "x"),
1074+ y: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "y"),
1075+ }
1076+ : null;
1077+ this.runtime.free(coordinate, coordinateLayout.size);
1078+ return point;
1079+ }
1080+
1081+ private getU16(data: number): number {
1082+ this.runtime.bytes(this.scratch, 2).fill(0);
1083+ this.assertSuccess(
1084+ "ghostty_render_state_get",
1085+ this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch),
1086+ );
1087+ return this.runtime.view(this.scratch, 2).getUint16(0, true);
1088+ }
1089+
1090+ private getU32(data: number): number {
1091+ this.runtime.bytes(this.scratch, 4).fill(0);
1092+ this.assertSuccess(
1093+ "ghostty_render_state_get",
1094+ this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch),
1095+ );
1096+ return this.runtime.view(this.scratch, 4).getUint32(0, true);
1097+ }
1098+
1099+ private getBool(data: number): boolean {
1100+ this.runtime.bytes(this.scratch, 1)[0] = 0;
1101+ this.assertSuccess(
1102+ "ghostty_render_state_get",
1103+ this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch),
1104+ );
1105+ return this.runtime.bytes(this.scratch, 1)[0] !== 0;
1106+ }
1107+
1108+ private getColor(data: number, fallback: GhosttyColor): GhosttyColor {
1109+ this.runtime.bytes(this.scratch, 3).fill(0);
1110+ const result = this.runtime.call(
1111+ "ghostty_render_state_get",
1112+ this.renderState,
1113+ data,
1114+ this.scratch,
1115+ );
1116+ return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback;
1117+ }
1118+
1119+ private getRowBool(iterator: number, data: number): boolean {
1120+ this.runtime.bytes(this.scratch, 1)[0] = 0;
1121+ return (
1122+ this.runtime.call("ghostty_render_state_row_get", iterator, data, this.scratch) ===
1123+ GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0
1124+ );
1125+ }
1126+
1127+ private getCellU32(iterator: number, data: number): number {
1128+ this.runtime.bytes(this.scratch, 4).fill(0);
1129+ const result = this.runtime.call(
1130+ "ghostty_render_state_row_cells_get",
1131+ iterator,
1132+ data,
1133+ this.scratch,
1134+ );
1135+ return result === GHOSTTY_SUCCESS ? this.runtime.view(this.scratch, 4).getUint32(0, true) : 0;
1136+ }
1137+
1138+ private getCellBool(iterator: number, data: number): boolean {
1139+ this.runtime.bytes(this.scratch, 1)[0] = 0;
1140+ return (
1141+ this.runtime.call("ghostty_render_state_row_cells_get", iterator, data, this.scratch) ===
1142+ GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0
1143+ );
1144+ }
1145+
1146+ private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor {
1147+ this.runtime.bytes(this.scratch, 3).fill(0);
1148+ const result = this.runtime.call(
1149+ "ghostty_render_state_row_cells_get",
1150+ iterator,
1151+ data,
1152+ this.scratch,
1153+ );
1154+ return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback;
1155+ }
1156+
1157+ private readColor(pointer: number): GhosttyColor {
1158+ const bytes = this.runtime.bytes(pointer, 3);
1159+ return { r: bytes[0] ?? 0, g: bytes[1] ?? 0, b: bytes[2] ?? 0 };
1160+ }
1161+
1162+ private emptyCell(foreground: GhosttyColor, background: GhosttyColor): GhosttyCell {
1163+ return {
1164+ text: "",
1165+ wide: 0,
1166+ foreground,
1167+ background,
1168+ bold: false,
1169+ italic: false,
1170+ invisible: false,
1171+ strikethrough: false,
1172+ overline: false,
1173+ underline: false,
1174+ selected: false,
1175+ };
1176+ }
1177+
1178+ private assertSuccess(operation: string, result: number): void {
1179+ if (result !== GHOSTTY_SUCCESS) throw new Error(`${operation} failed with result ${result}`);
1180+ }
1181+
1182+ private ensureActive(): void {
1183+ if (this.disposed) throw new Error("libghostty-vt terminal has been disposed");
1184+ }
1185+}
1186+
1187+export function ghosttyColorsEqual(left: GhosttyColor, right: GhosttyColor): boolean {
1188+ return sameColor(left, right);
1189+}
apps/web/src/terminal/ghostty/fonts/LICENSE+21−0
@@ -0,0 +1,21 @@
1+The MIT License (MIT)
2+
3+Copyright (c) 2014 Ryan L McIntyre
4+
5+Permission is hereby granted, free of charge, to any person obtaining a copy
6+of this software and associated documentation files (the "Software"), to deal
7+in the Software without restriction, including without limitation the rights
8+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+copies of the Software, and to permit persons to whom the Software is
10+furnished to do so, subject to the following conditions:
11+
12+The above copyright notice and this permission notice shall be included in all
13+copies or substantial portions of the Software.
14+
15+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+SOFTWARE.
apps/web/src/terminal/ghostty/fonts/SymbolsNerdFontMono-Regular.woff2+0−0

Binary file not shown.

apps/web/src/terminal/ghostty/keyCodes.test.ts+53−0
@@ -0,0 +1,53 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import { ghosttyKeyForCode, ghosttyUnshiftedCodepoint } from "./keyCodes";
4+
5+describe("ghosttyKeyForCode", () => {
6+ it("keeps the tail of the pinned Ghostty key enum in order", () => {
7+ expect(ghosttyKeyForCode("F25")).toBe(ghosttyKeyForCode("F24") + 1);
8+ expect(ghosttyKeyForCode("PrintScreen")).toBe(ghosttyKeyForCode("FnLock") + 1);
9+ expect(ghosttyKeyForCode("Pause")).toBe(ghosttyKeyForCode("ScrollLock") + 1);
10+ expect(ghosttyKeyForCode("Paste")).toBe(ghosttyKeyForCode("Cut") + 1);
11+ });
12+});
13+
14+describe("ghosttyUnshiftedCodepoint", () => {
15+ it("provides the logical base character for Kitty keyboard encoding", () => {
16+ expect(ghosttyUnshiftedCodepoint({ code: "KeyC", key: "c", shiftKey: false })).toBe(
17+ "c".codePointAt(0),
18+ );
19+ expect(ghosttyUnshiftedCodepoint({ code: "KeyC", key: "C", shiftKey: true })).toBe(
20+ "c".codePointAt(0),
21+ );
22+ expect(ghosttyUnshiftedCodepoint({ code: "Digit1", key: "!", shiftKey: true })).toBe(
23+ "1".codePointAt(0),
24+ );
25+ expect(ghosttyUnshiftedCodepoint({ code: "Slash", key: "?", shiftKey: true })).toBe(
26+ "/".codePointAt(0),
27+ );
28+ expect(ghosttyUnshiftedCodepoint({ code: "Digit1", key: "&", shiftKey: false })).toBe(
29+ "&".codePointAt(0),
30+ );
31+ expect(ghosttyUnshiftedCodepoint({ code: "Enter", key: "Enter", shiftKey: false })).toBe(0);
32+ });
33+
34+ it("reports unknown instead of the shifted character without layout data", () => {
35+ expect(ghosttyUnshiftedCodepoint({ code: "Digit7", key: "/", shiftKey: true })).toBe(0);
36+ expect(ghosttyUnshiftedCodepoint({ code: "KeyD", key: "Д", shiftKey: true })).toBe(
37+ "д".codePointAt(0),
38+ );
39+ });
40+
41+ it("prefers the active browser layout over US physical key positions", () => {
42+ const layoutMap = new Map([
43+ ["Digit1", "&"],
44+ ["KeyC", "j"],
45+ ]);
46+ expect(ghosttyUnshiftedCodepoint({ code: "Digit1", key: "1", shiftKey: true }, layoutMap)).toBe(
47+ "&".codePointAt(0),
48+ );
49+ expect(ghosttyUnshiftedCodepoint({ code: "KeyC", key: "J", shiftKey: true }, layoutMap)).toBe(
50+ "j".codePointAt(0),
51+ );
52+ });
53+});
apps/web/src/terminal/ghostty/keyCodes.ts+258−0
@@ -0,0 +1,258 @@
1+// This order mirrors GhosttyKey in ghostty/vt/key/event.h. The values are
2+// intentionally derived from the official W3C-aligned enum instead of
3+// maintaining a second keyboard protocol.
4+const ghosttyKeyboardCodes = [
5+ "Unidentified",
6+ "Backquote",
7+ "Backslash",
8+ "BracketLeft",
9+ "BracketRight",
10+ "Comma",
11+ "Digit0",
12+ "Digit1",
13+ "Digit2",
14+ "Digit3",
15+ "Digit4",
16+ "Digit5",
17+ "Digit6",
18+ "Digit7",
19+ "Digit8",
20+ "Digit9",
21+ "Equal",
22+ "IntlBackslash",
23+ "IntlRo",
24+ "IntlYen",
25+ "KeyA",
26+ "KeyB",
27+ "KeyC",
28+ "KeyD",
29+ "KeyE",
30+ "KeyF",
31+ "KeyG",
32+ "KeyH",
33+ "KeyI",
34+ "KeyJ",
35+ "KeyK",
36+ "KeyL",
37+ "KeyM",
38+ "KeyN",
39+ "KeyO",
40+ "KeyP",
41+ "KeyQ",
42+ "KeyR",
43+ "KeyS",
44+ "KeyT",
45+ "KeyU",
46+ "KeyV",
47+ "KeyW",
48+ "KeyX",
49+ "KeyY",
50+ "KeyZ",
51+ "Minus",
52+ "Period",
53+ "Quote",
54+ "Semicolon",
55+ "Slash",
56+ "AltLeft",
57+ "AltRight",
58+ "Backspace",
59+ "CapsLock",
60+ "ContextMenu",
61+ "ControlLeft",
62+ "ControlRight",
63+ "Enter",
64+ "MetaLeft",
65+ "MetaRight",
66+ "ShiftLeft",
67+ "ShiftRight",
68+ "Space",
69+ "Tab",
70+ "Convert",
71+ "KanaMode",
72+ "NonConvert",
73+ "Delete",
74+ "End",
75+ "Help",
76+ "Home",
77+ "Insert",
78+ "PageDown",
79+ "PageUp",
80+ "ArrowDown",
81+ "ArrowLeft",
82+ "ArrowRight",
83+ "ArrowUp",
84+ "NumLock",
85+ "Numpad0",
86+ "Numpad1",
87+ "Numpad2",
88+ "Numpad3",
89+ "Numpad4",
90+ "Numpad5",
91+ "Numpad6",
92+ "Numpad7",
93+ "Numpad8",
94+ "Numpad9",
95+ "NumpadAdd",
96+ "NumpadBackspace",
97+ "NumpadClear",
98+ "NumpadClearEntry",
99+ "NumpadComma",
100+ "NumpadDecimal",
101+ "NumpadDivide",
102+ "NumpadEnter",
103+ "NumpadEqual",
104+ "NumpadMemoryAdd",
105+ "NumpadMemoryClear",
106+ "NumpadMemoryRecall",
107+ "NumpadMemoryStore",
108+ "NumpadMemorySubtract",
109+ "NumpadMultiply",
110+ "NumpadParenLeft",
111+ "NumpadParenRight",
112+ "NumpadSubtract",
113+ "NumpadSeparator",
114+ "NumpadArrowUp",
115+ "NumpadArrowDown",
116+ "NumpadArrowRight",
117+ "NumpadArrowLeft",
118+ "NumpadBegin",
119+ "NumpadHome",
120+ "NumpadEnd",
121+ "NumpadInsert",
122+ "NumpadDelete",
123+ "NumpadPageUp",
124+ "NumpadPageDown",
125+ "Escape",
126+ "F1",
127+ "F2",
128+ "F3",
129+ "F4",
130+ "F5",
131+ "F6",
132+ "F7",
133+ "F8",
134+ "F9",
135+ "F10",
136+ "F11",
137+ "F12",
138+ "F13",
139+ "F14",
140+ "F15",
141+ "F16",
142+ "F17",
143+ "F18",
144+ "F19",
145+ "F20",
146+ "F21",
147+ "F22",
148+ "F23",
149+ "F24",
150+ "F25",
151+ "Fn",
152+ "FnLock",
153+ "PrintScreen",
154+ "ScrollLock",
155+ "Pause",
156+ "BrowserBack",
157+ "BrowserFavorites",
158+ "BrowserForward",
159+ "BrowserHome",
160+ "BrowserRefresh",
161+ "BrowserSearch",
162+ "BrowserStop",
163+ "Eject",
164+ "LaunchApp1",
165+ "LaunchApp2",
166+ "LaunchMail",
167+ "MediaPlayPause",
168+ "MediaSelect",
169+ "MediaStop",
170+ "MediaTrackNext",
171+ "MediaTrackPrevious",
172+ "Power",
173+ "Sleep",
174+ "AudioVolumeDown",
175+ "AudioVolumeMute",
176+ "AudioVolumeUp",
177+ "WakeUp",
178+ "Copy",
179+ "Cut",
180+ "Paste",
181+] as const;
182+
183+const codeToGhosttyKey = new Map<string, number>(
184+ ghosttyKeyboardCodes.map((code, index) => [code, index]),
185+);
186+
187+export function ghosttyKeyForCode(code: string): number {
188+ return codeToGhosttyKey.get(code) ?? 0;
189+}
190+
191+export interface GhosttyKeyboardLayoutMap {
192+ get(code: string): string | undefined;
193+}
194+
195+const shiftedToUnshiftedCharacter = new Map<string, string>([
196+ ["!", "1"],
197+ ["@", "2"],
198+ ["#", "3"],
199+ ["$", "4"],
200+ ["%", "5"],
201+ ["^", "6"],
202+ ["&", "7"],
203+ ["*", "8"],
204+ ["(", "9"],
205+ [")", "0"],
206+ ["~", "`"],
207+ ["_", "-"],
208+ ["+", "="],
209+ ["{", "["],
210+ ["}", "]"],
211+ ["|", "\\"],
212+ [":", ";"],
213+ ['"', "'"],
214+ ["<", ","],
215+ [">", "."],
216+ ["?", "/"],
217+]);
218+
219+let keyboardLayoutMapPromise: Promise<GhosttyKeyboardLayoutMap | undefined> | undefined;
220+
221+export function loadGhosttyKeyboardLayoutMap(): Promise<GhosttyKeyboardLayoutMap | undefined> {
222+ if (keyboardLayoutMapPromise) return keyboardLayoutMapPromise;
223+ const browserNavigator = globalThis.navigator as
224+ | (Navigator & {
225+ readonly keyboard?: {
226+ getLayoutMap(): Promise<GhosttyKeyboardLayoutMap>;
227+ };
228+ })
229+ | undefined;
230+ const keyboard = browserNavigator?.keyboard;
231+ const promise = keyboard?.getLayoutMap().catch(() => undefined) ?? Promise.resolve(undefined);
232+ keyboardLayoutMapPromise = promise;
233+ return promise;
234+}
235+
236+export function ghosttyUnshiftedCodepoint(
237+ event: Pick<KeyboardEvent, "code" | "key" | "shiftKey">,
238+ layoutMap?: GhosttyKeyboardLayoutMap,
239+): number {
240+ if ([...event.key].length !== 1) return 0;
241+ const layoutCharacter = layoutMap?.get(event.code);
242+ if (layoutCharacter && [...layoutCharacter].length === 1) {
243+ return layoutCharacter.codePointAt(0) ?? 0;
244+ }
245+ if (/^[A-Z]$/u.test(event.key)) return event.key.charCodeAt(0) + 32;
246+ if (event.shiftKey) {
247+ const unshiftedCharacter = shiftedToUnshiftedCharacter.get(event.key);
248+ if (unshiftedCharacter) return unshiftedCharacter.codePointAt(0) ?? 0;
249+ const lowercase = event.key.toLowerCase();
250+ if (lowercase !== event.key && [...lowercase].length === 1) {
251+ return lowercase.codePointAt(0) ?? 0;
252+ }
253+ // Without layout data the unshifted form of a shifted key is unknowable;
254+ // reporting the shifted character as unshifted corrupts Kitty alternate keys.
255+ return 0;
256+ }
257+ return event.key.codePointAt(0) ?? 0;
258+}
apps/web/src/terminal/ghostty/renderer.test.ts+175−0
@@ -0,0 +1,175 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import { GHOSTTY_CELL_WIDE, type GhosttyCell, type GhosttySnapshot } from "./core";
4+import {
5+ ghosttyTextRunEnd,
6+ measureGhosttyCell,
7+ renderGhosttySnapshot,
8+ terminalGridSize,
9+} from "./renderer";
10+
11+const cell = (text: string, wide = 0): GhosttyCell => ({
12+ text,
13+ wide,
14+ foreground: { r: 255, g: 255, b: 255 },
15+ background: { r: 0, g: 0, b: 0 },
16+ bold: false,
17+ italic: false,
18+ invisible: false,
19+ strikethrough: false,
20+ overline: false,
21+ underline: false,
22+ selected: false,
23+});
24+
25+describe("terminalGridSize", () => {
26+ it("matches the mobile renderer's cell-and-padding sizing model", () => {
27+ expect(terminalGridSize(808, 408, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({
28+ cols: 80,
29+ rows: 20,
30+ });
31+ });
32+
33+ it("never sends an invalid zero-sized terminal to libghostty", () => {
34+ expect(terminalGridSize(0, 0, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({
35+ cols: 1,
36+ rows: 1,
37+ });
38+ });
39+});
40+
41+describe("measureGhosttyCell", () => {
42+ it("uses descender-aware metrics and the mobile terminal line-height", () => {
43+ const measureText = (text: string) =>
44+ text === "M"
45+ ? { width: 7.2, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 0 }
46+ : { width: 14.4, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 3 };
47+ const context = {
48+ font: "",
49+ measureText,
50+ } as unknown as CanvasRenderingContext2D;
51+
52+ expect(measureGhosttyCell(context, 12, "monospace")).toEqual({
53+ width: 7.2,
54+ height: 16,
55+ baseline: 11,
56+ });
57+ });
58+});
59+
60+describe("ghosttyTextRunEnd", () => {
61+ it("includes wide spacer tails in the visual clip without rendering spaces", () => {
62+ const cells = [
63+ cell("界", GHOSTTY_CELL_WIDE.wide),
64+ cell("", GHOSTTY_CELL_WIDE.spacerTail),
65+ cell("🙂", GHOSTTY_CELL_WIDE.wide),
66+ cell("", GHOSTTY_CELL_WIDE.spacerTail),
67+ cell(""),
68+ ];
69+ expect(ghosttyTextRunEnd(cells, 0, () => true)).toBe(4);
70+ });
71+});
72+
73+describe("renderGhosttySnapshot", () => {
74+ it("constrains text runs and cursor glyphs to their terminal cells", () => {
75+ const fillTextCalls: unknown[][] = [];
76+ const context = {
77+ canvas: { width: 200, height: 40 },
78+ beginPath: () => {},
79+ clip: () => {},
80+ fillRect: () => {},
81+ fillText: (...args: unknown[]) => fillTextCalls.push(args),
82+ rect: () => {},
83+ resetTransform: () => {},
84+ restore: () => {},
85+ save: () => {},
86+ set fillStyle(_value: string) {},
87+ set font(_value: string) {},
88+ set textBaseline(_value: string) {},
89+ } as unknown as CanvasRenderingContext2D;
90+ const cells = [cell("a"), cell("b"), cell("x")];
91+ const snapshot: GhosttySnapshot = {
92+ cols: 3,
93+ rows: 1,
94+ foreground: { r: 255, g: 255, b: 255 },
95+ background: { r: 0, g: 0, b: 0 },
96+ cursor: { r: 255, g: 255, b: 255 },
97+ cursorX: 2,
98+ cursorY: 0,
99+ cursorVisible: true,
100+ cursorBlinking: false,
101+ cursorStyle: 1,
102+ dirtyRows: new Set([0]),
103+ rowData: [{ cells, text: "abx", isWrapContinuation: false, wrapsToNext: false }],
104+ };
105+
106+ renderGhosttySnapshot({
107+ context,
108+ snapshot,
109+ metrics: { width: 7.2, height: 16, baseline: 11 },
110+ fontSize: 12,
111+ fontFamily: "monospace",
112+ padding: 4,
113+ forceFull: false,
114+ cursorOn: true,
115+ });
116+
117+ expect(fillTextCalls).toEqual([
118+ ["abx", 4, 15, 21.6],
119+ ["x", 18.4, 15, 7.2],
120+ ]);
121+ });
122+
123+ it("repaints the previous cursor row after the cursor moves", () => {
124+ const clearedRows: number[] = [];
125+ const context = {
126+ canvas: { width: 200, height: 80 },
127+ beginPath: () => {},
128+ clip: () => {},
129+ fillRect: (_left: number, top: number, _width: number, height: number) => {
130+ if (height === 16) clearedRows.push(top);
131+ },
132+ fillText: () => {},
133+ rect: () => {},
134+ resetTransform: () => {},
135+ restore: () => {},
136+ save: () => {},
137+ set fillStyle(_value: string) {},
138+ set font(_value: string) {},
139+ set textBaseline(_value: string) {},
140+ } as unknown as CanvasRenderingContext2D;
141+ const snapshot: GhosttySnapshot = {
142+ cols: 1,
143+ rows: 3,
144+ foreground: { r: 255, g: 255, b: 255 },
145+ background: { r: 0, g: 0, b: 0 },
146+ cursor: { r: 255, g: 255, b: 255 },
147+ cursorX: 0,
148+ cursorY: 2,
149+ cursorVisible: true,
150+ cursorBlinking: false,
151+ cursorStyle: 1,
152+ dirtyRows: new Set(),
153+ rowData: [0, 1, 2].map(() => ({
154+ cells: [cell("")],
155+ text: "",
156+ isWrapContinuation: false,
157+ wrapsToNext: false,
158+ })),
159+ };
160+
161+ renderGhosttySnapshot({
162+ context,
163+ snapshot,
164+ metrics: { width: 7.2, height: 16, baseline: 11 },
165+ fontSize: 12,
166+ fontFamily: "monospace",
167+ padding: 4,
168+ forceFull: false,
169+ cursorOn: true,
170+ previousCursorY: 0,
171+ });
172+
173+ expect(clearedRows).toEqual([4, 36, 36]);
174+ });
175+});
apps/web/src/terminal/ghostty/renderer.ts+255−0
@@ -0,0 +1,255 @@
1+import {
2+ GHOSTTY_CELL_WIDE,
3+ ghosttyColorsEqual,
4+ type GhosttyCell,
5+ type GhosttyColor,
6+ type GhosttySnapshot,
7+} from "./core";
8+
9+export interface GhosttyCellMetrics {
10+ readonly width: number;
11+ readonly height: number;
12+ readonly baseline: number;
13+}
14+
15+const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)";
16+
17+function cssColor(color: GhosttyColor): string {
18+ return `rgb(${color.r}, ${color.g}, ${color.b})`;
19+}
20+
21+function sameTextStyle(left: GhosttyCell, right: GhosttyCell): boolean {
22+ // Selection deliberately does not participate: it only tints the background
23+ // overlay, and splitting a text run at a selection boundary visibly shifts
24+ // glyph spacing whenever the face's true advance differs from the cell width.
25+ return (
26+ ghosttyColorsEqual(left.foreground, right.foreground) &&
27+ left.bold === right.bold &&
28+ left.italic === right.italic &&
29+ left.invisible === right.invisible
30+ );
31+}
32+
33+export function ghosttyTextRunEnd(
34+ cells: readonly GhosttyCell[],
35+ start: number,
36+ sameStyle: (cell: GhosttyCell) => boolean,
37+): number {
38+ let end = start + 1;
39+ while (end < cells.length) {
40+ const next = cells[end];
41+ if (!next) break;
42+ if (next.wide === GHOSTTY_CELL_WIDE.spacerTail) {
43+ end += 1;
44+ continue;
45+ }
46+ if (next.text.length === 0 || !sameStyle(next)) break;
47+ end += 1;
48+ }
49+ return end;
50+}
51+
52+function fontForCell(cell: GhosttyCell, fontSize: number, fontFamily: string): string {
53+ const style = cell.italic ? "italic" : "normal";
54+ const weight = cell.bold ? "700" : "400";
55+ return `${style} ${weight} ${fontSize}px ${fontFamily}`;
56+}
57+
58+export function measureGhosttyCell(
59+ context: CanvasRenderingContext2D,
60+ fontSize: number,
61+ fontFamily: string,
62+): GhosttyCellMetrics {
63+ context.font = `normal 400 ${fontSize}px ${fontFamily}`;
64+ const widthMeasurement = context.measureText("M");
65+ const verticalMeasurement = context.measureText("Mg");
66+ const ascent = verticalMeasurement.actualBoundingBoxAscent || fontSize;
67+ const descent = verticalMeasurement.actualBoundingBoxDescent;
68+ const glyphHeight = ascent + descent;
69+ const height = Math.max(1, Math.round(fontSize * 1.35), Math.ceil(glyphHeight));
70+ return {
71+ width: Math.max(1, widthMeasurement.width),
72+ height,
73+ baseline: Math.round((height - glyphHeight) / 2 + ascent),
74+ };
75+}
76+
77+export function terminalGridSize(
78+ width: number,
79+ height: number,
80+ metrics: GhosttyCellMetrics,
81+ padding: number,
82+): { cols: number; rows: number } {
83+ return {
84+ cols: Math.max(1, Math.floor((width - padding * 2) / metrics.width)),
85+ rows: Math.max(1, Math.floor((height - padding * 2) / metrics.height)),
86+ };
87+}
88+
89+export function renderGhosttySnapshot(options: {
90+ readonly context: CanvasRenderingContext2D;
91+ readonly snapshot: GhosttySnapshot;
92+ readonly metrics: GhosttyCellMetrics;
93+ readonly fontSize: number;
94+ readonly fontFamily: string;
95+ readonly padding: number;
96+ readonly forceFull: boolean;
97+ readonly cursorOn: boolean;
98+ readonly previousCursorY?: number | null;
99+ readonly focused?: boolean;
100+ readonly selectionBackground?: string;
101+ /** Vertical origin of row 0; defaults to the horizontal padding. */
102+ readonly originY?: number;
103+}): void {
104+ const {
105+ context,
106+ snapshot,
107+ metrics,
108+ fontSize,
109+ fontFamily,
110+ padding,
111+ forceFull,
112+ cursorOn,
113+ previousCursorY,
114+ } = options;
115+ const focused = options.focused ?? true;
116+ const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND;
117+ const originY = options.originY ?? padding;
118+ const rowsToDraw = forceFull
119+ ? Array.from({ length: snapshot.rows }, (_, index) => index)
120+ : [...snapshot.dirtyRows];
121+ if (
122+ previousCursorY !== null &&
123+ previousCursorY !== undefined &&
124+ previousCursorY >= 0 &&
125+ !rowsToDraw.includes(previousCursorY)
126+ ) {
127+ rowsToDraw.push(previousCursorY);
128+ }
129+ if (snapshot.cursorVisible && snapshot.cursorY >= 0 && !rowsToDraw.includes(snapshot.cursorY)) {
130+ rowsToDraw.push(snapshot.cursorY);
131+ }
132+
133+ if (forceFull) {
134+ context.save();
135+ context.resetTransform();
136+ context.fillStyle = cssColor(snapshot.background);
137+ context.fillRect(0, 0, context.canvas.width, context.canvas.height);
138+ context.restore();
139+ }
140+
141+ context.textBaseline = "alphabetic";
142+ for (const rowIndex of rowsToDraw) {
143+ const row = snapshot.rowData[rowIndex];
144+ if (!row) continue;
145+ const top = originY + rowIndex * metrics.height;
146+
147+ context.fillStyle = cssColor(snapshot.background);
148+ context.fillRect(padding, top, snapshot.cols * metrics.width, metrics.height);
149+
150+ let backgroundStart = 0;
151+ while (backgroundStart < row.cells.length) {
152+ const first = row.cells[backgroundStart];
153+ if (!first) break;
154+ let backgroundEnd = backgroundStart + 1;
155+ while (backgroundEnd < row.cells.length) {
156+ const next = row.cells[backgroundEnd];
157+ if (
158+ !next ||
159+ next.selected !== first.selected ||
160+ !ghosttyColorsEqual(next.background, first.background)
161+ ) {
162+ break;
163+ }
164+ backgroundEnd += 1;
165+ }
166+ if (first.selected || !ghosttyColorsEqual(first.background, snapshot.background)) {
167+ const left = padding + backgroundStart * metrics.width;
168+ const width = (backgroundEnd - backgroundStart) * metrics.width;
169+ if (!ghosttyColorsEqual(first.background, snapshot.background)) {
170+ context.fillStyle = cssColor(first.background);
171+ context.fillRect(left, top, width, metrics.height);
172+ }
173+ if (first.selected) {
174+ context.fillStyle = selectionBackground;
175+ context.fillRect(left, top, width, metrics.height);
176+ }
177+ }
178+ backgroundStart = backgroundEnd;
179+ }
180+
181+ let runStart = 0;
182+ while (runStart < row.cells.length) {
183+ const first = row.cells[runStart];
184+ if (!first) break;
185+ if (first.text.length === 0) {
186+ runStart += 1;
187+ continue;
188+ }
189+ const runEnd = ghosttyTextRunEnd(row.cells, runStart, (cell) => sameTextStyle(cell, first));
190+ const text = row.cells
191+ .slice(runStart, runEnd)
192+ .map((cell) => cell.text)
193+ .join("");
194+ if (!first.invisible && text.trim().length > 0) {
195+ context.save();
196+ context.beginPath();
197+ context.rect(
198+ padding + runStart * metrics.width,
199+ top,
200+ (runEnd - runStart) * metrics.width,
201+ metrics.height,
202+ );
203+ context.clip();
204+ context.font = fontForCell(first, fontSize, fontFamily);
205+ context.fillStyle = cssColor(first.foreground);
206+ context.fillText(
207+ text,
208+ padding + runStart * metrics.width,
209+ top + metrics.baseline,
210+ (runEnd - runStart) * metrics.width,
211+ );
212+ context.restore();
213+ }
214+ runStart = runEnd;
215+ }
216+
217+ for (let column = 0; column < row.cells.length; column += 1) {
218+ const cell = row.cells[column];
219+ if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline)) continue;
220+ context.fillStyle = cssColor(cell.foreground);
221+ const left = padding + column * metrics.width;
222+ if (cell.underline) context.fillRect(left, top + metrics.height - 2, metrics.width, 1);
223+ if (cell.strikethrough) {
224+ context.fillRect(left, top + Math.floor(metrics.height * 0.55), metrics.width, 1);
225+ }
226+ if (cell.overline) context.fillRect(left, top + 1, metrics.width, 1);
227+ }
228+ }
229+
230+ if (cursorOn && snapshot.cursorVisible && snapshot.cursorX >= 0 && snapshot.cursorY >= 0) {
231+ const left = padding + snapshot.cursorX * metrics.width;
232+ const top = originY + snapshot.cursorY * metrics.height;
233+ context.fillStyle = cssColor(snapshot.cursor);
234+ if (!focused) {
235+ // An unfocused terminal draws a hollow cursor so the active pane is obvious.
236+ context.strokeStyle = cssColor(snapshot.cursor);
237+ context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1);
238+ } else if (snapshot.cursorStyle === 0) {
239+ context.fillRect(left, top, 2, metrics.height);
240+ } else if (snapshot.cursorStyle === 2) {
241+ context.fillRect(left, top + metrics.height - 2, metrics.width, 2);
242+ } else if (snapshot.cursorStyle === 3) {
243+ context.strokeStyle = cssColor(snapshot.cursor);
244+ context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1);
245+ } else {
246+ context.fillRect(left, top, metrics.width, metrics.height);
247+ const cell = snapshot.rowData[snapshot.cursorY]?.cells[snapshot.cursorX];
248+ if (cell?.text) {
249+ context.font = fontForCell(cell, fontSize, fontFamily);
250+ context.fillStyle = cssColor(snapshot.background);
251+ context.fillText(cell.text, left, top + metrics.baseline, metrics.width);
252+ }
253+ }
254+ }
255+}
apps/web/src/terminal/ghostty/runtime.ts+221−0
@@ -0,0 +1,221 @@
1+import ghosttyWasmUrl from "./vendor/ghostty-vt.wasm?url";
2+import ghosttyWritePtyWasmUrl from "./vendor/ghostty-write-pty.wasm?url&no-inline";
3+
4+type WasmFunction = (...args: Array<number | bigint>) => number;
5+
6+interface TypeField {
7+ readonly offset: number;
8+ readonly size: number;
9+ readonly type: string;
10+}
11+
12+interface TypeLayout {
13+ readonly size: number;
14+ readonly align: number;
15+ readonly fields: Readonly<Record<string, TypeField>>;
16+}
17+
18+type TypeLayouts = Readonly<Record<string, TypeLayout>>;
19+
20+const textDecoder = new TextDecoder();
21+
22+export class GhosttyRuntime {
23+ readonly memory: WebAssembly.Memory;
24+ readonly layouts: TypeLayouts;
25+ private readonly exports: WebAssembly.Exports;
26+ private readonly ptyWriters = new Map<number, (data: string) => void>();
27+ private nextPtyWriterId = 1;
28+ private writePtyFunctionIndex = 0;
29+
30+ private constructor(instance: WebAssembly.Instance) {
31+ this.exports = instance.exports;
32+ const memory = instance.exports.memory;
33+ if (!(memory instanceof WebAssembly.Memory)) {
34+ throw new Error("libghostty-vt did not export WebAssembly memory");
35+ }
36+ this.memory = memory;
37+ const jsonPointer = this.call("ghostty_type_json");
38+ const bytes = new Uint8Array(memory.buffer);
39+ let end = jsonPointer;
40+ while (end < bytes.length && bytes[end] !== 0) end += 1;
41+ this.layouts = JSON.parse(textDecoder.decode(bytes.subarray(jsonPointer, end))) as TypeLayouts;
42+ }
43+
44+ static async load(): Promise<GhosttyRuntime> {
45+ const response = await fetch(ghosttyWasmUrl);
46+ if (!response.ok) {
47+ throw new Error(`Unable to load libghostty-vt (${response.status})`);
48+ }
49+ let instance: WebAssembly.Instance | undefined;
50+ const imports = {
51+ env: {
52+ log: (pointer: number, length: number) => {
53+ if (!instance) return;
54+ const memory = instance.exports.memory;
55+ if (!(memory instanceof WebAssembly.Memory)) return;
56+ const message = textDecoder.decode(new Uint8Array(memory.buffer, pointer, length));
57+ console.debug("[libghostty-vt]", message);
58+ },
59+ },
60+ };
61+ const result = await WebAssembly.instantiate(await response.arrayBuffer(), imports);
62+ instance = result.instance;
63+ const runtime = new GhosttyRuntime(result.instance);
64+ await runtime.installWritePtyTrampoline();
65+ return runtime;
66+ }
67+
68+ call(name: string, ...args: Array<number | bigint>): number {
69+ const fn = this.exports[name];
70+ if (typeof fn !== "function") {
71+ throw new Error(`libghostty-vt export is unavailable: ${name}`);
72+ }
73+ return (fn as WasmFunction)(...args);
74+ }
75+
76+ layout(name: string): TypeLayout {
77+ const layout = this.layouts[name];
78+ if (!layout) throw new Error(`libghostty-vt type layout is unavailable: ${name}`);
79+ return layout;
80+ }
81+
82+ alloc(size: number): number {
83+ const pointer = this.call("ghostty_wasm_alloc_u8_array", size);
84+ if (pointer === 0) throw new Error(`libghostty-vt failed to allocate ${size} bytes`);
85+ new Uint8Array(this.memory.buffer, pointer, size).fill(0);
86+ return pointer;
87+ }
88+
89+ free(pointer: number, size: number): void {
90+ if (pointer !== 0) this.call("ghostty_wasm_free_u8_array", pointer, size);
91+ }
92+
93+ allocOpaque(): number {
94+ const pointer = this.call("ghostty_wasm_alloc_opaque");
95+ if (pointer === 0) throw new Error("libghostty-vt failed to allocate an opaque pointer");
96+ // The slot is uninitialized until a *_new call writes it; zero it so dispose
97+ // paths that run after a partial initialization never free a garbage pointer.
98+ new DataView(this.memory.buffer).setUint32(pointer, 0, true);
99+ return pointer;
100+ }
101+
102+ freeOpaque(pointer: number): void {
103+ if (pointer !== 0) this.call("ghostty_wasm_free_opaque", pointer);
104+ }
105+
106+ readPointer(slot: number): number {
107+ return new DataView(this.memory.buffer).getUint32(slot, true);
108+ }
109+
110+ attachPtyWriter(terminal: number, writer: (data: string) => void): number {
111+ if (this.writePtyFunctionIndex === 0) {
112+ throw new Error("libghostty-vt PTY callback trampoline is unavailable");
113+ }
114+ const id = this.nextPtyWriterId++;
115+ this.ptyWriters.set(id, writer);
116+ this.call("ghostty_terminal_set", terminal, 0, id);
117+ this.call("ghostty_terminal_set", terminal, 1, this.writePtyFunctionIndex);
118+ return id;
119+ }
120+
121+ detachPtyWriter(terminal: number, id: number): void {
122+ this.call("ghostty_terminal_set", terminal, 1, 0);
123+ this.call("ghostty_terminal_set", terminal, 0, 0);
124+ this.ptyWriters.delete(id);
125+ }
126+
127+ view(pointer: number, size?: number): DataView {
128+ return new DataView(this.memory.buffer, pointer, size);
129+ }
130+
131+ bytes(pointer: number, size: number): Uint8Array {
132+ return new Uint8Array(this.memory.buffer, pointer, size);
133+ }
134+
135+ setField(pointer: number, structName: string, fieldName: string, value: number): void {
136+ const field = this.layout(structName).fields[fieldName];
137+ if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`);
138+ const view = this.view(pointer + field.offset, field.size);
139+ switch (field.type) {
140+ case "bool":
141+ case "u8":
142+ view.setUint8(0, value);
143+ return;
144+ case "u16":
145+ view.setUint16(0, value, true);
146+ return;
147+ case "i32":
148+ view.setInt32(0, value, true);
149+ return;
150+ case "u32":
151+ case "enum":
152+ view.setUint32(0, value, true);
153+ return;
154+ case "u64":
155+ view.setBigUint64(0, BigInt(value), true);
156+ return;
157+ default:
158+ throw new Error(`Unsupported libghostty-vt field type: ${field.type}`);
159+ }
160+ }
161+
162+ readField(pointer: number, structName: string, fieldName: string): number {
163+ const field = this.layout(structName).fields[fieldName];
164+ if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`);
165+ const view = this.view(pointer + field.offset, field.size);
166+ switch (field.type) {
167+ case "bool":
168+ case "u8":
169+ return view.getUint8(0);
170+ case "u16":
171+ return view.getUint16(0, true);
172+ case "i32":
173+ return view.getInt32(0, true);
174+ case "u32":
175+ case "enum":
176+ return view.getUint32(0, true);
177+ case "u64":
178+ return Number(view.getBigUint64(0, true));
179+ default:
180+ throw new Error(`Unsupported libghostty-vt field type: ${field.type}`);
181+ }
182+ }
183+
184+ private async installWritePtyTrampoline(): Promise<void> {
185+ const response = await fetch(ghosttyWritePtyWasmUrl);
186+ if (!response.ok) {
187+ throw new Error(`Unable to load the libghostty-vt PTY trampoline (${response.status})`);
188+ }
189+ const result = await WebAssembly.instantiate(await response.arrayBuffer(), {
190+ env: {
191+ t3_write_pty: (_terminal: number, userdata: number, pointer: number, length: number) => {
192+ const writer = this.ptyWriters.get(userdata);
193+ if (!writer || length === 0) return;
194+ writer(textDecoder.decode(new Uint8Array(this.memory.buffer, pointer, length)));
195+ },
196+ },
197+ });
198+ const trampoline = result.instance.exports.ghostty_write_pty;
199+ const table = this.exports.__indirect_function_table;
200+ if (typeof trampoline !== "function" || !(table instanceof WebAssembly.Table)) {
201+ throw new Error("libghostty-vt did not expose its callback table");
202+ }
203+ const index = table.length;
204+ // grow-then-set instead of grow(1, fn): WebKit stores a grow init value
205+ // with broken type information and every later call_indirect through the
206+ // entry traps with a signature mismatch. table.set canonicalizes correctly.
207+ table.grow(1);
208+ table.set(index, trampoline);
209+ this.writePtyFunctionIndex = index;
210+ }
211+}
212+
213+let runtimePromise: Promise<GhosttyRuntime> | null = null;
214+
215+export function loadGhosttyRuntime(): Promise<GhosttyRuntime> {
216+ runtimePromise ??= GhosttyRuntime.load().catch((error) => {
217+ runtimePromise = null;
218+ throw error;
219+ });
220+ return runtimePromise;
221+}
apps/web/src/terminal/ghostty/runtimeAbi.test.ts+561−0
@@ -0,0 +1,561 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import wasmDataUrl from "./vendor/ghostty-vt.wasm?inline";
4+import writePtyWasmDataUrl from "./vendor/ghostty-write-pty.wasm?inline";
5+import mobileVersion from "../../../../mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION?raw";
6+import { ghosttyKeyForCode } from "./keyCodes";
7+
8+type WasmFunction = (...args: number[]) => number;
9+
10+function decodeWasmDataUrl(dataUrl: string): Uint8Array {
11+ const encoded = dataUrl.split(",", 2)[1];
12+ if (!encoded) throw new Error("The vendored Ghostty WASM data URL is invalid");
13+ return Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0));
14+}
15+
16+describe("vendored libghostty-vt WebAssembly", () => {
17+ it("stays pinned to mobile's canonical revision and size budget", async () => {
18+ const wasm = decodeWasmDataUrl(wasmDataUrl);
19+ expect(wasm.byteLength).toBeLessThan(750_000);
20+
21+ // The artifact carries its own provenance: the build embeds the pinned
22+ // revision as semver build metadata, so mobile's VERSION file is the
23+ // single source of truth and drift is caught here without a copy.
24+ const result = await WebAssembly.instantiate(wasm.buffer as ArrayBuffer, {
25+ env: { log: () => {} },
26+ });
27+ const instance = result instanceof WebAssembly.Instance ? result : result.instance;
28+ const memory = instance.exports.memory as WebAssembly.Memory;
29+ const call = (name: string, ...args: number[]) =>
30+ (instance.exports[name] as WasmFunction)(...args);
31+ const out = call("ghostty_wasm_alloc_u8_array", 8);
32+ expect(call("ghostty_build_info", 10, out)).toBe(0);
33+ const view = new DataView(memory.buffer, out, 8);
34+ const embeddedRevision = new TextDecoder().decode(
35+ new Uint8Array(memory.buffer, view.getUint32(0, true), view.getUint32(4, true)),
36+ );
37+ call("ghostty_wasm_free_u8_array", out, 8);
38+ expect(embeddedRevision).toBe(mobileVersion.trim());
39+ });
40+
41+ it("creates, writes multi-codepoint graphemes, and frees repeated terminals", async () => {
42+ const bytes = decodeWasmDataUrl(wasmDataUrl);
43+ let memory: WebAssembly.Memory | null = null;
44+ const instantiated = await WebAssembly.instantiate(bytes.buffer as ArrayBuffer, {
45+ env: {
46+ log: () => {},
47+ },
48+ });
49+ const instance =
50+ instantiated instanceof WebAssembly.Instance ? instantiated : instantiated.instance;
51+ const exports = instance.exports;
52+ memory = exports.memory as WebAssembly.Memory;
53+ const call = (name: string, ...args: number[]) => (exports[name] as WasmFunction)(...args);
54+ const jsonPointer = call("ghostty_type_json");
55+ const jsonBytes = new Uint8Array(memory.buffer, jsonPointer);
56+ const jsonEnd = jsonBytes.indexOf(0);
57+ const layouts = JSON.parse(new TextDecoder().decode(jsonBytes.subarray(0, jsonEnd))) as Record<
58+ string,
59+ { size: number }
60+ >;
61+ const optionsSize = layouts.GhosttyTerminalOptions?.size;
62+ expect(optionsSize).toBe(8);
63+ if (optionsSize === undefined) throw new Error("GhosttyTerminalOptions layout is missing");
64+
65+ for (let iteration = 0; iteration < 25; iteration += 1) {
66+ const options = call("ghostty_wasm_alloc_u8_array", optionsSize);
67+ const optionsView = new DataView(memory.buffer, options, optionsSize);
68+ optionsView.setUint16(0, 80, true);
69+ optionsView.setUint16(2, 24, true);
70+ optionsView.setUint32(4, 5_000, true);
71+ const terminalSlot = call("ghostty_wasm_alloc_opaque");
72+ expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0);
73+ const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true);
74+
75+ const input = new TextEncoder().encode("e\u0301 👨‍👩‍👧‍👦 العربية\r\n");
76+ const inputPointer = call("ghostty_wasm_alloc_u8_array", input.length);
77+ new Uint8Array(memory.buffer, inputPointer, input.length).set(input);
78+ call("ghostty_terminal_vt_write", terminal, inputPointer, input.length);
79+ call("ghostty_wasm_free_u8_array", inputPointer, input.length);
80+ call("ghostty_terminal_free", terminal);
81+ call("ghostty_wasm_free_opaque", terminalSlot);
82+ call("ghostty_wasm_free_u8_array", options, optionsSize);
83+ }
84+ });
85+
86+ it("reports and scrolls the viewport with Ghostty's scrollbar state", async () => {
87+ const result = await WebAssembly.instantiate(
88+ decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
89+ { env: { log: () => {} } },
90+ );
91+ const instance = result instanceof WebAssembly.Instance ? result : result.instance;
92+ const memory = instance.exports.memory as WebAssembly.Memory;
93+ const call = (name: string, ...args: number[]) =>
94+ (instance.exports[name] as WasmFunction)(...args);
95+ const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size);
96+ const options = alloc(8);
97+ const optionsView = new DataView(memory.buffer, options, 8);
98+ optionsView.setUint16(0, 80, true);
99+ optionsView.setUint16(2, 10, true);
100+ optionsView.setUint32(4, 1_000, true);
101+ const terminalSlot = call("ghostty_wasm_alloc_opaque");
102+ expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0);
103+ const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true);
104+ const input = new TextEncoder().encode(
105+ Array.from({ length: 50 }, (_, index) => `${index + 1}\r\n`).join(""),
106+ );
107+ const inputPointer = alloc(input.length);
108+ new Uint8Array(memory.buffer, inputPointer, input.length).set(input);
109+ call("ghostty_terminal_vt_write", terminal, inputPointer, input.length);
110+
111+ const scrollbar = alloc(24);
112+ const scrollbarView = new DataView(memory.buffer, scrollbar, 24);
113+ expect(call("ghostty_terminal_get", terminal, 9, scrollbar)).toBe(0);
114+ expect([0, 8, 16].map((offset) => Number(scrollbarView.getBigUint64(offset, true)))).toEqual([
115+ 51, 41, 10,
116+ ]);
117+
118+ const scroll = alloc(24);
119+ const scrollView = new DataView(memory.buffer, scroll, 24);
120+ scrollView.setUint32(0, 2, true);
121+ scrollView.setInt32(8, -5, true);
122+ call("ghostty_terminal_scroll_viewport", terminal, scroll);
123+ expect(call("ghostty_terminal_get", terminal, 9, scrollbar)).toBe(0);
124+ expect(Number(scrollbarView.getBigUint64(8, true))).toBe(36);
125+
126+ call("ghostty_wasm_free_u8_array", scroll, 24);
127+ call("ghostty_wasm_free_u8_array", scrollbar, 24);
128+ call("ghostty_wasm_free_u8_array", inputPointer, input.length);
129+ call("ghostty_terminal_free", terminal);
130+ call("ghostty_wasm_free_opaque", terminalSlot);
131+ call("ghostty_wasm_free_u8_array", options, 8);
132+ });
133+
134+ it("routes terminal-generated replies through the shared callback table", async () => {
135+ const mainResult = await WebAssembly.instantiate(
136+ decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
137+ { env: { log: () => {} } },
138+ );
139+ const main = mainResult instanceof WebAssembly.Instance ? mainResult : mainResult.instance;
140+ const memory = main.exports.memory as WebAssembly.Memory;
141+ let reply = "";
142+ const trampolineResult = await WebAssembly.instantiate(
143+ decodeWasmDataUrl(writePtyWasmDataUrl).buffer as ArrayBuffer,
144+ {
145+ env: {
146+ t3_write_pty: (_terminal: number, _userdata: number, pointer: number, length: number) => {
147+ reply += new TextDecoder().decode(new Uint8Array(memory.buffer, pointer, length));
148+ },
149+ },
150+ },
151+ );
152+ const trampoline =
153+ trampolineResult instanceof WebAssembly.Instance
154+ ? trampolineResult
155+ : trampolineResult.instance;
156+ const table = main.exports.__indirect_function_table as WebAssembly.Table;
157+ const callbackIndex = table.length;
158+ table.grow(1, trampoline.exports.ghostty_write_pty as CallableFunction);
159+ const call = (name: string, ...args: number[]) => (main.exports[name] as WasmFunction)(...args);
160+ const options = call("ghostty_wasm_alloc_u8_array", 8);
161+ const optionsView = new DataView(memory.buffer, options, 8);
162+ optionsView.setUint16(0, 80, true);
163+ optionsView.setUint16(2, 24, true);
164+ const terminalSlot = call("ghostty_wasm_alloc_opaque");
165+ expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0);
166+ const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true);
167+ call("ghostty_terminal_set", terminal, 0, 1);
168+ call("ghostty_terminal_set", terminal, 1, callbackIndex);
169+ const query = new TextEncoder().encode("\u001b[5n");
170+ const queryPointer = call("ghostty_wasm_alloc_u8_array", query.length);
171+ new Uint8Array(memory.buffer, queryPointer, query.length).set(query);
172+ call("ghostty_terminal_vt_write", terminal, queryPointer, query.length);
173+
174+ expect(reply).toBe("\u001b[0n");
175+ reply = "";
176+ call("ghostty_terminal_set", terminal, 1, 0);
177+ call("ghostty_terminal_set", terminal, 0, 0);
178+ call("ghostty_terminal_vt_write", terminal, queryPointer, query.length);
179+ expect(reply).toBe("");
180+ call("ghostty_terminal_set", terminal, 0, 1);
181+ call("ghostty_terminal_set", terminal, 1, callbackIndex);
182+ call("ghostty_terminal_vt_write", terminal, queryPointer, query.length);
183+ expect(reply).toBe("\u001b[0n");
184+ call("ghostty_wasm_free_u8_array", queryPointer, query.length);
185+ call("ghostty_terminal_free", terminal);
186+ call("ghostty_wasm_free_opaque", terminalSlot);
187+ call("ghostty_wasm_free_u8_array", options, 8);
188+ });
189+
190+ it("formats the active selection with Ghostty's copy semantics", async () => {
191+ const result = await WebAssembly.instantiate(
192+ decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
193+ { env: { log: () => {} } },
194+ );
195+ const instance = result instanceof WebAssembly.Instance ? result : result.instance;
196+ const memory = instance.exports.memory as WebAssembly.Memory;
197+ const call = (name: string, ...args: number[]) =>
198+ (instance.exports[name] as WasmFunction)(...args);
199+ const options = call("ghostty_wasm_alloc_u8_array", 8);
200+ const optionsView = new DataView(memory.buffer, options, 8);
201+ optionsView.setUint16(0, 80, true);
202+ optionsView.setUint16(2, 24, true);
203+ const terminalSlot = call("ghostty_wasm_alloc_opaque");
204+ expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0);
205+ const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true);
206+ const input = new TextEncoder().encode("a\r\n\r\nb");
207+ const inputPointer = call("ghostty_wasm_alloc_u8_array", input.length);
208+ new Uint8Array(memory.buffer, inputPointer, input.length).set(input);
209+ call("ghostty_terminal_vt_write", terminal, inputPointer, input.length);
210+
211+ const selection = call("ghostty_wasm_alloc_u8_array", 32);
212+ new DataView(memory.buffer, selection, 32).setUint32(0, 32, true);
213+ expect(call("ghostty_terminal_select_all", terminal, selection)).toBe(0);
214+ expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0);
215+ const formatOptions = call("ghostty_wasm_alloc_u8_array", 16);
216+ const formatView = new DataView(memory.buffer, formatOptions, 16);
217+ formatView.setUint32(0, 16, true);
218+ formatView.setUint8(8, 1);
219+ formatView.setUint8(9, 1);
220+ const written = call("ghostty_wasm_alloc_usize");
221+ expect(
222+ call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written),
223+ ).toBe(-3);
224+ const outputSize = new DataView(memory.buffer, written, 4).getUint32(0, true);
225+ const output = call("ghostty_wasm_alloc_u8_array", outputSize);
226+ expect(
227+ call(
228+ "ghostty_terminal_selection_format_buf",
229+ terminal,
230+ formatOptions,
231+ output,
232+ outputSize,
233+ written,
234+ ),
235+ ).toBe(0);
236+ expect(new TextDecoder().decode(new Uint8Array(memory.buffer, output, outputSize))).toBe(
237+ "a\n\nb",
238+ );
239+
240+ call("ghostty_wasm_free_u8_array", output, outputSize);
241+ call("ghostty_wasm_free_usize", written);
242+ call("ghostty_wasm_free_u8_array", formatOptions, 16);
243+ call("ghostty_wasm_free_u8_array", selection, 32);
244+ call("ghostty_wasm_free_u8_array", inputPointer, input.length);
245+ call("ghostty_terminal_free", terminal);
246+ call("ghostty_wasm_free_opaque", terminalSlot);
247+ call("ghostty_wasm_free_u8_array", options, 8);
248+ });
249+
250+ it("uses Ghostty for mouse encoding, word selection, and OSC 8 hit testing", async () => {
251+ const result = await WebAssembly.instantiate(
252+ decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
253+ { env: { log: () => {} } },
254+ );
255+ const instance = result instanceof WebAssembly.Instance ? result : result.instance;
256+ const memory = instance.exports.memory as WebAssembly.Memory;
257+ const call = (name: string, ...args: number[]) =>
258+ (instance.exports[name] as WasmFunction)(...args);
259+ const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size);
260+ const free = (pointer: number, size: number) =>
261+ call("ghostty_wasm_free_u8_array", pointer, size);
262+
263+ const terminalOptions = alloc(8);
264+ const terminalOptionsView = new DataView(memory.buffer, terminalOptions, 8);
265+ terminalOptionsView.setUint16(0, 80, true);
266+ terminalOptionsView.setUint16(2, 24, true);
267+ const terminalSlot = call("ghostty_wasm_alloc_opaque");
268+ expect(call("ghostty_terminal_new", 0, terminalSlot, terminalOptions)).toBe(0);
269+ const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true);
270+ const input = new TextEncoder().encode(
271+ "\u001b[?1000h\u001b[?1006h\u001b]8;;https://t3.codes/docs\u001b\\linked\u001b]8;;\u001b\\ plain",
272+ );
273+ const inputPointer = alloc(input.length);
274+ new Uint8Array(memory.buffer, inputPointer, input.length).set(input);
275+ call("ghostty_terminal_vt_write", terminal, inputPointer, input.length);
276+
277+ const modeFlag = alloc(1);
278+ new Uint8Array(memory.buffer, modeFlag, 1)[0] = 0;
279+ expect(call("ghostty_terminal_get", terminal, 11, modeFlag)).toBe(0);
280+ expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(1);
281+ new Uint8Array(memory.buffer, modeFlag, 1)[0] = 1;
282+ expect(call("ghostty_terminal_mode_get", terminal, 1003, modeFlag)).toBe(0);
283+ expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(0);
284+ const anyEventInput = new TextEncoder().encode("\u001b[?1003h");
285+ const anyEventPointer = alloc(anyEventInput.length);
286+ new Uint8Array(memory.buffer, anyEventPointer, anyEventInput.length).set(anyEventInput);
287+ call("ghostty_terminal_vt_write", terminal, anyEventPointer, anyEventInput.length);
288+ expect(call("ghostty_terminal_mode_get", terminal, 1003, modeFlag)).toBe(0);
289+ expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(1);
290+ const anyEventReset = new TextEncoder().encode("\u001b[?1003l\u001b[?1000h");
291+ const anyEventResetPointer = alloc(anyEventReset.length);
292+ new Uint8Array(memory.buffer, anyEventResetPointer, anyEventReset.length).set(anyEventReset);
293+ call("ghostty_terminal_vt_write", terminal, anyEventResetPointer, anyEventReset.length);
294+ expect(call("ghostty_terminal_mode_get", terminal, 1003, modeFlag)).toBe(0);
295+ expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(0);
296+ free(anyEventResetPointer, anyEventReset.length);
297+ free(anyEventPointer, anyEventInput.length);
298+ free(modeFlag, 1);
299+
300+ const point = alloc(24);
301+ const pointView = new DataView(memory.buffer, point, 24);
302+ pointView.setUint32(0, 1, true);
303+ pointView.setUint16(8, 1, true);
304+ pointView.setUint32(12, 0, true);
305+ const gridRef = alloc(12);
306+ new DataView(memory.buffer, gridRef, 12).setUint32(0, 12, true);
307+ expect(call("ghostty_terminal_grid_ref", terminal, point, gridRef)).toBe(0);
308+
309+ const written = call("ghostty_wasm_alloc_usize");
310+ expect(call("ghostty_grid_ref_hyperlink_uri", gridRef, 0, 0, written)).toBe(-3);
311+ const hyperlinkSize = new DataView(memory.buffer, written, 4).getUint32(0, true);
312+ const hyperlink = alloc(hyperlinkSize);
313+ expect(call("ghostty_grid_ref_hyperlink_uri", gridRef, hyperlink, hyperlinkSize, written)).toBe(
314+ 0,
315+ );
316+ expect(new TextDecoder().decode(new Uint8Array(memory.buffer, hyperlink, hyperlinkSize))).toBe(
317+ "https://t3.codes/docs",
318+ );
319+
320+ const wordOptions = alloc(24);
321+ const wordOptionsView = new DataView(memory.buffer, wordOptions, 24);
322+ wordOptionsView.setUint32(0, 24, true);
323+ new Uint8Array(memory.buffer, wordOptions + 4, 12).set(
324+ new Uint8Array(memory.buffer, gridRef, 12),
325+ );
326+ const selection = alloc(32);
327+ new DataView(memory.buffer, selection, 32).setUint32(0, 32, true);
328+ expect(call("ghostty_terminal_select_word", terminal, wordOptions, selection)).toBe(0);
329+ expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0);
330+ const formatOptions = alloc(16);
331+ const formatView = new DataView(memory.buffer, formatOptions, 16);
332+ formatView.setUint32(0, 16, true);
333+ formatView.setUint8(8, 1);
334+ formatView.setUint8(9, 1);
335+ expect(
336+ call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written),
337+ ).toBe(-3);
338+ const selectionSize = new DataView(memory.buffer, written, 4).getUint32(0, true);
339+ const selectionText = alloc(selectionSize);
340+ expect(
341+ call(
342+ "ghostty_terminal_selection_format_buf",
343+ terminal,
344+ formatOptions,
345+ selectionText,
346+ selectionSize,
347+ written,
348+ ),
349+ ).toBe(0);
350+ expect(
351+ new TextDecoder().decode(new Uint8Array(memory.buffer, selectionText, selectionSize)),
352+ ).toBe("linked");
353+
354+ const lineOptions = alloc(28);
355+ const lineOptionsView = new DataView(memory.buffer, lineOptions, 28);
356+ lineOptionsView.setUint32(0, 28, true);
357+ new Uint8Array(memory.buffer, lineOptions + 4, 12).set(
358+ new Uint8Array(memory.buffer, gridRef, 12),
359+ );
360+ expect(call("ghostty_terminal_select_line", terminal, lineOptions, selection)).toBe(0);
361+ expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0);
362+ expect(
363+ call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written),
364+ ).toBe(-3);
365+ const lineSelectionSize = new DataView(memory.buffer, written, 4).getUint32(0, true);
366+ const lineSelectionText = alloc(lineSelectionSize);
367+ expect(
368+ call(
369+ "ghostty_terminal_selection_format_buf",
370+ terminal,
371+ formatOptions,
372+ lineSelectionText,
373+ lineSelectionSize,
374+ written,
375+ ),
376+ ).toBe(0);
377+ expect(
378+ new TextDecoder().decode(new Uint8Array(memory.buffer, lineSelectionText, lineSelectionSize)),
379+ ).toBe("linked plain");
380+
381+ const mouseEncoderSlot = call("ghostty_wasm_alloc_opaque");
382+ const mouseEventSlot = call("ghostty_wasm_alloc_opaque");
383+ expect(call("ghostty_mouse_encoder_new", 0, mouseEncoderSlot)).toBe(0);
384+ expect(call("ghostty_mouse_event_new", 0, mouseEventSlot)).toBe(0);
385+ const view = new DataView(memory.buffer);
386+ const mouseEncoder = view.getUint32(mouseEncoderSlot, true);
387+ const mouseEvent = view.getUint32(mouseEventSlot, true);
388+ call("ghostty_mouse_encoder_setopt_from_terminal", mouseEncoder, terminal);
389+ const mouseSize = alloc(36);
390+ const mouseSizeView = new DataView(memory.buffer, mouseSize, 36);
391+ for (const [offset, value] of [
392+ [0, 36],
393+ [4, 800],
394+ [8, 480],
395+ [12, 10],
396+ [16, 20],
397+ ] as const) {
398+ mouseSizeView.setUint32(offset, value, true);
399+ }
400+ call("ghostty_mouse_encoder_setopt", mouseEncoder, 2, mouseSize);
401+ call("ghostty_mouse_event_set_action", mouseEvent, 0);
402+ call("ghostty_mouse_event_set_button", mouseEvent, 1);
403+ const mousePosition = new DataView(new ArrayBuffer(8));
404+ mousePosition.setFloat32(0, 15, true);
405+ mousePosition.setFloat32(4, 25, true);
406+ const mousePositionPointer = alloc(8);
407+ new Uint8Array(memory.buffer, mousePositionPointer, 8).set(
408+ new Uint8Array(mousePosition.buffer),
409+ );
410+ call("ghostty_mouse_event_set_position", mouseEvent, mousePositionPointer);
411+ const mouseOutput = alloc(128);
412+ expect(
413+ call("ghostty_mouse_encoder_encode", mouseEncoder, mouseEvent, mouseOutput, 128, written),
414+ ).toBe(0);
415+ const mouseOutputSize = new DataView(memory.buffer, written, 4).getUint32(0, true);
416+ expect(
417+ new TextDecoder().decode(new Uint8Array(memory.buffer, mouseOutput, mouseOutputSize)),
418+ ).toBe("\u001b[<0;2;2M");
419+
420+ call("ghostty_mouse_event_free", mouseEvent);
421+ call("ghostty_mouse_encoder_free", mouseEncoder);
422+ call("ghostty_wasm_free_opaque", mouseEventSlot);
423+ call("ghostty_wasm_free_opaque", mouseEncoderSlot);
424+ free(mousePositionPointer, 8);
425+ free(mouseOutput, 128);
426+ free(mouseSize, 36);
427+ free(lineSelectionText, lineSelectionSize);
428+ free(lineOptions, 28);
429+ free(selectionText, selectionSize);
430+ free(formatOptions, 16);
431+ free(selection, 32);
432+ free(wordOptions, 24);
433+ free(hyperlink, hyperlinkSize);
434+ call("ghostty_wasm_free_usize", written);
435+ free(gridRef, 12);
436+ free(point, 24);
437+ free(inputPointer, input.length);
438+ call("ghostty_terminal_free", terminal);
439+ call("ghostty_wasm_free_opaque", terminalSlot);
440+ free(terminalOptions, 8);
441+ });
442+
443+ it("encodes modified printable keys in Kitty keyboard mode", async () => {
444+ const result = await WebAssembly.instantiate(
445+ decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer,
446+ { env: { log: () => {} } },
447+ );
448+ const instance = result instanceof WebAssembly.Instance ? result : result.instance;
449+ const memory = instance.exports.memory as WebAssembly.Memory;
450+ const call = (name: string, ...args: number[]) =>
451+ (instance.exports[name] as WasmFunction)(...args);
452+ const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size);
453+ const free = (pointer: number, size: number) =>
454+ call("ghostty_wasm_free_u8_array", pointer, size);
455+
456+ const terminalOptions = alloc(8);
457+ const terminalOptionsView = new DataView(memory.buffer, terminalOptions, 8);
458+ terminalOptionsView.setUint16(0, 80, true);
459+ terminalOptionsView.setUint16(2, 24, true);
460+ const terminalSlot = call("ghostty_wasm_alloc_opaque");
461+ expect(call("ghostty_terminal_new", 0, terminalSlot, terminalOptions)).toBe(0);
462+ const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true);
463+ const kittyMode = new TextEncoder().encode("\u001b[>1u");
464+ const kittyModePointer = alloc(kittyMode.length);
465+ new Uint8Array(memory.buffer, kittyModePointer, kittyMode.length).set(kittyMode);
466+ call("ghostty_terminal_vt_write", terminal, kittyModePointer, kittyMode.length);
467+
468+ const encoderSlot = call("ghostty_wasm_alloc_opaque");
469+ const eventSlot = call("ghostty_wasm_alloc_opaque");
470+ expect(call("ghostty_key_encoder_new", 0, encoderSlot)).toBe(0);
471+ expect(call("ghostty_key_event_new", 0, eventSlot)).toBe(0);
472+ const view = new DataView(memory.buffer);
473+ const keyEncoder = view.getUint32(encoderSlot, true);
474+ const keyEvent = view.getUint32(eventSlot, true);
475+ call("ghostty_key_encoder_setopt_from_terminal", keyEncoder, terminal);
476+ call("ghostty_key_event_set_action", keyEvent, 1);
477+ call("ghostty_key_event_set_key", keyEvent, ghosttyKeyForCode("KeyC"));
478+ call("ghostty_key_event_set_mods", keyEvent, 1 << 1);
479+ call("ghostty_key_event_set_consumed_mods", keyEvent, 0);
480+ call("ghostty_key_event_set_composing", keyEvent, 0);
481+ call("ghostty_key_event_set_unshifted_codepoint", keyEvent, "c".codePointAt(0)!);
482+ const text = new TextEncoder().encode("c");
483+ const textPointer = alloc(text.length);
484+ new Uint8Array(memory.buffer, textPointer, text.length).set(text);
485+ call("ghostty_key_event_set_utf8", keyEvent, textPointer, text.length);
486+
487+ const written = call("ghostty_wasm_alloc_usize");
488+ expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(-3);
489+ const outputSize = new DataView(memory.buffer, written, 4).getUint32(0, true);
490+ const output = alloc(outputSize);
491+ expect(
492+ call("ghostty_key_encoder_encode", keyEncoder, keyEvent, output, outputSize, written),
493+ ).toBe(0);
494+ const outputLength = new DataView(memory.buffer, written, 4).getUint32(0, true);
495+ expect(new TextDecoder().decode(new Uint8Array(memory.buffer, output, outputLength))).toBe(
496+ "\u001b[99;5u",
497+ );
498+
499+ const remappedText = new TextEncoder().encode("j");
500+ const remappedTextPointer = alloc(remappedText.length);
501+ new Uint8Array(memory.buffer, remappedTextPointer, remappedText.length).set(remappedText);
502+ call("ghostty_key_event_set_unshifted_codepoint", keyEvent, "j".codePointAt(0)!);
503+ call("ghostty_key_event_set_utf8", keyEvent, remappedTextPointer, remappedText.length);
504+ expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(-3);
505+ const remappedOutputSize = new DataView(memory.buffer, written, 4).getUint32(0, true);
506+ const remappedOutput = alloc(remappedOutputSize);
507+ expect(
508+ call(
509+ "ghostty_key_encoder_encode",
510+ keyEncoder,
511+ keyEvent,
512+ remappedOutput,
513+ remappedOutputSize,
514+ written,
515+ ),
516+ ).toBe(0);
517+ const remappedOutputLength = new DataView(memory.buffer, written, 4).getUint32(0, true);
518+ expect(
519+ new TextDecoder().decode(new Uint8Array(memory.buffer, remappedOutput, remappedOutputLength)),
520+ ).toBe("\u001b[106;5u");
521+
522+ // Without the Kitty report-event-types flag a release encodes nothing, so
523+ // the surface's keyup handler stays silent for legacy sessions.
524+ call("ghostty_key_event_set_action", keyEvent, 0);
525+ expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(0);
526+ expect(new DataView(memory.buffer, written, 4).getUint32(0, true)).toBe(0);
527+
528+ // With report-event-types enabled the same release encodes an event-typed code.
529+ const reportEvents = new TextEncoder().encode("\u001b[>3u");
530+ const reportEventsPointer = alloc(reportEvents.length);
531+ new Uint8Array(memory.buffer, reportEventsPointer, reportEvents.length).set(reportEvents);
532+ call("ghostty_terminal_vt_write", terminal, reportEventsPointer, reportEvents.length);
533+ call("ghostty_key_encoder_setopt_from_terminal", keyEncoder, terminal);
534+ expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(-3);
535+ const releaseSize = new DataView(memory.buffer, written, 4).getUint32(0, true);
536+ const releaseOutput = alloc(releaseSize);
537+ expect(
538+ call("ghostty_key_encoder_encode", keyEncoder, keyEvent, releaseOutput, releaseSize, written),
539+ ).toBe(0);
540+ const releaseLength = new DataView(memory.buffer, written, 4).getUint32(0, true);
541+ expect(
542+ new TextDecoder().decode(new Uint8Array(memory.buffer, releaseOutput, releaseLength)),
543+ ).toBe("\u001b[106;5:3u");
544+ free(releaseOutput, releaseSize);
545+ free(reportEventsPointer, reportEvents.length);
546+
547+ free(remappedOutput, remappedOutputSize);
548+ free(remappedTextPointer, remappedText.length);
549+ free(output, outputSize);
550+ call("ghostty_wasm_free_usize", written);
551+ free(textPointer, text.length);
552+ call("ghostty_key_event_free", keyEvent);
553+ call("ghostty_key_encoder_free", keyEncoder);
554+ call("ghostty_wasm_free_opaque", eventSlot);
555+ call("ghostty_wasm_free_opaque", encoderSlot);
556+ free(kittyModePointer, kittyMode.length);
557+ call("ghostty_terminal_free", terminal);
558+ call("ghostty_wasm_free_opaque", terminalSlot);
559+ free(terminalOptions, 8);
560+ });
561+});
apps/web/src/terminal/ghostty/surface.test.ts+383−0
@@ -0,0 +1,383 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import type { GhosttyCell, GhosttyRow } from "./core";
4+import {
5+ DEFAULT_TERMINAL_FONT_FAMILY,
6+ DEFAULT_TERMINAL_FONT_SIZE,
7+ advanceTerminalSelectionClickSequence,
8+ ghosttyMouseButton,
9+ isTerminalAltGraphText,
10+ isTerminalCompositionCommitInput,
11+ isTerminalCopyShortcut,
12+ isTerminalLinkPointerGesture,
13+ isTerminalPasteShortcut,
14+ shouldReportTerminalMouse,
15+ terminalScrollbarGeometry,
16+ terminalScrollbarOffsetAtPointer,
17+ terminalLinkAtColumn,
18+ terminalLinkAtPosition,
19+ terminalContentOriginY,
20+ terminalFontFamily,
21+ terminalFontSize,
22+ terminalWheelArrowData,
23+ terminalWheelDeltaRows,
24+} from "./surface";
25+
26+const cell = (text: string): GhosttyCell => ({
27+ text,
28+ wide: 0,
29+ foreground: { r: 255, g: 255, b: 255 },
30+ background: { r: 0, g: 0, b: 0 },
31+ bold: false,
32+ italic: false,
33+ invisible: false,
34+ strikethrough: false,
35+ overline: false,
36+ underline: false,
37+ selected: false,
38+});
39+
40+describe("isTerminalAltGraphText", () => {
41+ it("defers printable AltGr output to the textarea input event", () => {
42+ expect(
43+ isTerminalAltGraphText({
44+ key: "@",
45+ getModifierState: (modifier) => modifier === "AltGraph",
46+ }),
47+ ).toBe(true);
48+ expect(
49+ isTerminalAltGraphText({
50+ key: "ArrowRight",
51+ getModifierState: (modifier) => modifier === "AltGraph",
52+ }),
53+ ).toBe(false);
54+ });
55+});
56+
57+describe("terminalLinkAtColumn", () => {
58+ it("maps terminal cells to UTF-16 offsets after a wide emoji", () => {
59+ const cells = [
60+ cell("🙂"),
61+ cell(""),
62+ ...Array.from("https://t3.codes", (character) => cell(character)),
63+ ];
64+ const row: GhosttyRow = {
65+ cells,
66+ text: cells
67+ .map((value) => value.text || " ")
68+ .join("")
69+ .trimEnd(),
70+ isWrapContinuation: false,
71+ wrapsToNext: false,
72+ };
73+
74+ expect(terminalLinkAtColumn(row, 2)).toBe("https://t3.codes");
75+ expect(terminalLinkAtColumn(row, cells.length - 1)).toBe("https://t3.codes");
76+ expect(terminalLinkAtColumn(row, 0)).toBeNull();
77+ });
78+
79+ it("uses shared path matching and reconstructs soft-wrapped links", () => {
80+ const row = (text: string, isWrapContinuation: boolean, wrapsToNext = false): GhosttyRow => ({
81+ cells: Array.from(text.padEnd(16), (character) => cell(character)),
82+ text: text.trimEnd(),
83+ isWrapContinuation,
84+ wrapsToNext,
85+ });
86+ const rows = [
87+ row("https://example.", false),
88+ row("com/reference", true),
89+ row("~/project/file", false),
90+ row("C:\\repo\\file.ts", false),
91+ ];
92+
93+ expect(terminalLinkAtPosition(rows, 0, 8)).toBe("https://example.com/reference");
94+ expect(terminalLinkAtPosition(rows, 1, 4)).toBe("https://example.com/reference");
95+ expect(terminalLinkAtPosition(rows, 2, 2)).toBe("~/project/file");
96+ expect(terminalLinkAtPosition(rows, 3, 4)).toBe("C:\\repo\\file.ts");
97+ });
98+
99+ it("refuses links truncated at the viewport edges instead of mis-resolving", () => {
100+ const row = (text: string, isWrapContinuation: boolean, wrapsToNext = false): GhosttyRow => ({
101+ cells: Array.from(text.padEnd(16), (character) => cell(character)),
102+ text: text.trimEnd(),
103+ isWrapContinuation,
104+ wrapsToNext,
105+ });
106+ // The head of the wrapped line scrolled above the viewport.
107+ const headCut = [row("ple.com/missing", true), row("head", true)];
108+ expect(terminalLinkAtPosition(headCut, 0, 4)).toBeNull();
109+ // The bottom row soft-wraps on below the viewport.
110+ const tailCut = [row("https://t3.codes", false, true)];
111+ expect(terminalLinkAtPosition(tailCut, 0, 8)).toBeNull();
112+ // A partial bottom row is provably complete and still resolves.
113+ const complete = [row("https://t3.codes", false), row("", false)];
114+ expect(terminalLinkAtPosition(complete, 0, 8)).toBe("https://t3.codes");
115+ // A wide grapheme earlier in the row must not break truncation detection:
116+ // the soft-wrap flag decides, not string-length-versus-cell-count.
117+ const wideFull: GhosttyRow = {
118+ cells: [
119+ { ...cell("🙂"), wide: 1 },
120+ { ...cell(""), wide: 2 },
121+ ...Array.from("https://t3.code", (character) => cell(character)),
122+ ],
123+ text: "🙂 https://t3.code",
124+ isWrapContinuation: false,
125+ wrapsToNext: true,
126+ };
127+ expect(terminalLinkAtPosition([wideFull], 0, 8)).toBeNull();
128+ // Unwritten trailing cells prove the bottom row is complete.
129+ const unwrittenTail: GhosttyRow = {
130+ cells: [
131+ ...Array.from("https://t3.codes", (character) => cell(character)),
132+ cell(""),
133+ cell(""),
134+ ],
135+ text: "https://t3.codes",
136+ isWrapContinuation: false,
137+ wrapsToNext: false,
138+ };
139+ expect(terminalLinkAtPosition([unwrittenTail], 0, 8)).toBe("https://t3.codes");
140+ });
141+});
142+
143+describe("isTerminalCopyShortcut", () => {
144+ const event = (overrides: Partial<Parameters<typeof isTerminalCopyShortcut>[0]> = {}) => ({
145+ ctrlKey: false,
146+ key: "c",
147+ metaKey: false,
148+ shiftKey: false,
149+ ...overrides,
150+ });
151+
152+ it("keeps Ctrl+C available for SIGINT on macOS", () => {
153+ expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "MacIntel")).toBe(false);
154+ expect(isTerminalCopyShortcut(event({ metaKey: true }), "MacIntel")).toBe(true);
155+ });
156+
157+ it("uses the conventional Ctrl+Shift+C shortcut elsewhere", () => {
158+ expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(false);
159+ expect(isTerminalCopyShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe(
160+ true,
161+ );
162+ });
163+
164+ it("uses the produced character instead of the physical key position", () => {
165+ expect(isTerminalCopyShortcut(event({ key: "C", metaKey: true }), "MacIntel")).toBe(true);
166+ expect(isTerminalCopyShortcut(event({ key: "j", metaKey: true }), "MacIntel")).toBe(false);
167+ });
168+});
169+
170+describe("isTerminalPasteShortcut", () => {
171+ const event = (overrides: Partial<Parameters<typeof isTerminalPasteShortcut>[0]> = {}) => ({
172+ ctrlKey: false,
173+ key: "v",
174+ metaKey: false,
175+ shiftKey: false,
176+ ...overrides,
177+ });
178+
179+ it("uses Cmd+V on macOS", () => {
180+ expect(isTerminalPasteShortcut(event({ metaKey: true }), "MacIntel")).toBe(true);
181+ expect(isTerminalPasteShortcut(event({ ctrlKey: true }), "MacIntel")).toBe(false);
182+ });
183+
184+ it("preserves Ctrl+V and uses Ctrl+Shift+V elsewhere", () => {
185+ expect(isTerminalPasteShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(false);
186+ expect(isTerminalPasteShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe(
187+ true,
188+ );
189+ });
190+});
191+
192+describe("isTerminalCompositionCommitInput", () => {
193+ it("identifies browser composition follow-up input", () => {
194+ expect(isTerminalCompositionCommitInput({ inputType: "" })).toBe(true);
195+ expect(isTerminalCompositionCommitInput({ inputType: "insertCompositionText" })).toBe(true);
196+ expect(isTerminalCompositionCommitInput({ inputType: "insertFromComposition" })).toBe(true);
197+ });
198+
199+ it("keeps a fast repeated input as legitimate text", () => {
200+ expect(isTerminalCompositionCommitInput({ inputType: "insertText" })).toBe(false);
201+ });
202+});
203+
204+describe("application mouse reporting", () => {
205+ const event = (overrides: Partial<Parameters<typeof shouldReportTerminalMouse>[1]> = {}) => ({
206+ ctrlKey: false,
207+ metaKey: false,
208+ shiftKey: false,
209+ ...overrides,
210+ });
211+
212+ it("keeps Shift and link activation modifiers available to the browser", () => {
213+ expect(shouldReportTerminalMouse(true, event())).toBe(true);
214+ expect(shouldReportTerminalMouse(true, event({ shiftKey: true }))).toBe(false);
215+ expect(shouldReportTerminalMouse(true, event({ ctrlKey: true }))).toBe(false);
216+ expect(shouldReportTerminalMouse(true, event({ metaKey: true }))).toBe(false);
217+ expect(shouldReportTerminalMouse(false, event())).toBe(false);
218+ });
219+
220+ it("maps browser buttons to Ghostty's button enum", () => {
221+ expect([0, 1, 2, 3, 4, 5].map(ghosttyMouseButton)).toEqual([1, 3, 2, 4, 5, null]);
222+ });
223+});
224+
225+describe("terminal font resolution", () => {
226+ it("keeps the glyph fallbacks behind a custom text face", () => {
227+ expect(terminalFontFamily()).toBe(DEFAULT_TERMINAL_FONT_FAMILY);
228+ expect(terminalFontFamily(" ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY);
229+ const custom = terminalFontFamily('"Fira Code"');
230+ expect(custom.startsWith('"Fira Code", ')).toBe(true);
231+ expect(custom).toContain('"Symbols Nerd Font Mono"');
232+ expect(custom.endsWith("monospace")).toBe(true);
233+ });
234+
235+ it("clamps requested font sizes to the supported range", () => {
236+ expect(terminalFontSize()).toBe(DEFAULT_TERMINAL_FONT_SIZE);
237+ expect(terminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE);
238+ expect(terminalFontSize(13.4)).toBe(13);
239+ expect(terminalFontSize(2)).toBe(6);
240+ expect(terminalFontSize(90)).toBe(32);
241+ });
242+});
243+
244+describe("terminalContentOriginY", () => {
245+ it("stays top-anchored like a fresh terminal until scrollback exists", () => {
246+ expect(terminalContentOriginY(100, 4, 5, 16, false)).toBe(4);
247+ });
248+
249+ it("pins the grid to the bottom by moving the sub-row slack above row 0", () => {
250+ // 100px mount, 4px padding, 5 rows of 16px: 92 - 80 = 12px slack on top.
251+ expect(terminalContentOriginY(100, 4, 5, 16, true)).toBe(16);
252+ // Exact fit keeps the origin at the padding.
253+ expect(terminalContentOriginY(88, 4, 5, 16, true)).toBe(4);
254+ // A mount smaller than the grid never pushes the origin above the padding.
255+ expect(terminalContentOriginY(80, 4, 5, 16, true)).toBe(4);
256+ });
257+
258+ it("keeps the prompt stationary while a drag crosses row boundaries", () => {
259+ // Growing the mount pixel by pixel: the bottom edge (origin + rows*cell)
260+ // tracks the mount bottom exactly until a new row fits.
261+ for (let height = 88; height < 104; height += 1) {
262+ const rows = Math.max(1, Math.floor((height - 8) / 16));
263+ const origin = terminalContentOriginY(height, 4, rows, 16, true);
264+ expect(origin + rows * 16).toBe(height - 4);
265+ }
266+ });
267+});
268+
269+describe("terminalWheelDeltaRows", () => {
270+ it("converts line-mode deltas into whole rows", () => {
271+ const result = terminalWheelDeltaRows({ deltaY: 3, deltaMode: 1 }, 16, 24, 0);
272+ expect(result.rows).toBe(3);
273+ expect(result.remainder).toBe(0);
274+ });
275+
276+ it("accumulates fractional pixel deltas across events", () => {
277+ let remainder = 0;
278+ let scrolled = 0;
279+ for (let index = 0; index < 4; index += 1) {
280+ const result = terminalWheelDeltaRows({ deltaY: 5, deltaMode: 0 }, 16, 24, remainder);
281+ remainder = result.remainder;
282+ scrolled += result.rows;
283+ }
284+ expect(scrolled).toBe(1);
285+ expect(remainder).toBeCloseTo(4 / 16);
286+ });
287+
288+ it("keeps direction for negative page-mode deltas", () => {
289+ const result = terminalWheelDeltaRows({ deltaY: -1, deltaMode: 2 }, 16, 24, 0);
290+ expect(result.rows).toBe(-24);
291+ expect(result.remainder).toBe(0);
292+ });
293+});
294+
295+describe("terminalWheelArrowData", () => {
296+ it("emits one arrow per row honoring application cursor keys", () => {
297+ expect(terminalWheelArrowData(-2, false)).toBe("\u001b[A\u001b[A");
298+ expect(terminalWheelArrowData(3, false)).toBe("\u001b[B\u001b[B\u001b[B");
299+ expect(terminalWheelArrowData(-1, true)).toBe("\u001bOA");
300+ expect(terminalWheelArrowData(0, true)).toBe("");
301+ });
302+});
303+
304+describe("isTerminalLinkPointerGesture", () => {
305+ it("uses Command on macOS and Control elsewhere", () => {
306+ expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "MacIntel")).toBe(true);
307+ expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "MacIntel")).toBe(false);
308+ expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "Linux x86_64")).toBe(
309+ true,
310+ );
311+ expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "Linux x86_64")).toBe(
312+ false,
313+ );
314+ });
315+});
316+
317+describe("advanceTerminalSelectionClickSequence", () => {
318+ it("recognizes stationary double and triple pointer presses without PointerEvent.detail", () => {
319+ const first = advanceTerminalSelectionClickSequence(null, {
320+ clientX: 20,
321+ clientY: 30,
322+ timeStamp: 1_000,
323+ });
324+ const second = advanceTerminalSelectionClickSequence(first, {
325+ clientX: 22,
326+ clientY: 29,
327+ timeStamp: 1_200,
328+ });
329+ const third = advanceTerminalSelectionClickSequence(second, {
330+ clientX: 21,
331+ clientY: 31,
332+ timeStamp: 1_400,
333+ });
334+
335+ expect([first.count, second.count, third.count]).toEqual([1, 2, 3]);
336+ });
337+
338+ it("starts over after movement, delay, or a completed triple click", () => {
339+ const previous = { count: 3, time: 1_000, x: 20, y: 30 };
340+ expect(
341+ advanceTerminalSelectionClickSequence(previous, {
342+ clientX: 20,
343+ clientY: 30,
344+ timeStamp: 1_100,
345+ }).count,
346+ ).toBe(1);
347+ expect(
348+ advanceTerminalSelectionClickSequence(previous, {
349+ clientX: 30,
350+ clientY: 30,
351+ timeStamp: 1_100,
352+ }).count,
353+ ).toBe(1);
354+ expect(
355+ advanceTerminalSelectionClickSequence(previous, {
356+ clientX: 20,
357+ clientY: 30,
358+ timeStamp: 1_501,
359+ }).count,
360+ ).toBe(1);
361+ });
362+});
363+
364+describe("terminal scrollbar", () => {
365+ it("maps Ghostty's viewport state to a proportional thumb", () => {
366+ expect(terminalScrollbarGeometry({ total: 100, offset: 40, len: 20 }, 200)).toEqual({
367+ thumbHeight: 40,
368+ thumbTop: 80,
369+ maxOffset: 80,
370+ });
371+ expect(terminalScrollbarGeometry({ total: 100, offset: 0, len: 100 }, 200)).toBeNull();
372+ });
373+
374+ it("keeps the thumb usable for large scrollback and maps dragging back to rows", () => {
375+ const state = { total: 10_000, offset: 0, len: 20 };
376+ expect(terminalScrollbarGeometry(state, 200)).toEqual({
377+ thumbHeight: 18,
378+ thumbTop: 0,
379+ maxOffset: 9_980,
380+ });
381+ expect(terminalScrollbarOffsetAtPointer(state, 200, 191, 9)).toBe(9_980);
382+ });
383+});
apps/web/src/terminal/ghostty/surface.ts+1393−0
@@ -0,0 +1,1393 @@
1+import { isMacPlatform } from "../../lib/utils";
2+import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links";
3+import {
4+ GhosttyTerminalCore,
5+ type GhosttyScrollbar,
6+ type GhosttySnapshot,
7+ type GhosttyTheme,
8+} from "./core";
9+import {
10+ measureGhosttyCell,
11+ renderGhosttySnapshot,
12+ terminalGridSize,
13+ type GhosttyCellMetrics,
14+} from "./renderer";
15+import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url";
16+
17+export const DEFAULT_TERMINAL_FONT_SIZE = 12;
18+const MIN_TERMINAL_FONT_SIZE = 6;
19+const MAX_TERMINAL_FONT_SIZE = 32;
20+// The glyph fallbacks only supply symbols the text faces are missing (powerline
21+// separators, devicons, and other private-use prompt symbols), so shells
22+// configured for a locally installed Nerd Font keep their prompt glyphs no
23+// matter which text face is active.
24+const TERMINAL_GLYPH_FALLBACKS =
25+ '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono Nerd Font", ' +
26+ '"JetBrainsMono NF", "FiraCode Nerd Font", "Hack Nerd Font", "MesloLGS NF", ' +
27+ '"CaskaydiaCove Nerd Font", "PowerlineSymbols", monospace';
28+// SF Mono where the platform has it (macOS), otherwise the bundled JetBrains
29+// Mono webfont, so the default rendering is identical everywhere else.
30+export const DEFAULT_TERMINAL_FONT_FAMILY =
31+ '"SF Mono", "SFMono-Regular", "JetBrains Mono", ' + TERMINAL_GLYPH_FALLBACKS;
32+const CONTENT_PADDING = 4;
33+const MIN_SCROLLBAR_THUMB_HEIGHT = 18;
34+
35+/** Requested terminal font; omitted fields fall back to the defaults. */
36+export interface GhosttyTerminalFont {
37+ readonly family?: string;
38+ readonly size?: number;
39+}
40+
41+let symbolsFontLoad: Promise<void> | null = null;
42+
43+/**
44+ * Register the bundled symbols-only Nerd Font once per page. It loads lazily
45+ * with the first terminal, and because it carries no regular text glyphs it
46+ * composes with any text face without changing metrics — prompt symbols and
47+ * devicons render even on machines without a locally installed Nerd Font.
48+ */
49+function ensureTerminalSymbolsFont(): Promise<void> {
50+ if (symbolsFontLoad !== null) return symbolsFontLoad;
51+ symbolsFontLoad = (async () => {
52+ try {
53+ const face = new FontFace("Symbols Nerd Font Mono", `url(${symbolsFontUrl})`);
54+ document.fonts.add(await face.load());
55+ } catch {
56+ // Locally installed fallback faces still apply.
57+ }
58+ })();
59+ return symbolsFontLoad;
60+}
61+
62+export function terminalFontFamily(family?: string): string {
63+ const custom = family?.trim();
64+ if (!custom) return DEFAULT_TERMINAL_FONT_FAMILY;
65+ // A custom face keeps the glyph fallbacks so prompt symbols stay covered.
66+ return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`;
67+}
68+
69+export function terminalFontSize(size?: number): number {
70+ if (size === undefined || !Number.isFinite(size)) return DEFAULT_TERMINAL_FONT_SIZE;
71+ return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size)));
72+}
73+
74+/**
75+ * Vertical origin of the grid inside the mount. While content is shorter than
76+ * the viewport the grid sits at the top like a fresh terminal. Once scrollback
77+ * exists the prompt lives on the bottom row, so the grid anchors to the bottom
78+ * edge instead: the sub-row remainder moves above row 0 and resizing within a
79+ * row boundary keeps the prompt pinned instead of snapping up and down.
80+ */
81+export function terminalContentOriginY(
82+ mountHeight: number,
83+ padding: number,
84+ rows: number,
85+ cellHeight: number,
86+ anchorBottom: boolean,
87+): number {
88+ if (!anchorBottom) return padding;
89+ const slack = mountHeight - padding * 2 - rows * cellHeight;
90+ return padding + Math.max(0, slack);
91+}
92+
93+export interface TerminalScrollbarGeometry {
94+ readonly thumbHeight: number;
95+ readonly thumbTop: number;
96+ readonly maxOffset: number;
97+}
98+
99+export function terminalScrollbarGeometry(
100+ state: GhosttyScrollbar,
101+ trackHeight: number,
102+): TerminalScrollbarGeometry | null {
103+ const total = Math.max(0, state.total);
104+ const len = Math.max(0, Math.min(state.len, total));
105+ const maxOffset = Math.max(0, total - len);
106+ if (trackHeight <= 0 || len <= 0 || maxOffset === 0) return null;
107+ const thumbHeight = Math.min(
108+ trackHeight,
109+ Math.max(MIN_SCROLLBAR_THUMB_HEIGHT, (trackHeight * len) / total),
110+ );
111+ const travel = Math.max(0, trackHeight - thumbHeight);
112+ const offset = Math.max(0, Math.min(state.offset, maxOffset));
113+ return {
114+ thumbHeight,
115+ thumbTop: travel * (offset / maxOffset),
116+ maxOffset,
117+ };
118+}
119+
120+export function terminalScrollbarOffsetAtPointer(
121+ state: GhosttyScrollbar,
122+ trackHeight: number,
123+ pointerY: number,
124+ pointerOffset: number,
125+): number {
126+ const geometry = terminalScrollbarGeometry(state, trackHeight);
127+ if (geometry === null) return 0;
128+ const travel = Math.max(0, trackHeight - geometry.thumbHeight);
129+ if (travel === 0) return 0;
130+ const thumbTop = Math.max(0, Math.min(pointerY - pointerOffset, travel));
131+ return Math.round((thumbTop / travel) * geometry.maxOffset);
132+}
133+
134+function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string {
135+ const text = row.cells.map((cell) => cell.text || " ").join("");
136+ return trimRight ? text.trimEnd() : text;
137+}
138+
139+function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: number): number {
140+ let offset = 0;
141+ for (let cellIndex = 0; cellIndex < column; cellIndex += 1) {
142+ offset += row.cells[cellIndex]?.text.length || 1;
143+ }
144+ return offset;
145+}
146+
147+export function terminalLinkAtPosition(
148+ rows: GhosttySnapshot["rowData"],
149+ rowIndex: number,
150+ column: number,
151+): string | null {
152+ const wrappedLine = collectWrappedTerminalLinkLine(rowIndex + 1, (index) => {
153+ const row = rows[index];
154+ if (!row) return null;
155+ return {
156+ isWrapped: row.isWrapContinuation,
157+ translateToString: (trimRight = false) => terminalRowText(row, trimRight),
158+ };
159+ });
160+ if (!wrappedLine) return null;
161+ // Only viewport rows are available: a wrapped line whose head scrolled above
162+ // the viewport would resolve a truncated match into a wrong link.
163+ const firstSegment = wrappedLine.segments[0];
164+ if (firstSegment && rows[firstSegment.bufferLineNumber - 1]?.isWrapContinuation) {
165+ return null;
166+ }
167+ const segment = wrappedLine.segments.find((value) => value.bufferLineNumber === rowIndex + 1);
168+ const row = rows[rowIndex];
169+ if (!segment || !row) return null;
170+ const lastSegment = wrappedLine.segments.at(-1);
171+ const lastRow = lastSegment ? rows[lastSegment.bufferLineNumber - 1] : undefined;
172+ // Ghostty's soft-wrap flag is authoritative: when the last collected row
173+ // still wraps onward, its continuation is outside the viewport.
174+ const continuesBelowViewport = lastRow !== undefined && lastRow.wrapsToNext;
175+ const offset = segment.startIndex + terminalColumnOffset(row, column);
176+ for (const match of extractTerminalLinks(wrappedLine.text)) {
177+ if (offset >= match.start && offset < match.end) {
178+ // A truncated tail must not activate as a complete link.
179+ if (match.end === wrappedLine.text.length && continuesBelowViewport) return null;
180+ return match.text;
181+ }
182+ }
183+ return null;
184+}
185+
186+export function terminalLinkAtColumn(row: GhosttySnapshot["rowData"][number], column: number) {
187+ return terminalLinkAtPosition([row], 0, column);
188+}
189+
190+export function isTerminalCopyShortcut(
191+ event: Pick<KeyboardEvent, "ctrlKey" | "key" | "metaKey" | "shiftKey">,
192+ platform = navigator.platform,
193+) {
194+ if (event.key.toLowerCase() !== "c") return false;
195+ return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey;
196+}
197+
198+export function isTerminalPasteShortcut(
199+ event: Pick<KeyboardEvent, "ctrlKey" | "key" | "metaKey" | "shiftKey">,
200+ platform = navigator.platform,
201+) {
202+ if (event.key.toLowerCase() !== "v") return false;
203+ return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey;
204+}
205+
206+export function isTerminalCompositionCommitInput(event: Pick<InputEvent, "inputType">): boolean {
207+ return (
208+ event.inputType === "" ||
209+ event.inputType === "insertCompositionText" ||
210+ event.inputType === "insertFromComposition"
211+ );
212+}
213+
214+export function isTerminalAltGraphText(
215+ event: Pick<KeyboardEvent, "getModifierState" | "key">,
216+): boolean {
217+ return event.getModifierState("AltGraph") && [...event.key].length === 1;
218+}
219+
220+export function shouldReportTerminalMouse(
221+ tracking: boolean,
222+ event: Pick<MouseEvent, "ctrlKey" | "metaKey" | "shiftKey">,
223+): boolean {
224+ return tracking && !event.shiftKey && !event.ctrlKey && !event.metaKey;
225+}
226+
227+export function terminalWheelDeltaRows(
228+ event: Pick<WheelEvent, "deltaY" | "deltaMode">,
229+ cellHeight: number,
230+ viewportRows: number,
231+ remainder: number,
232+): { readonly rows: number; readonly remainder: number } {
233+ // deltaMode: 0 pixels, 1 lines, 2 pages.
234+ const pixels =
235+ event.deltaMode === 1
236+ ? event.deltaY * cellHeight
237+ : event.deltaMode === 2
238+ ? event.deltaY * viewportRows * cellHeight
239+ : event.deltaY;
240+ const total = remainder + pixels / cellHeight;
241+ const rows = Math.trunc(total);
242+ return { rows, remainder: total - rows };
243+}
244+
245+export function terminalWheelArrowData(rows: number, applicationCursorKeys: boolean): string {
246+ if (rows === 0) return "";
247+ const sequence =
248+ rows < 0
249+ ? applicationCursorKeys
250+ ? "\u001bOA"
251+ : "\u001b[A"
252+ : applicationCursorKeys
253+ ? "\u001bOB"
254+ : "\u001b[B";
255+ return sequence.repeat(Math.abs(rows));
256+}
257+
258+export function isTerminalLinkPointerGesture(
259+ event: Pick<MouseEvent, "ctrlKey" | "metaKey">,
260+ platform = navigator.platform,
261+): boolean {
262+ return isMacPlatform(platform)
263+ ? event.metaKey && !event.ctrlKey
264+ : event.ctrlKey && !event.metaKey;
265+}
266+
267+export function ghosttyMouseButton(button: number): number | null {
268+ switch (button) {
269+ case 0:
270+ return 1;
271+ case 1:
272+ return 3;
273+ case 2:
274+ return 2;
275+ case 3:
276+ return 4;
277+ case 4:
278+ return 5;
279+ default:
280+ return null;
281+ }
282+}
283+
284+export interface TerminalSelectionClickSequence {
285+ readonly count: number;
286+ readonly time: number;
287+ readonly x: number;
288+ readonly y: number;
289+}
290+
291+export function advanceTerminalSelectionClickSequence(
292+ previous: TerminalSelectionClickSequence | null,
293+ event: Pick<PointerEvent, "clientX" | "clientY" | "timeStamp">,
294+): TerminalSelectionClickSequence {
295+ const repeats =
296+ previous !== null &&
297+ event.timeStamp - previous.time <= 500 &&
298+ Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= 4;
299+ return {
300+ count: repeats ? (previous.count >= 3 ? 1 : previous.count + 1) : 1,
301+ time: event.timeStamp,
302+ x: event.clientX,
303+ y: event.clientY,
304+ };
305+}
306+
307+export interface GhosttySelectionPosition {
308+ readonly start: { readonly x: number; readonly y: number };
309+ readonly end: { readonly x: number; readonly y: number };
310+}
311+
312+export interface GhosttyTerminalSurfaceOptions {
313+ readonly theme: GhosttyTheme;
314+ readonly font?: GhosttyTerminalFont;
315+ readonly onData: (data: string) => void;
316+ readonly onResize: (cols: number, rows: number) => void;
317+ readonly onSelectionChange: () => void;
318+ readonly onCopy: (text: string) => void;
319+ readonly beforeKey: (event: KeyboardEvent) => boolean;
320+ readonly onLinkActivate: (text: string, event: MouseEvent) => void;
321+}
322+
323+export class GhosttyTerminalSurface {
324+ readonly canvas: HTMLCanvasElement;
325+ readonly input: HTMLTextAreaElement;
326+ readonly scrollbar: HTMLDivElement;
327+ cols = 1;
328+ rows = 1;
329+
330+ private readonly mount: HTMLElement;
331+ private readonly context: CanvasRenderingContext2D;
332+ private readonly core: GhosttyTerminalCore;
333+ private readonly options: GhosttyTerminalSurfaceOptions;
334+ private metrics: GhosttyCellMetrics;
335+ private fontFamily: string;
336+ private fontSize: number;
337+ private fontEpoch = 0;
338+ private readonly resizeObserver: ResizeObserver;
339+ private readonly scrollbarThumb: HTMLDivElement;
340+ private snapshot: GhosttySnapshot | null = null;
341+ private frame = 0;
342+ private cursorTimer: number | null = null;
343+ private compositionInputToSuppress: string | null = null;
344+ private compositionSuppressionTimer: number | null = null;
345+ private cursorOn = true;
346+ private renderedCursorY: number | null = null;
347+ private forceFullRender = true;
348+ private scrollbarDirty = true;
349+ private scrollbarState: GhosttyScrollbar | null = null;
350+ private scrollbarPointerId: number | null = null;
351+ private scrollbarPointerOffset = 0;
352+ private disposed = false;
353+ private resizeNotifyTimer: number | null = null;
354+ private originY = CONTENT_PADDING;
355+ private mountHeight = 0;
356+ private selectionEnd: { x: number; y: number } | null = null;
357+ private selectionAnchorScreen: { x: number; y: number } | null = null;
358+ private selectionEndScreen: { x: number; y: number } | null = null;
359+ private selectionMode: "cell" | "word" | "line" = "cell";
360+ // Word/line selection base in screen coordinates so streaming output cannot
361+ // shift the origin of a drag selection.
362+ private selectionBase: {
363+ start: { x: number; y: number };
364+ end: { x: number; y: number };
365+ } | null = null;
366+ private selectionScrollTimer: number | null = null;
367+ private selectionScrollDelta = 0;
368+ private selectionPointer: { x: number; y: number } | null = null;
369+ private mouseReportingPointerId: number | null = null;
370+ private mouseReportingButton: number | null = null;
371+ private linkActivationPointerId: number | null = null;
372+ private selectionClickSequence: TerminalSelectionClickSequence | null = null;
373+ private selectionMoved = false;
374+ private composing = false;
375+ private focused = false;
376+ private resizeNotified = false;
377+ private canvasConfigured = false;
378+ private theme: GhosttyTheme;
379+ private readonly suppressedKeyCodes = new Set<string>();
380+ private pasteShortcutToken = 0;
381+ private wheelRemainder = 0;
382+ private dprMedia: MediaQueryList | null = null;
383+ private inputLeft = -1;
384+ private inputTop = -1;
385+
386+ private constructor(
387+ mount: HTMLElement,
388+ canvas: HTMLCanvasElement,
389+ input: HTMLTextAreaElement,
390+ scrollbar: HTMLDivElement,
391+ scrollbarThumb: HTMLDivElement,
392+ context: CanvasRenderingContext2D,
393+ core: GhosttyTerminalCore,
394+ metrics: GhosttyCellMetrics,
395+ options: GhosttyTerminalSurfaceOptions,
396+ ) {
397+ this.mount = mount;
398+ this.canvas = canvas;
399+ this.input = input;
400+ this.scrollbar = scrollbar;
401+ this.scrollbarThumb = scrollbarThumb;
402+ this.context = context;
403+ this.core = core;
404+ this.metrics = metrics;
405+ this.options = options;
406+ this.theme = options.theme;
407+ this.fontFamily = terminalFontFamily(options.font?.family);
408+ this.fontSize = terminalFontSize(options.font?.size);
409+ this.resizeObserver = new ResizeObserver(() => this.fit());
410+ this.installEvents();
411+ this.watchDevicePixelRatio();
412+ document.fonts.addEventListener("loadingdone", this.onFontsLoaded);
413+ this.resizeObserver.observe(mount);
414+ }
415+
416+ static async create(
417+ mount: HTMLElement,
418+ options: GhosttyTerminalSurfaceOptions,
419+ ): Promise<GhosttyTerminalSurface> {
420+ const canvas = document.createElement("canvas");
421+ canvas.className = "t3-ghostty-canvas";
422+ canvas.style.cssText = "display:block;width:100%;height:100%;";
423+ canvas.setAttribute("aria-hidden", "true");
424+
425+ const input = document.createElement("textarea");
426+ input.className = "t3-ghostty-input";
427+ input.setAttribute("aria-label", "Terminal input");
428+ input.autocapitalize = "off";
429+ input.autocomplete = "off";
430+ input.spellcheck = false;
431+ input.style.cssText =
432+ "position:absolute;left:4px;top:4px;width:1px;height:1px;opacity:0;padding:0;border:0;resize:none;pointer-events:none;";
433+
434+ const scrollbar = document.createElement("div");
435+ scrollbar.className = "t3-ghostty-scrollbar";
436+ scrollbar.setAttribute("role", "scrollbar");
437+ scrollbar.setAttribute("aria-label", "Terminal scrollback");
438+ scrollbar.setAttribute("aria-orientation", "vertical");
439+ scrollbar.tabIndex = 0;
440+ scrollbar.hidden = true;
441+ const scrollbarThumb = document.createElement("div");
442+ scrollbarThumb.className = "t3-ghostty-scrollbar-thumb";
443+ scrollbar.append(scrollbarThumb);
444+ mount.replaceChildren(canvas, input, scrollbar);
445+
446+ const context = canvas.getContext("2d", { alpha: false });
447+ if (!context) throw new Error("Canvas 2D is unavailable");
448+ const fontFamily = terminalFontFamily(options.font?.family);
449+ const fontSize = terminalFontSize(options.font?.size);
450+ try {
451+ // Cell metrics must come from the faces that will render; measuring before
452+ // the bundled webfonts load would size the grid from a fallback font.
453+ await ensureTerminalSymbolsFont();
454+ await document.fonts.load(`${fontSize}px ${fontFamily}`);
455+ } catch {
456+ // Metrics fall back to whichever faces are already available.
457+ }
458+ const metrics = measureGhosttyCell(context, fontSize, fontFamily);
459+ const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING);
460+ const core = await GhosttyTerminalCore.create(
461+ grid.cols,
462+ grid.rows,
463+ metrics.width,
464+ metrics.height,
465+ options.theme,
466+ options.onData,
467+ );
468+ const surface = new GhosttyTerminalSurface(
469+ mount,
470+ canvas,
471+ input,
472+ scrollbar,
473+ scrollbarThumb,
474+ context,
475+ core,
476+ metrics,
477+ options,
478+ );
479+ surface.fit();
480+ surface.requestRender();
481+ return surface;
482+ }
483+
484+ write(data: string): void {
485+ if (this.disposed) return;
486+ this.core.write(data);
487+ // Restart the blink cycle from the visible phase so the cursor never sits
488+ // invisible through a stream of output or a burst of typing echo.
489+ this.cursorOn = true;
490+ this.scrollbarDirty = true;
491+ this.requestRender();
492+ }
493+
494+ resetAndWrite(data: string): void {
495+ if (this.disposed) return;
496+ this.core.resetAndWrite(data);
497+ this.forceFullRender = true;
498+ this.scrollbarDirty = true;
499+ this.requestRender();
500+ }
501+
502+ setTheme(theme: GhosttyTheme): void {
503+ if (this.disposed) return;
504+ this.theme = theme;
505+ this.core.setTheme(theme);
506+ this.forceFullRender = true;
507+ this.requestRender();
508+ }
509+
510+ async setFont(font: GhosttyTerminalFont): Promise<void> {
511+ if (this.disposed) return;
512+ const fontFamily = terminalFontFamily(font.family);
513+ const fontSize = terminalFontSize(font.size);
514+ // The fields only change together with their metrics after the load, and
515+ // the epoch lets the newest overlapping call win regardless of load order.
516+ const epoch = ++this.fontEpoch;
517+ try {
518+ await document.fonts.load(`${fontSize}px ${fontFamily}`);
519+ } catch {
520+ // Metrics fall back to whichever faces are already available.
521+ }
522+ if (this.disposed || epoch !== this.fontEpoch) return;
523+ this.fontFamily = fontFamily;
524+ this.fontSize = fontSize;
525+ this.applyFontMetrics();
526+ }
527+
528+ private applyFontMetrics(): void {
529+ this.metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily);
530+ this.core.resize(this.cols, this.rows, this.metrics.width, this.metrics.height);
531+ // Cached IME textarea coordinates are stale in the new cell geometry.
532+ this.inputLeft = -1;
533+ this.inputTop = -1;
534+ this.forceFullRender = true;
535+ this.scrollbarDirty = true;
536+ this.fit();
537+ this.requestRender();
538+ }
539+
540+ private readonly onFontsLoaded = () => {
541+ if (this.disposed) return;
542+ // A face that finished loading after the initial measurement changes glyph
543+ // advances; re-measure and refit so the grid matches what actually renders.
544+ const metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily);
545+ if (
546+ metrics.width === this.metrics.width &&
547+ metrics.height === this.metrics.height &&
548+ metrics.baseline === this.metrics.baseline
549+ ) {
550+ return;
551+ }
552+ this.applyFontMetrics();
553+ };
554+
555+ fit(): boolean {
556+ if (this.disposed) return false;
557+ const width = this.mount.clientWidth;
558+ const height = this.mount.clientHeight;
559+ if (width <= 0 || height <= 0) return false;
560+ const ratio = window.devicePixelRatio || 1;
561+ const pixelWidth = Math.max(1, Math.round(width * ratio));
562+ const pixelHeight = Math.max(1, Math.round(height * ratio));
563+ let shouldRender = false;
564+ // The DPR transform must be installed even when the target size happens to
565+ // equal the canvas default 300x150 backing store, so the first fit always
566+ // schedules a canvas configuration.
567+ if (
568+ this.canvas.width !== pixelWidth ||
569+ this.canvas.height !== pixelHeight ||
570+ !this.canvasConfigured
571+ ) {
572+ this.canvas.width = pixelWidth;
573+ this.canvas.height = pixelHeight;
574+ this.context.setTransform(ratio, 0, 0, ratio, 0, 0);
575+ this.canvasConfigured = true;
576+ this.forceFullRender = true;
577+ this.scrollbarDirty = true;
578+ shouldRender = true;
579+ }
580+ const grid = terminalGridSize(width, height, this.metrics, CONTENT_PADDING);
581+ this.mountHeight = height;
582+ // onResize is the only PTY resize channel, so the first successful fit must
583+ // notify even when the measured grid equals the 1x1 construction sentinel.
584+ if (grid.cols !== this.cols || grid.rows !== this.rows || !this.resizeNotified) {
585+ this.cols = grid.cols;
586+ this.rows = grid.rows;
587+ this.core.resize(grid.cols, grid.rows, this.metrics.width, this.metrics.height);
588+ this.notifyResize();
589+ this.forceFullRender = true;
590+ this.scrollbarDirty = true;
591+ shouldRender = true;
592+ }
593+ // Rendering synchronously keeps the repaint inside the same frame as the
594+ // layout change: ResizeObserver fires before paint, so the browser never
595+ // composites the old backing store stretched into the new element box.
596+ if (shouldRender) this.renderFrame();
597+ return true;
598+ }
599+
600+ /**
601+ * The local grid reflows immediately, but the PTY only hears about settled
602+ * dimensions: notifying on every drag step makes the shell reprint its
603+ * prompt mid-drag, which reads as jitter.
604+ */
605+ private notifyResize(): void {
606+ this.resizeNotified = true;
607+ if (this.resizeNotifyTimer !== null) window.clearTimeout(this.resizeNotifyTimer);
608+ this.resizeNotifyTimer = window.setTimeout(() => {
609+ this.resizeNotifyTimer = null;
610+ if (!this.disposed) this.options.onResize(this.cols, this.rows);
611+ }, 150);
612+ }
613+
614+ focus(): void {
615+ this.input.focus({ preventScroll: true });
616+ }
617+
618+ hasSelection(): boolean {
619+ return this.core.selectionText().length > 0;
620+ }
621+
622+ getSelection(): string {
623+ return this.core.selectionText();
624+ }
625+
626+ getSelectionPosition(): GhosttySelectionPosition | null {
627+ if (!this.selectionAnchorScreen || !this.selectionEndScreen || !this.hasSelection())
628+ return null;
629+ const before =
630+ this.selectionAnchorScreen.y < this.selectionEndScreen.y ||
631+ (this.selectionAnchorScreen.y === this.selectionEndScreen.y &&
632+ this.selectionAnchorScreen.x <= this.selectionEndScreen.x);
633+ return before
634+ ? { start: this.selectionAnchorScreen, end: this.selectionEndScreen }
635+ : { start: this.selectionEndScreen, end: this.selectionAnchorScreen };
636+ }
637+
638+ getSelectionEndClientRect(): { readonly right: number; readonly bottom: number } | null {
639+ const position = this.getSelectionPosition();
640+ if (!position) return null;
641+ const viewportEnd = this.core.screenPointToViewport(position.end.x, position.end.y);
642+ if (!viewportEnd) return null;
643+ const bounds = this.canvas.getBoundingClientRect();
644+ return {
645+ right: bounds.left + CONTENT_PADDING + (viewportEnd.x + 1) * this.metrics.width,
646+ bottom: bounds.top + this.originY + (viewportEnd.y + 1) * this.metrics.height,
647+ };
648+ }
649+
650+ clearSelection(): void {
651+ this.core.clearSelection();
652+ this.selectionEnd = null;
653+ this.selectionAnchorScreen = null;
654+ this.selectionEndScreen = null;
655+ this.selectionMode = "cell";
656+ this.selectionBase = null;
657+ this.setSelectionAutoscroll(0);
658+ this.options.onSelectionChange();
659+ // Selection highlights span rows Ghostty may not mark dirty for this change.
660+ this.forceFullRender = true;
661+ this.requestRender();
662+ }
663+
664+ scrollToBottom(): void {
665+ this.core.scrollToBottom();
666+ this.forceFullRender = true;
667+ this.scrollbarDirty = true;
668+ this.requestRender();
669+ }
670+
671+ isAtBottom(): boolean {
672+ return this.core.isViewportActive();
673+ }
674+
675+ dispose(): void {
676+ if (this.disposed) return;
677+ this.disposed = true;
678+ this.resizeObserver.disconnect();
679+ document.fonts.removeEventListener("loadingdone", this.onFontsLoaded);
680+ this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange);
681+ this.dprMedia = null;
682+ if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer);
683+ if (this.resizeNotifyTimer !== null) {
684+ window.clearTimeout(this.resizeNotifyTimer);
685+ this.resizeNotifyTimer = null;
686+ // Flush the settled dimensions so the PTY keeps the final size even when
687+ // the surface unmounts inside the debounce window.
688+ this.options.onResize(this.cols, this.rows);
689+ }
690+ if (this.frame !== 0) window.cancelAnimationFrame(this.frame);
691+ if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer);
692+ if (this.compositionSuppressionTimer !== null) {
693+ window.clearTimeout(this.compositionSuppressionTimer);
694+ }
695+ this.removeEvents();
696+ this.core.dispose();
697+ if (
698+ this.canvas.parentElement === this.mount ||
699+ this.input.parentElement === this.mount ||
700+ this.scrollbar.parentElement === this.mount
701+ ) {
702+ this.canvas.remove();
703+ this.input.remove();
704+ this.scrollbar.remove();
705+ }
706+ }
707+
708+ private readonly onKeyDown = (event: KeyboardEvent) => {
709+ // Presses handled outside the terminal must also swallow their release:
710+ // beforeKey runs side effects (keybindings, navigation sends), so it cannot
711+ // be consulted again on keyup, and Kitty report-event-types sessions would
712+ // otherwise receive a release for a press the shell never saw.
713+ if (isTerminalAltGraphText(event) || !this.options.beforeKey(event)) {
714+ this.suppressedKeyCodes.add(event.code);
715+ return;
716+ }
717+ if (isTerminalCopyShortcut(event) && this.hasSelection()) {
718+ event.preventDefault();
719+ this.suppressedKeyCodes.add(event.code);
720+ this.options.onCopy(this.getSelection());
721+ return;
722+ }
723+ if (isTerminalPasteShortcut(event)) {
724+ this.suppressedKeyCodes.add(event.code);
725+ const clipboard = navigator.clipboard;
726+ if (typeof clipboard?.readText === "function") {
727+ // Race the async clipboard read against the browser's own paste event:
728+ // the native event (dispatched synchronously with the default action)
729+ // always claims the token first when it fires, and the read covers
730+ // browsers whose paste shortcut produces no paste event. Not preventing
731+ // the default keeps the native path alive when the read is denied.
732+ const token = ++this.pasteShortcutToken;
733+ void clipboard.readText().then(
734+ (text) => {
735+ if (this.disposed || this.pasteShortcutToken !== token) return;
736+ this.pasteShortcutToken += 1;
737+ if (text.length > 0) this.options.onData(this.core.encodePaste(text));
738+ },
739+ () => {
740+ // Clipboard read denied; the native paste event remains the path.
741+ },
742+ );
743+ }
744+ return;
745+ }
746+ // keyCode 229 is Safari's only signal that this keydown opens an IME
747+ // composition; encoding it would double the committed text.
748+ if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) {
749+ return;
750+ }
751+ const data = this.core.encodeKey(event);
752+ if (data.length === 0) return;
753+ this.suppressedKeyCodes.delete(event.code);
754+ event.preventDefault();
755+ event.stopPropagation();
756+ this.options.onData(data);
757+ };
758+
759+ private readonly onKeyUp = (event: KeyboardEvent) => {
760+ if (this.suppressedKeyCodes.delete(event.code)) return;
761+ if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) {
762+ return;
763+ }
764+ // Ghostty's encoder only emits release codes when the terminal enabled the
765+ // Kitty report-event-types flag, so legacy sessions send nothing here.
766+ const data = this.core.encodeKey(event, "release");
767+ if (data.length === 0) return;
768+ event.preventDefault();
769+ event.stopPropagation();
770+ this.options.onData(data);
771+ };
772+
773+ private readonly onFocus = () => {
774+ this.focused = true;
775+ this.cursorOn = true;
776+ this.requestRender();
777+ };
778+
779+ private readonly onBlur = () => {
780+ this.focused = false;
781+ // Suppressions survive blur deliberately: a shortcut that moves focus (for
782+ // example terminal-toggle) must still swallow its own keyup if focus comes
783+ // back before release. Stale entries are harmless — an encoding keydown
784+ // always removes its code first.
785+ // The steady unfocused hollow cursor must not inherit an off blink phase.
786+ this.cursorOn = true;
787+ this.requestRender();
788+ };
789+
790+ private readonly onDevicePixelRatioChange = () => {
791+ this.watchDevicePixelRatio();
792+ this.fit();
793+ };
794+
795+ private watchDevicePixelRatio(): void {
796+ this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange);
797+ // A resolution media query only fires once for the ratio it was created at,
798+ // so re-arm it after every change (monitor moves, browser zoom).
799+ this.dprMedia = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
800+ this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange);
801+ }
802+
803+ private readonly onPaste = (event: ClipboardEvent) => {
804+ // Always suppress the browser's default insertion: content the textarea
805+ // would receive (for example an html-only clipboard converted to text)
806+ // leaks through onInput without bracketed-paste encoding.
807+ event.preventDefault();
808+ const data = event.clipboardData?.getData("text/plain") ?? "";
809+ if (data.length === 0) return;
810+ // The native paste won the race with actual text; a pending clipboard read
811+ // must not double. An empty native paste leaves the read as the only path.
812+ this.pasteShortcutToken += 1;
813+ this.options.onData(this.core.encodePaste(data));
814+ };
815+
816+ private readonly onCompositionStart = () => {
817+ this.clearCompositionInputSuppression();
818+ this.composing = true;
819+ };
820+
821+ private readonly onCompositionEnd = (event: CompositionEvent) => {
822+ this.composing = false;
823+ const data = this.input.value || event.data;
824+ if (data.length > 0) this.options.onData(data);
825+ this.input.value = "";
826+ this.compositionInputToSuppress = data;
827+ this.compositionSuppressionTimer = window.setTimeout(() => {
828+ this.compositionInputToSuppress = null;
829+ this.compositionSuppressionTimer = null;
830+ }, 100);
831+ };
832+
833+ private readonly onInput = (event: Event) => {
834+ const inputEvent = event as InputEvent;
835+ if (this.composing || inputEvent.isComposing) return;
836+ const data = this.input.value || inputEvent.data || "";
837+ if (data === this.compositionInputToSuppress && isTerminalCompositionCommitInput(inputEvent)) {
838+ this.clearCompositionInputSuppression();
839+ this.input.value = "";
840+ return;
841+ }
842+ this.clearCompositionInputSuppression();
843+ if (data.length > 0) this.options.onData(data);
844+ this.input.value = "";
845+ };
846+
847+ private clearCompositionInputSuppression(): void {
848+ if (this.compositionSuppressionTimer !== null) {
849+ window.clearTimeout(this.compositionSuppressionTimer);
850+ this.compositionSuppressionTimer = null;
851+ }
852+ this.compositionInputToSuppress = null;
853+ }
854+
855+ private readonly onPointerDown = (event: PointerEvent) => {
856+ this.focus();
857+ if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) {
858+ const button = ghosttyMouseButton(event.button);
859+ if (button === null) return;
860+ event.preventDefault();
861+ event.stopPropagation();
862+ this.mouseReportingPointerId = event.pointerId;
863+ this.mouseReportingButton = button;
864+ this.sendMouse("press", button, event);
865+ this.canvas.setPointerCapture(event.pointerId);
866+ return;
867+ }
868+ if (event.button !== 0) return;
869+ if (isTerminalLinkPointerGesture(event)) {
870+ event.preventDefault();
871+ event.stopPropagation();
872+ this.linkActivationPointerId = event.pointerId;
873+ this.canvas.setPointerCapture(event.pointerId);
874+ return;
875+ }
876+ const cell = this.cellAt(event.clientX, event.clientY);
877+ this.selectionMoved = false;
878+ this.selectionClickSequence = advanceTerminalSelectionClickSequence(
879+ this.selectionClickSequence,
880+ event,
881+ );
882+ const clickCount = this.selectionClickSequence.count;
883+ this.selectionMode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : "cell";
884+ const range =
885+ this.selectionMode === "line"
886+ ? this.core.selectLine(cell.x, cell.y)
887+ : this.selectionMode === "word"
888+ ? this.core.selectWord(cell.x, cell.y)
889+ : null;
890+ if (range) {
891+ this.selectionBase = range.screen;
892+ this.selectionEnd = range.viewport.end;
893+ this.selectionAnchorScreen = range.screen.start;
894+ this.selectionEndScreen = range.screen.end;
895+ this.options.onSelectionChange();
896+ } else {
897+ this.selectionMode = "cell";
898+ this.selectionBase = null;
899+ this.selectionEnd = cell;
900+ const screen = this.core.viewportPointToScreen(cell.x, cell.y);
901+ this.selectionAnchorScreen = screen;
902+ this.selectionEndScreen = screen;
903+ if (screen) {
904+ this.core.setSelection({ ...screen, tag: 2 }, { ...screen, tag: 2 });
905+ } else {
906+ this.core.setSelection(cell, cell);
907+ }
908+ }
909+ this.forceFullRender = true;
910+ this.canvas.setPointerCapture(event.pointerId);
911+ this.requestRender();
912+ };
913+
914+ private readonly onPointerMove = (event: PointerEvent) => {
915+ if (this.linkActivationPointerId === event.pointerId) return;
916+ // Hover motion is only reportable in any-event tracking (DEC 1003); normal and
917+ // button-event tracking never report motion without a captured pressed button.
918+ if (
919+ this.mouseReportingPointerId === event.pointerId ||
920+ shouldReportTerminalMouse(this.core.isMouseAnyEventTracking(), event)
921+ ) {
922+ event.preventDefault();
923+ this.canvas.style.cursor = "default";
924+ this.sendMouse("motion", this.buttonFromButtons(event.buttons), event);
925+ return;
926+ }
927+ if (!this.selectionAnchorScreen || !this.canvas.hasPointerCapture(event.pointerId)) {
928+ this.updateHoverCursor(event);
929+ return;
930+ }
931+ this.selectionPointer = { x: event.clientX, y: event.clientY };
932+ const bounds = this.canvas.getBoundingClientRect();
933+ this.setSelectionAutoscroll(
934+ event.clientY < bounds.top ? -1 : event.clientY > bounds.bottom ? 1 : 0,
935+ );
936+ const cell = this.cellAt(event.clientX, event.clientY);
937+ if (cell.x === this.selectionEnd?.x && cell.y === this.selectionEnd.y) return;
938+ this.extendSelectionTo(event.clientX, event.clientY);
939+ };
940+
941+ private extendSelectionTo(clientX: number, clientY: number): void {
942+ const anchorScreen = this.selectionAnchorScreen;
943+ if (anchorScreen === null) return;
944+ const cell = this.cellAt(clientX, clientY);
945+ this.selectionMoved = true;
946+ this.selectionEnd = cell;
947+ const range =
948+ this.selectionMode === "line"
949+ ? this.core.selectLine(cell.x, cell.y)
950+ : this.selectionMode === "word"
951+ ? this.core.selectWord(cell.x, cell.y)
952+ : null;
953+ const cellScreen = this.core.viewportPointToScreen(cell.x, cell.y);
954+ if (cellScreen === null) return;
955+ const base = this.selectionBase;
956+ const beforeBase =
957+ base !== null &&
958+ (cellScreen.y < base.start.y ||
959+ (cellScreen.y === base.start.y && cellScreen.x < base.start.x));
960+ const anchor = base === null ? anchorScreen : beforeBase ? base.end : base.start;
961+ const end = range === null ? cellScreen : beforeBase ? range.screen.start : range.screen.end;
962+ this.selectionAnchorScreen = anchor;
963+ this.selectionEndScreen = end;
964+ this.core.setSelection({ ...anchor, tag: 2 }, { ...end, tag: 2 });
965+ this.options.onSelectionChange();
966+ this.forceFullRender = true;
967+ this.requestRender();
968+ }
969+
970+ private setSelectionAutoscroll(delta: number): void {
971+ this.selectionScrollDelta = delta;
972+ if (delta === 0) {
973+ if (this.selectionScrollTimer !== null) {
974+ window.clearInterval(this.selectionScrollTimer);
975+ this.selectionScrollTimer = null;
976+ }
977+ return;
978+ }
979+ if (this.selectionScrollTimer !== null) return;
980+ // Dragging past the edge scrolls the viewport and keeps extending the
981+ // selection into the newly revealed rows, like xterm's drag scroller.
982+ this.selectionScrollTimer = window.setInterval(() => {
983+ if (this.disposed || this.selectionScrollDelta === 0) return;
984+ this.scrollViewport(this.selectionScrollDelta);
985+ const pointer = this.selectionPointer;
986+ if (pointer) this.extendSelectionTo(pointer.x, pointer.y);
987+ }, 80);
988+ }
989+
990+ private updateHoverCursor(event: PointerEvent): void {
991+ const overLink =
992+ isTerminalLinkPointerGesture(event) && this.linkAt(event.clientX, event.clientY) !== null;
993+ const cursor = overLink ? "pointer" : "";
994+ if (this.canvas.style.cursor !== cursor) this.canvas.style.cursor = cursor;
995+ }
996+
997+ private readonly onPointerUp = (event: PointerEvent) => {
998+ this.setSelectionAutoscroll(0);
999+ if (this.linkActivationPointerId === event.pointerId) {
1000+ event.preventDefault();
1001+ event.stopPropagation();
1002+ this.linkActivationPointerId = null;
1003+ if (this.canvas.hasPointerCapture(event.pointerId)) {
1004+ this.canvas.releasePointerCapture(event.pointerId);
1005+ }
1006+ if (event.type !== "pointercancel") {
1007+ const link = this.linkAt(event.clientX, event.clientY);
1008+ if (link) this.options.onLinkActivate(link, event);
1009+ }
1010+ return;
1011+ }
1012+ if (this.mouseReportingPointerId === event.pointerId) {
1013+ event.preventDefault();
1014+ event.stopPropagation();
1015+ this.sendMouse("release", this.mouseReportingButton, event);
1016+ this.mouseReportingPointerId = null;
1017+ this.mouseReportingButton = null;
1018+ if (this.canvas.hasPointerCapture(event.pointerId)) {
1019+ this.canvas.releasePointerCapture(event.pointerId);
1020+ }
1021+ return;
1022+ }
1023+ if (this.canvas.hasPointerCapture(event.pointerId)) {
1024+ this.canvas.releasePointerCapture(event.pointerId);
1025+ }
1026+ if (event.button !== 0) return;
1027+ if (!this.selectionMoved && this.selectionMode === "cell") {
1028+ this.clearSelection();
1029+ }
1030+ this.options.onSelectionChange();
1031+ };
1032+
1033+ private readonly onWheel = (event: WheelEvent) => {
1034+ if (event.deltaY === 0) return;
1035+ event.preventDefault();
1036+ const delta = terminalWheelDeltaRows(
1037+ event,
1038+ this.metrics.height,
1039+ this.rows,
1040+ this.wheelRemainder,
1041+ );
1042+ this.wheelRemainder = delta.remainder;
1043+ if (delta.rows === 0) return;
1044+ const magnitude = Math.abs(delta.rows);
1045+ if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) {
1046+ const button = delta.rows < 0 ? 4 : 5;
1047+ for (let index = 0; index < magnitude; index += 1) {
1048+ this.sendMouse("press", button, event);
1049+ }
1050+ return;
1051+ }
1052+ if (this.core.isAlternateScreen()) {
1053+ // The alternate screen has no scrollback: translate wheel motion into
1054+ // arrow keys so full-screen apps like vim and less scroll, matching xterm.
1055+ this.options.onData(terminalWheelArrowData(delta.rows, this.core.isApplicationCursorKeys()));
1056+ return;
1057+ }
1058+ this.scrollViewport(delta.rows);
1059+ };
1060+
1061+ private readonly onMouseDown = (event: MouseEvent) => {
1062+ if (event.button === 0) event.preventDefault();
1063+ this.focus();
1064+ };
1065+
1066+ private readonly onContextMenu = (event: MouseEvent) => {
1067+ if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) {
1068+ event.preventDefault();
1069+ }
1070+ };
1071+
1072+ private readonly onScrollbarPointerDown = (event: PointerEvent) => {
1073+ if (event.button !== 0) return;
1074+ const state = this.readScrollbarState();
1075+ if (state === null) return;
1076+ const bounds = this.scrollbar.getBoundingClientRect();
1077+ const geometry = terminalScrollbarGeometry(state, bounds.height);
1078+ if (geometry === null) return;
1079+ event.preventDefault();
1080+ event.stopPropagation();
1081+ this.scrollbarPointerId = event.pointerId;
1082+ this.scrollbarPointerOffset =
1083+ event.target === this.scrollbarThumb
1084+ ? event.clientY - bounds.top - geometry.thumbTop
1085+ : geometry.thumbHeight / 2;
1086+ this.scrollbar.setPointerCapture(event.pointerId);
1087+ this.scrollbarToPointer(event.clientY, bounds);
1088+ };
1089+
1090+ private readonly onScrollbarPointerMove = (event: PointerEvent) => {
1091+ if (event.pointerId !== this.scrollbarPointerId || this.scrollbarState === null) return;
1092+ event.preventDefault();
1093+ this.scrollbarToPointer(event.clientY, this.scrollbar.getBoundingClientRect());
1094+ };
1095+
1096+ private readonly onScrollbarPointerUp = (event: PointerEvent) => {
1097+ if (event.pointerId !== this.scrollbarPointerId) return;
1098+ event.preventDefault();
1099+ this.scrollbarPointerId = null;
1100+ if (this.scrollbar.hasPointerCapture(event.pointerId)) {
1101+ this.scrollbar.releasePointerCapture(event.pointerId);
1102+ }
1103+ };
1104+
1105+ private readonly onScrollbarKeyDown = (event: KeyboardEvent) => {
1106+ const state = this.readScrollbarState();
1107+ if (state === null) return;
1108+ let delta = 0;
1109+ switch (event.key) {
1110+ case "ArrowUp":
1111+ delta = -1;
1112+ break;
1113+ case "ArrowDown":
1114+ delta = 1;
1115+ break;
1116+ case "PageUp":
1117+ delta = -Math.max(1, state.len);
1118+ break;
1119+ case "PageDown":
1120+ delta = Math.max(1, state.len);
1121+ break;
1122+ case "Home":
1123+ delta = -state.offset;
1124+ break;
1125+ case "End":
1126+ delta = state.total - state.len - state.offset;
1127+ break;
1128+ default:
1129+ return;
1130+ }
1131+ event.preventDefault();
1132+ event.stopPropagation();
1133+ this.scrollViewport(delta);
1134+ };
1135+
1136+ private installEvents(): void {
1137+ this.input.addEventListener("keydown", this.onKeyDown);
1138+ this.input.addEventListener("keyup", this.onKeyUp);
1139+ this.input.addEventListener("focus", this.onFocus);
1140+ this.input.addEventListener("blur", this.onBlur);
1141+ this.input.addEventListener("input", this.onInput);
1142+ this.input.addEventListener("paste", this.onPaste);
1143+ this.input.addEventListener("compositionstart", this.onCompositionStart);
1144+ this.input.addEventListener("compositionend", this.onCompositionEnd);
1145+ this.canvas.addEventListener("pointerdown", this.onPointerDown);
1146+ this.canvas.addEventListener("pointermove", this.onPointerMove);
1147+ this.canvas.addEventListener("pointerup", this.onPointerUp);
1148+ this.canvas.addEventListener("pointercancel", this.onPointerUp);
1149+ this.canvas.addEventListener("wheel", this.onWheel, { passive: false });
1150+ this.canvas.addEventListener("mousedown", this.onMouseDown);
1151+ this.canvas.addEventListener("contextmenu", this.onContextMenu);
1152+ this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown);
1153+ this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove);
1154+ this.scrollbar.addEventListener("pointerup", this.onScrollbarPointerUp);
1155+ this.scrollbar.addEventListener("pointercancel", this.onScrollbarPointerUp);
1156+ this.scrollbar.addEventListener("keydown", this.onScrollbarKeyDown);
1157+ }
1158+
1159+ private removeEvents(): void {
1160+ this.input.removeEventListener("keydown", this.onKeyDown);
1161+ this.input.removeEventListener("keyup", this.onKeyUp);
1162+ this.input.removeEventListener("focus", this.onFocus);
1163+ this.input.removeEventListener("blur", this.onBlur);
1164+ this.input.removeEventListener("input", this.onInput);
1165+ this.input.removeEventListener("paste", this.onPaste);
1166+ this.input.removeEventListener("compositionstart", this.onCompositionStart);
1167+ this.input.removeEventListener("compositionend", this.onCompositionEnd);
1168+ this.canvas.removeEventListener("pointerdown", this.onPointerDown);
1169+ this.canvas.removeEventListener("pointermove", this.onPointerMove);
1170+ this.canvas.removeEventListener("pointerup", this.onPointerUp);
1171+ this.canvas.removeEventListener("pointercancel", this.onPointerUp);
1172+ this.canvas.removeEventListener("wheel", this.onWheel);
1173+ this.canvas.removeEventListener("mousedown", this.onMouseDown);
1174+ this.canvas.removeEventListener("contextmenu", this.onContextMenu);
1175+ this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown);
1176+ this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove);
1177+ this.scrollbar.removeEventListener("pointerup", this.onScrollbarPointerUp);
1178+ this.scrollbar.removeEventListener("pointercancel", this.onScrollbarPointerUp);
1179+ this.scrollbar.removeEventListener("keydown", this.onScrollbarKeyDown);
1180+ }
1181+
1182+ private scrollViewport(deltaRows: number): void {
1183+ let delta = Math.trunc(deltaRows);
1184+ const state = this.readScrollbarState();
1185+ if (state !== null) {
1186+ const maxOffset = Math.max(0, state.total - state.len);
1187+ const offset = Math.max(0, Math.min(state.offset + delta, maxOffset));
1188+ delta = offset - state.offset;
1189+ this.scrollbarState = { ...state, offset };
1190+ }
1191+ if (delta === 0) return;
1192+ this.core.scroll(delta);
1193+ this.forceFullRender = true;
1194+ this.scrollbarDirty = true;
1195+ this.requestRender();
1196+ }
1197+
1198+ private scrollbarToPointer(clientY: number, bounds: DOMRect): void {
1199+ const state = this.scrollbarState;
1200+ if (state === null) return;
1201+ const offset = terminalScrollbarOffsetAtPointer(
1202+ state,
1203+ bounds.height,
1204+ clientY - bounds.top,
1205+ this.scrollbarPointerOffset,
1206+ );
1207+ this.scrollViewport(offset - state.offset);
1208+ }
1209+
1210+ private updateScrollbar(): void {
1211+ const state = this.readScrollbarState();
1212+ const geometry =
1213+ state === null
1214+ ? null
1215+ : terminalScrollbarGeometry(
1216+ state,
1217+ Math.max(0, this.mount.clientHeight - CONTENT_PADDING * 2),
1218+ );
1219+ this.scrollbar.hidden = geometry === null;
1220+ if (state === null || geometry === null) return;
1221+ this.scrollbar.setAttribute("aria-valuemin", "0");
1222+ this.scrollbar.setAttribute("aria-valuemax", String(geometry.maxOffset));
1223+ this.scrollbar.setAttribute(
1224+ "aria-valuenow",
1225+ String(Math.max(0, Math.min(state.offset, geometry.maxOffset))),
1226+ );
1227+ this.scrollbarThumb.style.height = `${geometry.thumbHeight}px`;
1228+ this.scrollbarThumb.style.transform = `translateY(${geometry.thumbTop}px)`;
1229+ }
1230+
1231+ private readScrollbarState(): GhosttyScrollbar | null {
1232+ const state = this.core.scrollbarState();
1233+ this.scrollbarState = state;
1234+ return state;
1235+ }
1236+
1237+ private requestRender(): void {
1238+ if (this.disposed || this.frame !== 0) return;
1239+ this.frame = window.requestAnimationFrame(() => {
1240+ this.frame = 0;
1241+ this.renderFrame();
1242+ });
1243+ }
1244+
1245+ private renderFrame(): void {
1246+ if (this.disposed) return;
1247+ if (this.frame !== 0) {
1248+ window.cancelAnimationFrame(this.frame);
1249+ this.frame = 0;
1250+ }
1251+ this.snapshot = this.core.snapshot();
1252+ if (!this.snapshot.cursorBlinking) this.cursorOn = true;
1253+ // The origin only moves together with a forced full repaint: partial
1254+ // dirty-row redraws must never composite rows at a shifted origin over
1255+ // rows painted at the previous one. Bottom anchoring starts once
1256+ // scrollback exists, i.e. when the prompt actually lives at the bottom.
1257+ const scrollState = this.readScrollbarState();
1258+ const anchorBottom = scrollState !== null && scrollState.total > scrollState.len;
1259+ const nextOriginY = terminalContentOriginY(
1260+ this.mountHeight,
1261+ CONTENT_PADDING,
1262+ this.rows,
1263+ this.metrics.height,
1264+ anchorBottom,
1265+ );
1266+ if (nextOriginY !== this.originY) {
1267+ this.originY = nextOriginY;
1268+ this.forceFullRender = true;
1269+ }
1270+ renderGhosttySnapshot({
1271+ context: this.context,
1272+ snapshot: this.snapshot,
1273+ metrics: this.metrics,
1274+ fontSize: this.fontSize,
1275+ fontFamily: this.fontFamily,
1276+ padding: CONTENT_PADDING,
1277+ originY: this.originY,
1278+ forceFull: this.forceFullRender,
1279+ cursorOn: this.cursorOn,
1280+ previousCursorY: this.renderedCursorY,
1281+ focused: this.focused,
1282+ ...(this.theme.selectionBackground !== undefined
1283+ ? { selectionBackground: this.theme.selectionBackground }
1284+ : {}),
1285+ });
1286+ this.positionInput();
1287+ this.renderedCursorY =
1288+ this.cursorOn && this.snapshot.cursorVisible && this.snapshot.cursorY >= 0
1289+ ? this.snapshot.cursorY
1290+ : null;
1291+ if (this.scrollbarDirty) {
1292+ this.scrollbarDirty = false;
1293+ this.updateScrollbar();
1294+ }
1295+ this.forceFullRender = false;
1296+ this.scheduleCursorBlink();
1297+ }
1298+
1299+ private scheduleCursorBlink(): void {
1300+ if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer);
1301+ this.cursorTimer = null;
1302+ // An unfocused surface shows a steady hollow cursor instead of blinking.
1303+ if (!this.focused || !this.snapshot?.cursorBlinking || !this.snapshot.cursorVisible) return;
1304+ this.cursorTimer = window.setTimeout(() => {
1305+ this.cursorTimer = null;
1306+ this.cursorOn = !this.cursorOn;
1307+ this.requestRender();
1308+ }, 500);
1309+ }
1310+
1311+ private positionInput(): void {
1312+ const snapshot = this.snapshot;
1313+ if (!snapshot || !snapshot.cursorVisible || snapshot.cursorX < 0 || snapshot.cursorY < 0) {
1314+ return;
1315+ }
1316+ // The IME candidate window anchors to the textarea, so it must follow the
1317+ // terminal cursor for composition to appear where the user is typing.
1318+ const left = CONTENT_PADDING + snapshot.cursorX * this.metrics.width;
1319+ const top = this.originY + snapshot.cursorY * this.metrics.height;
1320+ if (left === this.inputLeft && top === this.inputTop) return;
1321+ this.inputLeft = left;
1322+ this.inputTop = top;
1323+ this.input.style.left = `${left}px`;
1324+ this.input.style.top = `${top}px`;
1325+ this.input.style.height = `${this.metrics.height}px`;
1326+ }
1327+
1328+ private cellAt(clientX: number, clientY: number): { x: number; y: number } {
1329+ const bounds = this.canvas.getBoundingClientRect();
1330+ return {
1331+ x: Math.max(
1332+ 0,
1333+ Math.min(
1334+ this.cols - 1,
1335+ Math.floor((clientX - bounds.left - CONTENT_PADDING) / this.metrics.width),
1336+ ),
1337+ ),
1338+ y: Math.max(
1339+ 0,
1340+ Math.min(
1341+ this.rows - 1,
1342+ Math.floor((clientY - bounds.top - this.originY) / this.metrics.height),
1343+ ),
1344+ ),
1345+ };
1346+ }
1347+
1348+ private linkAt(clientX: number, clientY: number): string | null {
1349+ if (!this.snapshot) return null;
1350+ const cell = this.cellAt(clientX, clientY);
1351+ const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y);
1352+ if (explicitHyperlink) return explicitHyperlink;
1353+ return terminalLinkAtPosition(this.snapshot.rowData, cell.y, cell.x);
1354+ }
1355+
1356+ private sendMouse(
1357+ action: "press" | "release" | "motion",
1358+ button: number | null,
1359+ event: MouseEvent,
1360+ ): void {
1361+ const bounds = this.canvas.getBoundingClientRect();
1362+ const data = this.core.encodeMouse({
1363+ action,
1364+ button,
1365+ mods:
1366+ (event.shiftKey ? 1 : 0) |
1367+ (event.ctrlKey ? 1 << 1 : 0) |
1368+ (event.altKey ? 1 << 2 : 0) |
1369+ (event.metaKey ? 1 << 3 : 0),
1370+ x: Math.max(0, event.clientX - bounds.left),
1371+ y: Math.max(0, event.clientY - bounds.top),
1372+ screenWidth: bounds.width,
1373+ screenHeight: bounds.height,
1374+ cellWidth: this.metrics.width,
1375+ cellHeight: this.metrics.height,
1376+ paddingLeft: CONTENT_PADDING,
1377+ paddingRight: CONTENT_PADDING,
1378+ paddingTop: this.originY,
1379+ paddingBottom: Math.max(0, bounds.height - this.originY - this.rows * this.metrics.height),
1380+ anyButtonPressed: event.buttons !== 0,
1381+ });
1382+ if (data.length > 0) this.options.onData(data);
1383+ }
1384+
1385+ private buttonFromButtons(buttons: number): number | null {
1386+ if ((buttons & 1) !== 0) return 1;
1387+ if ((buttons & 4) !== 0) return 3;
1388+ if ((buttons & 2) !== 0) return 2;
1389+ if ((buttons & 8) !== 0) return 4;
1390+ if ((buttons & 16) !== 0) return 5;
1391+ return null;
1392+ }
1393+}
apps/web/src/terminal/ghostty/vendor/ghostty-vt.wasm+0−0

Binary file not shown.

apps/web/src/terminal/ghostty/vendor/ghostty-write-pty.wasm+0−0

Binary file not shown.

apps/web/vite.config.ts+1−0
@@ -126,6 +126,7 @@ const allowedHosts = [".ts.net", ...configuredAllowedHosts]; · 125 unmodified lines
126126
127127 export default defineConfig(() => {
128128 return {
129+ assetsInclude: ["**/*.wasm"],
129130 plugins: [
130131 tanstackRouter(),
131132 react(),
docs/architecture/terminal-renderers.md+42−0
@@ -0,0 +1,42 @@
1+# Terminal renderers
2+
3+Terminal sessions remain server-owned PTYs. Clients receive the existing raw byte stream and send
4+input and resize events over the existing terminal contracts; renderer choices never cross the
5+wire.
6+
7+## Ghostty alignment
8+
9+Android and web use the official `libghostty-vt` C ABI for parsing, terminal state, grapheme
10+boundaries, keyboard encoding, selection, and scrollback:
11+
12+- Android links the native shared library and converts render state into a compact JNI snapshot.
13+- Web loads a separately cached WebAssembly build and reads render state into a Canvas 2D surface.
14+- Both artifacts are built from the revision in
15+ `apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION`.
16+
17+The platform adapters deliberately own only platform behavior. Android owns its Kotlin Canvas and
18+touch integration. Web owns browser font shaping, the hidden IME textarea, clipboard and DOM input,
19+and its Canvas renderer. The web adapter also delegates application mouse encoding, word and line
20+selection, and OSC 8 hyperlink metadata to the official ABI. Browser conventions remain available:
21+holding Shift bypasses application mouse capture, and the platform link modifier opens hyperlinks.
22+React does not participate in terminal frames.
23+
24+The web runtime is singleton-scoped per browser tab so split terminals share one compiled module
25+and memory. Each visible terminal owns and frees its own terminal, render state, row iterator, cell
26+iterator, key and mouse encoder, and input event handles. Restoring captured scrollback temporarily
27+detaches the PTY callback so historical device queries cannot emit replies into the current shell.
28+
29+## Updating Ghostty
30+
31+Update and rebuild Android first, because mobile's `VERSION` file is the single source of truth for
32+the upstream pin (the upstream `LICENSE` lives beside it). Then run:
33+
34+```sh
35+pnpm --dir apps/web build:ghostty-wasm
36+```
37+
38+Commit the regenerated web `wasm` artifacts. The build embeds the pinned revision into the binary as
39+semver build metadata, and the focused web ABI test reads it back through `ghostty_build_info` and
40+compares it against mobile's `VERSION` — so the web vendor directory holds only the artifacts, drift
41+cannot hide, and there is no second pin to keep in sync. The same test enforces the artifact budget
42+and exercises repeated create/write/free cycles with multi-codepoint graphemes.
pnpm-lock.yaml+0−16
@@ -570,12 +570,6 @@ importers: · 569 unmodified lines
570570 '@tanstack/react-router':
571571 specifier: ^1.160.2
572572 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
579573 class-variance-authority:
580574 specifier: ^0.7.1
581575 version: 0.7.1
@@ -5156,12 +5150,6 @@ packages: · 4574 unmodified lines
51565150 resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
51575151 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−
51655153 '@yuuang/ffi-rs-android-arm64@1.3.2':
51665154 resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==}
51675155 engines: {node: '>= 12'}
@@ -15283,10 +15271,6 @@ snapshots: · 10115 unmodified lines
1528315271
1528415272 '@xmldom/xmldom@0.9.10': {}
1528515273
15286− '@xterm/addon-fit@0.11.0': {}
15287−
15288− '@xterm/xterm@6.0.0': {}
15289−
1529015274 '@yuuang/ffi-rs-android-arm64@1.3.2':
1529115275 optional: true
1529215276