diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 0aa9f30a2ff2..3eaaf508e31f 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -52,6 +52,7 @@ jobs: args: - --filter=@t3tools/mobile... - --filter=@t3tools/scripts... + - --filter=t3... - name: Expose pnpm run: | @@ -100,6 +101,7 @@ jobs: args: - --filter=@t3tools/mobile... - --filter=@t3tools/scripts... + - --filter=t3... - name: Expose pnpm run: | diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 92e300004277..2db85dafc4da 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -57,7 +57,7 @@ describe("ElectronProtocol", () => { assert.equal(yield* Effect.promise(() => response.text()), "ok"); assert.include( response.headers.get("content-security-policy") ?? "", - "script-src 'self' 'unsafe-inline' https://clerk.t3.codes https://challenges.cloudflare.com", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://clerk.t3.codes https://challenges.cloudflare.com", ); assert.include( response.headers.get("content-security-policy") ?? "", @@ -212,6 +212,7 @@ describe("ElectronProtocol", () => { assert.deepEqual(directives["script-src"], [ "'self'", "'unsafe-inline'", + "'wasm-unsafe-eval'", "https://clerk.t3.codes", "https://challenges.cloudflare.com", ]); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 03c7ef64fd73..9d5a47806b38 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -71,6 +71,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat const scriptSources = [ "'self'", "'unsafe-inline'", + "'wasm-unsafe-eval'", ...(clerkOrigin ? [clerkOrigin] : []), "https://challenges.cloudflare.com", ]; diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 0147bb71a862..9a51373cfd05 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -181,6 +181,9 @@ const config: ExpoConfig = { ios: { icon: variant.assets.iosIcon, supportsTablet: true, + // Multitasking-capable iPad apps cannot rotate programmatically, so the + // showcase capture build requires full screen (see infoPlist below). + requireFullScreen: process.env.T3_SHOWCASE_CAPTURE_BUILD === "1", bundleIdentifier: iosBundleIdentifier, // Pin code signing to the T3 Tools team so non-interactive `expo run:ios` // does not fall back to a personal team (which cannot sign app groups, @@ -197,6 +200,21 @@ const config: ExpoConfig = { NSLocalNetworkUsageDescription: "Allow T3 Code to connect to T3 Code servers on your local network or tailnet.", ITSAppUsesNonExemptEncryption: false, + // The App Store screenshot harness rotates the iPad interface from + // inside the app (CI denies osascript the Accessibility access that + // Simulator menu scripting needs), and iPadOS ignores programmatic + // orientation requests for multitasking-capable apps — so the capture + // build opts out of multitasking and declares landscape support. + ...(process.env.T3_SHOWCASE_CAPTURE_BUILD === "1" + ? { + "UISupportedInterfaceOrientations~ipad": [ + "UIInterfaceOrientationPortrait", + "UIInterfaceOrientationPortraitUpsideDown", + "UIInterfaceOrientationLandscapeLeft", + "UIInterfaceOrientationLandscapeRight", + ], + } + : {}), }, }, android: { diff --git a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift index 7781b164a2c8..23cf4720d8c0 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3NativeControlsModule.swift @@ -1,5 +1,6 @@ import ExpoModulesCore import Security +import UIKit public final class T3NativeControlsModule: Module { public func definition() -> ModuleDefinition { @@ -32,6 +33,50 @@ public final class T3NativeControlsModule: Module { return arguments[flagIndex + 1] } + Function("getShowcaseOrientation") { () -> String? in + let arguments = ProcessInfo.processInfo.arguments + guard + let flagIndex = arguments.firstIndex(of: "--showcaseOrientation"), + arguments.indices.contains(flagIndex + 1) + else { + return nil as String? + } + return arguments[flagIndex + 1] + } + + // Rotates the interface without Simulator menu UI scripting, which CI + // runners cannot perform (osascript is denied Accessibility access there). + AsyncFunction("applyShowcaseOrientation") { (orientation: String) in + guard #available(iOS 16.0, *) else { return } + let mask: UIInterfaceOrientationMask = orientation == "landscape" ? .landscapeRight : .portrait + for case let windowScene as UIWindowScene in UIApplication.shared.connectedScenes { + windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { error in + NSLog("T3NativeControls applyShowcaseOrientation(\(orientation)) failed: \(error)") + } + for window in windowScene.windows { + window.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() + } + } + }.runOnQueue(.main) + + // The geometry request above can fail transiently (for example before the + // scene is foreground-active), so callers poll this until it settles. + // Screen bounds — not the scene's interface orientation — decide the + // answer because they match the captured framebuffer: with iPadOS + // windowing active, a floating landscape window still reports a portrait + // screen, and screenshots would come out portrait. + AsyncFunction("getInterfaceOrientation") { () -> String in + guard + let windowScene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first + else { + return "unknown" + } + let bounds = windowScene.screen.coordinateSpace.bounds + return bounds.width > bounds.height ? "landscape" : "portrait" + }.runOnQueue(.main) + Function("prepareShowcaseCapture") { for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { SecItemDelete([kSecClass as String: itemClass] as CFDictionary) diff --git a/apps/mobile/modules/t3-terminal/README.md b/apps/mobile/modules/t3-terminal/README.md index 768e3c0704c1..32670b893c70 100644 --- a/apps/mobile/modules/t3-terminal/README.md +++ b/apps/mobile/modules/t3-terminal/README.md @@ -40,7 +40,7 @@ fails, run `xcodebuild -downloadComponent MetalToolchain`. ## Rebuilding libghostty-vt for Android The checked-in Android shared libraries and headers are pinned to the revision recorded in -`Vendor/libghostty-vt/VERSION`. Set `ANDROID_NDK_HOME` and run: +`native/libghostty-vt/VERSION` at the repository root. Set `ANDROID_NDK_HOME` and run: ```bash apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh diff --git a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md index 990dd4bbe9ca..b06f18eadce5 100644 --- a/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md +++ b/apps/mobile/modules/t3-terminal/THIRD_PARTY_NOTICES.md @@ -18,13 +18,14 @@ Ghostty's MIT license applies to the vendored framework. Keep this notice in syn ## Ghostty / libghostty-vt The Android terminal renderer vendors upstream `libghostty-vt` shared libraries and C headers. +The web terminal vendors a WebAssembly build from the same revision and uses the same C ABI. - Upstream project: https://github.com/ghostty-org/ghostty - Vendored revision: `9f62873bf195e4d8a762d768a1405a5f2f7b1697` - License: MIT -Ghostty's MIT license applies to the vendored Android libraries. Keep this notice in sync when -updating `Vendor/libghostty-vt`. +Ghostty's MIT license applies to the vendored Android and web libraries. Keep this notice and both +artifacts in sync when updating the repository-root `native/libghostty-vt`. ## MesloLGS NF (Android terminal font) diff --git a/apps/mobile/modules/t3-terminal/android/src/main/cpp/CMakeLists.txt b/apps/mobile/modules/t3-terminal/android/src/main/cpp/CMakeLists.txt index 0273ff6c8648..a43a2981b67e 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/cpp/CMakeLists.txt +++ b/apps/mobile/modules/t3-terminal/android/src/main/cpp/CMakeLists.txt @@ -13,7 +13,7 @@ add_library(t3terminal SHARED t3_terminal_jni.cpp) target_include_directories( t3terminal - PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../../../../Vendor/libghostty-vt/include" + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../../../../native/libghostty-vt/include" ) target_link_options( diff --git a/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh index 7b9aa9b6dc63..55d994d3b363 100755 --- a/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh +++ b/apps/mobile/modules/t3-terminal/scripts/build-libghostty-android.sh @@ -4,7 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MODULE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" -VENDOR_DIR="${MODULE_DIR}/Vendor/libghostty-vt" +VENDOR_DIR="${MODULE_DIR}/../../../../native/libghostty-vt" PATCH_DIR="${SCRIPT_DIR}/libghostty-android-patches" GHOSTTY_REVISION="${GHOSTTY_REVISION:-9f62873bf195e4d8a762d768a1405a5f2f7b1697}" diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index 9a4272824bae..ffeca9671b75 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -9,6 +9,8 @@ import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; import { holdEditingQueuedMessage } from "../../state/use-thread-outbox"; import { useWorkspaceState } from "../../state/workspace"; import { + applyNativeShowcaseOrientation, + getNativeShowcaseOrientation, getNativeShowcasePairingUrls, getNativeShowcaseScene, markNativeShowcaseReady, @@ -47,6 +49,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) const [pendingTasksReady, setPendingTasksReady] = useState(false); const [requestedScene, setRequestedScene] = useState(null); const [readyScene, setReadyScene] = useState(null); + const [orientationSettled, setOrientationSettled] = useState(false); useEffect(() => { if (!SHOWCASE_ENABLED || pairingUrls.length > 0) return; @@ -60,6 +63,25 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) return () => clearInterval(interval); }, [pairingUrls.length]); + useEffect(() => { + if (!SHOWCASE_ENABLED || orientationSettled) return; + const orientation = getNativeShowcaseOrientation(); + if (orientation === null) { + setOrientationSettled(true); + return; + } + + let cancelled = false; + void retryShowcaseOperation(async () => applyNativeShowcaseOrientation(orientation), { + isCancelled: () => cancelled, + }).then((applied) => { + if (!cancelled && applied) setOrientationSettled(true); + }); + return () => { + cancelled = true; + }; + }, [orientationSettled]); + useEffect(() => { if (!SHOWCASE_ENABLED) return; @@ -182,7 +204,10 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) scene === null || requestedScene === null || scene !== requestedScene || - !hasFixture + !hasFixture || + // Never report a scene ready while the capture orientation is still + // being applied — a screenshot taken early has the wrong dimensions. + !orientationSettled ) { setReadyScene(null); return; @@ -210,7 +235,7 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) if (renderFrame !== null) cancelAnimationFrame(renderFrame); if (readyFrame !== null) cancelAnimationFrame(readyFrame); }; - }, [hasFixture, requestedScene, scene]); + }, [hasFixture, orientationSettled, requestedScene, scene]); if (!SHOWCASE_ENABLED || readyScene === null) return null; diff --git a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts index 1f2e263ebf1b..07ca60cf5333 100644 --- a/apps/mobile/src/features/showcase/nativeShowcaseScene.ts +++ b/apps/mobile/src/features/showcase/nativeShowcaseScene.ts @@ -3,9 +3,14 @@ import { requireOptionalNativeModule } from "expo"; export const SHOWCASE_SCENES = ["threads", "thread", "terminal", "review", "environments"] as const; export type ShowcaseScene = (typeof SHOWCASE_SCENES)[number]; +export type ShowcaseOrientation = "portrait" | "landscape"; + interface NativeShowcaseControls { readonly getShowcasePairingUrl?: () => string | null; readonly getShowcaseScene?: () => string | null; + readonly getShowcaseOrientation?: () => string | null; + readonly applyShowcaseOrientation?: (orientation: ShowcaseOrientation) => Promise; + readonly getInterfaceOrientation?: () => Promise; readonly prepareShowcaseCapture?: () => void; readonly markShowcaseReady?: (scene: ShowcaseScene) => void; } @@ -59,6 +64,35 @@ export function prepareNativeShowcaseCapture(): void { } } +export function getNativeShowcaseOrientation(): ShowcaseOrientation | null { + try { + const orientation = nativeShowcaseControls()?.getShowcaseOrientation?.()?.trim(); + return orientation === "portrait" || orientation === "landscape" ? orientation : null; + } catch { + return null; + } +} + +export async function applyNativeShowcaseOrientation( + orientation: ShowcaseOrientation, +): Promise { + const controls = nativeShowcaseControls(); + if (!controls?.applyShowcaseOrientation || !controls.getInterfaceOrientation) { + // A development build that predates this helper keeps its default + // orientation; report success so callers do not retry forever. + return true; + } + try { + await controls.applyShowcaseOrientation(orientation); + // The geometry request settles asynchronously; confirm it took effect so + // callers can retry attempts made before the scene was foreground-active. + await new Promise((resolve) => setTimeout(resolve, 500)); + return (await controls.getInterfaceOrientation()) === orientation; + } catch { + return false; + } +} + export function markNativeShowcaseReady(scene: ShowcaseScene): void { try { nativeShowcaseControls()?.markShowcaseReady?.(scene); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 82398748cf25..ab60749e3896 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -9,6 +9,7 @@ import * as NetService from "@t3tools/shared/Net"; import packageJson from "../package.json" with { type: "json" }; import { authCommand } from "./cli/auth.ts"; import { connectCommand } from "./cli/connect.ts"; +import { pairCommand } from "./cli/pair.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; @@ -48,6 +49,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => Command.withSubcommands([ startCommand, serveCommand, + pairCommand, authCommand, projectCommand, serviceCommand, diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts new file mode 100644 index 000000000000..c4f321a5a61e --- /dev/null +++ b/apps/server/src/cli/pair.test.ts @@ -0,0 +1,268 @@ +// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries. +import * as NodeHttp from "node:http"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NetService from "@t3tools/shared/Net"; +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/unstable/cli"; + +import { cli } from "../bin.ts"; +import { + makePersistedServerRuntimeState, + persistServerRuntimeState, + type PersistedServerRuntimeState, +} from "../serverRuntimeState.ts"; +import { + DevServerNotProxiableError, + resolveDirectPairingBaseUrl, + resolveTailscaleLocalTarget, +} from "./pair.ts"; + +const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); + +const baseState = { + version: 1, + pid: 123, + port: 3_773, + origin: "http://127.0.0.1:3773", + startedAt: "2026-06-20T00:00:00.000Z", +} as const satisfies PersistedServerRuntimeState; + +describe("pair base URL selection", () => { + it("pairs through the dev web origin when the server fronts a dev server", () => { + expect(resolveDirectPairingBaseUrl({ ...baseState, devUrl: "http://localhost:5733/" })).toBe( + "http://localhost:5733/", + ); + }); + + it("pairs through the bound host when there is no dev server", () => { + expect(resolveDirectPairingBaseUrl({ ...baseState, host: "100.64.0.7" })).toBe( + "http://100.64.0.7:3773", + ); + expect(resolveDirectPairingBaseUrl(baseState)).toBe("http://localhost:3773"); + }); +}); + +describe("pair tailscale local target", () => { + it("proxies the dev web port for dev servers", () => { + expect(resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://localhost:5733/" })).toEqual( + { localPort: 5_733 }, + ); + // A dev server on a non-loopback interface must be proxied at that + // interface; tailscale serve defaults to 127.0.0.1 otherwise. + expect( + resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://192.168.1.10:5733/" }), + ).toEqual({ localPort: 5_733, localHost: "192.168.1.10" }); + // URL.hostname keeps IPv6 brackets, so the serve target stays valid. + expect( + resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://[fd7a:115c::1]:5733/" }), + ).toEqual({ localPort: 5_733, localHost: "[fd7a:115c::1]" }); + }); + + it("rejects HTTPS dev URLs, which tailscale serve cannot proxy", () => { + expect( + resolveTailscaleLocalTarget({ ...baseState, devUrl: "https://localhost:5733/" }), + ).toBeInstanceOf(DevServerNotProxiableError); + }); + + it("proxies the backend port directly otherwise", () => { + expect(resolveTailscaleLocalTarget(baseState)).toEqual({ localPort: 3_773 }); + expect(resolveTailscaleLocalTarget({ ...baseState, host: "0.0.0.0" })).toEqual({ + localPort: 3_773, + }); + expect(resolveTailscaleLocalTarget({ ...baseState, host: "192.168.1.42" })).toEqual({ + localPort: 3_773, + localHost: "192.168.1.42", + }); + }); +}); + +const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args); + +const provideCliTestLayers = (effect: Effect.Effect) => + Effect.provide(effect, Layer.mergeAll(CliRuntimeLayer, TestConsole.layer)); + +// Console output accumulates across CLI runs within a test, and each +// Console.log call is one entry — so the latest command's output is the last +// entry, even when it spans many lines. +const captureStdout = (effect: Effect.Effect) => + provideCliTestLayers( + Effect.gen(function* () { + yield* effect; + return ( + (yield* TestConsole.logLines).findLast( + (line): line is string => typeof line === "string", + ) ?? "" + ); + }), + ); + +const testDescriptor = { + environmentId: "pair-test-environment", + label: "pair-test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.1", + capabilities: { repositoryIdentity: true }, +}; + +const withDescriptorServer = (run: (origin: string) => Effect.Effect) => + Effect.acquireUseRelease( + Effect.callback((resume) => { + const server = NodeHttp.createServer((request, response) => { + if (request.url === "/.well-known/t3/environment") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(testDescriptor)); + return; + } + response.writeHead(404); + response.end(); + }); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => { + const address = server.address(); + if (address === null || typeof address === "string") { + return Effect.die(new Error("Expected a TCP address")); + } + return run(`http://127.0.0.1:${String(address.port)}`); + }, + (server) => Effect.sync(() => server.close()), + ); + +describe("t3 pair", () => { + it.effect("mints a token and prints a QR pairing URL for a live server", () => + withDescriptorServer((origin) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-test-")); + const port = Number(new URL(origin).port); + const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json"); + yield* persistServerRuntimeState({ + path: statePath, + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port, + }), + }); + + const output = yield* captureStdout(runCli(["pair", "--base-dir", baseDir])); + + assert.include(output, `Pairing with pair-test (${origin})`); + assert.include(output, `Pairing URL: ${origin}/pair#token=`); + assert.isTrue(output.includes("█") || output.includes("▀") || output.includes("▄")); + // Loopback origins are not reachable from a phone; the output must say so. + assert.include(output, "only reachable from this machine"); + + const token = /#token=([A-Z2-9]+)/.exec(output)?.[1]; + assert.isString(token); + + // The token must be in the same store the running server reads. + const listed = yield* captureStdout( + runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO. + const credentials = JSON.parse(listed) as ReadonlyArray<{ readonly label?: string }>; + assert.equal(credentials.length, 1); + assert.equal(credentials[0]?.label, "t3 pair"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("pairs through the recorded dev web URL for dev servers", () => + withDescriptorServer((origin) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-dev-test-")); + const port = Number(new URL(origin).port); + const statePath = NodePath.join(baseDir, "dev", "server-runtime.json"); + yield* persistServerRuntimeState({ + path: statePath, + state: yield* makePersistedServerRuntimeState({ + config: { host: undefined, devUrl: new URL("http://localhost:5733") }, + port, + }), + }); + + const output = yield* captureStdout(runCli(["pair", "--base-dir", baseDir])); + + assert.include(output, "Pairing URL: http://localhost:5733/pair#token="); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("directs to t3 serve or t3 connect when no server is running", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-none-test-")); + + const error = yield* provideCliTestLayers( + runCli(["pair", "--base-dir", baseDir]).pipe(Effect.flip), + ); + + const rendered = String( + typeof error === "object" && error !== null && "cause" in error ? error.cause : error, + ); + assert.include(rendered, "No running T3 Code server found."); + assert.include(rendered, "npx t3 serve"); + assert.include(rendered, "npx t3 connect"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("ignores runtime state whose recorded pid is no longer alive", () => + withDescriptorServer((origin) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-pid-test-")); + const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json"); + // The origin answers (another server reused the port), but the pid + // that wrote this state file is dead — pairing must not mint a token + // into the dead server's database. + const state = yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: Number(new URL(origin).port), + }); + yield* persistServerRuntimeState({ + path: statePath, + // pid 2**22 + 1 exceeds any default Linux/macOS pid range. + state: { ...state, pid: 4_194_305 }, + }); + + const error = yield* provideCliTestLayers( + runCli(["pair", "--base-dir", baseDir]).pipe(Effect.flip), + ); + + const rendered = String( + typeof error === "object" && error !== null && "cause" in error ? error.cause : error, + ); + assert.include(rendered, "No running T3 Code server found."); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("ignores stale runtime state pointing at a dead server", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-stale-test-")); + const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json"); + // A port from the dynamic range with nothing listening: the probe fails + // fast with ECONNREFUSED and discovery moves on. + yield* persistServerRuntimeState({ + path: statePath, + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: 1, + }), + }); + + const error = yield* provideCliTestLayers( + runCli(["pair", "--base-dir", baseDir]).pipe(Effect.flip), + ); + + const rendered = String( + typeof error === "object" && error !== null && "cause" in error ? error.cause : error, + ); + assert.include(rendered, "No running T3 Code server found."); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts new file mode 100644 index 000000000000..38fa3be8bb57 --- /dev/null +++ b/apps/server/src/cli/pair.ts @@ -0,0 +1,542 @@ +/** + * `t3 pair` - mint a pairing token for an already-running server and print it + * as a QR code, without restarting anything. + * + * Discovery reads the `server-runtime.json` a live server persists next to its + * database, then confirms the process is actually answering by fetching its + * public environment descriptor. Inside a linked git worktree the worktree's + * own `.t3` is checked first (matching dev-runner precedence); otherwise the + * shared T3 home. `--tailscale` publishes the server over Tailscale Serve + * HTTPS and pairs through the tailnet URL instead. + */ +import { + AuthStandardClientScopes, + ExecutionEnvironmentDescriptor, + PortSchema, +} from "@t3tools/contracts"; +import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; +import { + buildTailscaleHttpsBaseUrl, + DEFAULT_TAILSCALE_SERVE_PORT, + ensureTailscaleServe, + readTailscaleStatus, +} from "@t3tools/tailscale"; +import * as Config from "effect/Config"; +import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; +import { Command, Flag, GlobalFlag } from "effect/unstable/cli"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerConfig from "../config.ts"; +import { resolveBaseDir } from "../os-jank.ts"; +import { + type PersistedServerRuntimeState, + readPersistedServerRuntimeState, +} from "../serverRuntimeState.ts"; +import { + buildPairingUrl, + formatHostForUrl, + isLoopbackHost, + isWildcardHost, + renderTerminalQrCode, + resolveHeadlessConnectionString, +} from "../startupAccess.ts"; +import { baseDirFlag, DurationFromString } from "./config.ts"; + +const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment"; +const PAIR_PROBE_TIMEOUT = Duration.millis(2_500); +// Tailscale provisions an HTTPS certificate on the first request to a fresh +// serve mapping, which can take a few seconds. +const TAILSCALE_PROBE_ATTEMPTS = 5; +const TAILSCALE_PROBE_RETRY_DELAY = Duration.seconds(1); + +export type PairStateVariant = "userdata" | "dev"; + +// deriveServerPaths only checks devUrl for undefined-ness when picking the +// dev-vs-userdata state directory; the value itself is not used. +const DEV_VARIANT_PLACEHOLDER_URL = new URL("http://localhost"); + +export class NoRunningServerError extends Schema.TaggedErrorClass()( + "NoRunningServerError", + { + checkedStatePaths: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return [ + "No running T3 Code server found.", + ...this.checkedStatePaths.map((statePath) => ` checked ${statePath}`), + "Start one with `npx t3 serve`, or connect this machine with T3 Connect: `npx t3 connect`.", + ].join("\n"); + } +} + +// Each tailscale failure gets its own class (same reasoning as +// scripts/lib/dev-share.ts): distinct caller-visible message, distinct remedy. +export class TailscaleUnavailableError extends Schema.TaggedErrorClass()( + "TailscaleUnavailableError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not talk to Tailscale. Is tailscaled running? Try `tailscale status`."; + } +} + +export class MagicDnsNameMissingError extends Schema.TaggedErrorClass()( + "MagicDnsNameMissingError", + {}, +) { + override get message(): string { + return "This machine has no MagicDNS name. Run `tailscale up` and enable MagicDNS."; + } +} + +export class ServesOtherEnvironmentError extends Schema.TaggedErrorClass()( + "ServesOtherEnvironmentError", + { servePort: Schema.Number }, +) { + override get message(): string { + return `Tailscale Serve on HTTPS port ${String(this.servePort)} already fronts a different T3 Code server. Pass --tailscale-serve-port to publish this one on another port.`; + } +} + +export class TailscaleServeFailedError extends Schema.TaggedErrorClass()( + "TailscaleServeFailedError", + { servePort: Schema.Number, cause: Schema.Defect() }, +) { + override get message(): string { + return `tailscale serve failed for HTTPS port ${String(this.servePort)}. Run \`tailscale serve --https=${String(this.servePort)} --bg \` by hand to see why.`; + } +} + +export class ServePortOccupiedError extends Schema.TaggedErrorClass()( + "ServePortOccupiedError", + { servePort: Schema.Number }, +) { + override get message(): string { + return `HTTPS port ${String(this.servePort)} on the tailnet already serves something that is not a T3 Code server. Pass --tailscale-serve-port to publish this one on another port.`; + } +} + +/** The URL a browser or phone should pair through, absent Tailscale. */ +export const resolveDirectPairingBaseUrl = (state: PersistedServerRuntimeState): string => + state.devUrl ?? resolveHeadlessConnectionString(state.host, state.port); + +export class DevServerNotProxiableError extends Schema.TaggedErrorClass()( + "DevServerNotProxiableError", + { devUrl: Schema.String }, +) { + override get message(): string { + return `Tailscale Serve can only proxy plain-HTTP local targets, and this dev server runs at ${this.devUrl}. Pair without --tailscale instead.`; + } +} + +const isDevServerNotProxiableError = Schema.is(DevServerNotProxiableError); + +/** + * The local endpoint Tailscale Serve should proxy to. Dev servers are + * single-origin, so the web dev server's port is the one to publish; the + * backend rides along behind Vite's proxy. Serve targets are always plain + * HTTP, so an HTTPS dev URL cannot be proxied and is rejected. + */ +export const resolveTailscaleLocalTarget = ( + state: PersistedServerRuntimeState, +): { readonly localPort: number; readonly localHost?: string } | DevServerNotProxiableError => { + if (state.devUrl !== undefined) { + const devUrl = new URL(state.devUrl); + if (devUrl.protocol !== "http:") { + return new DevServerNotProxiableError({ devUrl: state.devUrl }); + } + const localPort = devUrl.port.length > 0 ? Number.parseInt(devUrl.port, 10) : 80; + return isLoopbackHost(devUrl.hostname) + ? { localPort } + : { localPort, localHost: devUrl.hostname }; + } + // A server bound to one specific interface does not answer on loopback, so + // the proxy has to target that interface directly. + if (state.host !== undefined && !isWildcardHost(state.host) && !isLoopbackHost(state.host)) { + return { localPort: state.port, localHost: formatHostForUrl(state.host) }; + } + return { localPort: state.port }; +}; + +export const formatPairOutput = (input: { + readonly serverLabel: string; + readonly origin: string; + readonly pairingUrl: string; + readonly token: string; + readonly expiresAt: DateTime.Utc; + readonly notes: ReadonlyArray; +}): string => + [ + `Pairing with ${input.serverLabel} (${input.origin}).`, + "", + renderTerminalQrCode(input.pairingUrl), + "", + `Pairing URL: ${input.pairingUrl}`, + `Token: ${input.token}`, + `Expires: ${DateTime.formatIso(input.expiresAt)}`, + ...input.notes.flatMap((note) => ["", `Note: ${note}`]), + "", + ].join("\n"); + +/** + * Three outcomes, because they drive different decisions: a T3 descriptor + * (pair with it), nothing answering (safe to configure Tailscale Serve), or + * something answering that is not a T3 server (do NOT overwrite its mapping). + */ +type EnvironmentProbeResult = + | { readonly _tag: "descriptor"; readonly descriptor: ExecutionEnvironmentDescriptor } + | { readonly _tag: "unreachable" } + | { readonly _tag: "not-a-t3-server" }; + +const probeEnvironmentDescriptor = ( + baseUrl: string, +): Effect.Effect => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const request = HttpClientRequest.get(new URL(WELL_KNOWN_ENVIRONMENT_PATH, baseUrl).toString()); + const response = yield* client.execute(request).pipe( + Effect.timeout(PAIR_PROBE_TIMEOUT), + // Transport failure or timeout: nothing (reachable) is listening there. + Effect.mapError(() => ({ _tag: "unreachable" }) as const), + ); + // Bad-gateway family means a proxy (Tailscale Serve) answered for a + // backend that is gone — a stale mapping, not a live occupant. Treating + // it as unreachable lets `t3 pair --tailscale` repair its own mapping + // after the server's port changed. + if (response.status === 502 || response.status === 503 || response.status === 504) { + return { _tag: "unreachable" } as const; + } + // Anything else that answered HTTP but not with a valid descriptor is + // some other service. + const descriptor = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)), + Effect.mapError(() => ({ _tag: "not-a-t3-server" }) as const), + ); + return { _tag: "descriptor", descriptor } as const; + }).pipe(Effect.catch((outcome) => Effect.succeed(outcome))); + +// signal 0 delivers nothing; it only reports whether the pid exists. EPERM +// means it exists but belongs to another user, which still counts as alive. +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error instanceof Error && "code" in error && error.code === "EPERM"; + } +}; + +interface DiscoveredPairTarget { + readonly baseDir: string; + readonly variant: PairStateVariant; + readonly state: PersistedServerRuntimeState; + readonly descriptor: ExecutionEnvironmentDescriptor; +} + +const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* ( + explicitBaseDir: string | undefined, +) { + const bases: Array = []; + if (explicitBaseDir !== undefined && explicitBaseDir.trim().length > 0) { + bases.push(yield* resolveBaseDir(explicitBaseDir)); + } else { + // Same precedence as dev-runner: inside a linked worktree its own `.t3` + // outranks the shared home, so `t3 pair` in a worktree pairs with the dev + // server under test rather than the daily-driver install. + const worktreeHome = yield* resolveWorktreeT3Home(process.cwd()); + if (worktreeHome !== undefined) { + bases.push(worktreeHome); + } + const envHome = yield* Config.string("T3CODE_HOME").pipe(Config.option); + bases.push(yield* resolveBaseDir(Option.getOrUndefined(envHome))); + } + + const checkedStatePaths: Array = []; + for (const baseDir of new Set(bases)) { + for (const variant of ["userdata", "dev"] as const) { + const derivedPaths = yield* ServerConfig.deriveServerPaths( + baseDir, + variant === "dev" ? DEV_VARIANT_PLACEHOLDER_URL : undefined, + {}, + ); + const statePath = derivedPaths.serverRuntimeStatePath; + checkedStatePaths.push(statePath); + const state = yield* readPersistedServerRuntimeState(statePath); + if (Option.isNone(state)) { + continue; + } + // The pid check guards against a dead server's state file whose port + // was since reused by a different server: pairing would then mint a + // token in the old database while the QR code points at the new server. + if (!isProcessAlive(state.value.pid)) { + continue; + } + const probed = yield* probeEnvironmentDescriptor(state.value.origin); + if (probed._tag !== "descriptor") { + continue; + } + return { + baseDir, + variant, + state: state.value, + descriptor: probed.descriptor, + } satisfies DiscoveredPairTarget; + } + } + return yield* new NoRunningServerError({ checkedStatePaths }); +}); + +/** + * Server config pointed at the discovered server's state directory, so the + * minted token lands in the database the running server reads from. Built by + * hand rather than through `resolveServerConfig` to keep the dev-vs-userdata + * choice pinned to where the runtime state was actually found, independent of + * ambient environment variables. + */ +const makePairServerConfig = Effect.fn(function* (input: { + readonly target: DiscoveredPairTarget; + readonly logLevel: ServerConfig.ServerConfig["Service"]["logLevel"]; +}) { + const { baseDir, variant, state } = input.target; + // The state-dir variant does not imply dev-ness: a worktree dev server uses + // an explicit home and therefore lands in `userdata`. The recorded devUrl is + // what actually marks a dev server. + const devUrl = state.devUrl !== undefined ? new URL(state.devUrl) : undefined; + const derivedPaths = yield* ServerConfig.deriveServerPaths( + baseDir, + variant === "dev" ? DEV_VARIANT_PLACEHOLDER_URL : undefined, + {}, + ); + return ServerConfig.make({ + logLevel: input.logLevel, + traceMinLevel: "Info", + traceTimingEnabled: false, + traceBatchWindowMs: 1_000, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + mode: "web", + port: state.port, + host: state.host, + cwd: process.cwd(), + baseDir, + ...derivedPaths, + staticDir: undefined, + devUrl, + devAllowedOrigins: [], + noBrowser: true, + startupPresentation: "headless", + desktopBootstrapToken: undefined, + desktopTelemetryFd: undefined, + desktopTelemetryControlFd: undefined, + resourceMonitorPath: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, + }); +}); + +const awaitEnvironmentDescriptor = Effect.fn(function* (baseUrl: string) { + let last: EnvironmentProbeResult = { _tag: "unreachable" }; + for (let attempt = 0; attempt < TAILSCALE_PROBE_ATTEMPTS; attempt += 1) { + last = yield* probeEnvironmentDescriptor(baseUrl); + if (last._tag === "descriptor") { + return last; + } + yield* Effect.sleep(TAILSCALE_PROBE_RETRY_DELAY); + } + return last; +}); + +const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase")( + function* (input: { readonly target: DiscoveredPairTarget; readonly servePort: number }) { + const notes: Array = []; + const status = yield* readTailscaleStatus.pipe( + Effect.mapError((cause) => new TailscaleUnavailableError({ cause })), + ); + if (status.magicDnsName === null) { + return yield* new MagicDnsNameMissingError(); + } + const baseUrl = buildTailscaleHttpsBaseUrl({ + magicDnsName: status.magicDnsName, + servePort: input.servePort, + }); + + // Only an unreachable port, or a mapping already fronting this exact + // environment, is safe to (re)configure. Any other responder — T3 or not + // — must not have its mapping silently replaced. + const existing = yield* probeEnvironmentDescriptor(baseUrl); + if (existing._tag === "descriptor") { + if (existing.descriptor.environmentId !== input.target.descriptor.environmentId) { + return yield* new ServesOtherEnvironmentError({ servePort: input.servePort }); + } + // Matching environment id proves the mapping reaches this server, but + // not through which port: for a dev server it may front the backend + // (whose /.well-known also answers) while /pair only renders through + // the web origin. Reuse as-is for regular servers; fall through and + // repoint our own mapping at the web port for dev servers. + if (input.target.state.devUrl === undefined) { + return { baseUrl, notes }; + } + } + if (existing._tag === "not-a-t3-server") { + return yield* new ServePortOccupiedError({ servePort: input.servePort }); + } + + const localTarget = resolveTailscaleLocalTarget(input.target.state); + if (isDevServerNotProxiableError(localTarget)) { + return yield* localTarget; + } + yield* ensureTailscaleServe({ + localPort: localTarget.localPort, + servePort: input.servePort, + ...(localTarget.localHost !== undefined ? { localHost: localTarget.localHost } : {}), + }).pipe( + Effect.mapError( + (cause) => new TailscaleServeFailedError({ servePort: input.servePort, cause }), + ), + ); + notes.push( + `Tailscale Serve now maps ${baseUrl} to this server and persists across restarts. Remove it with \`tailscale serve --https=${String(input.servePort)} off\`.`, + ); + + const probed = yield* awaitEnvironmentDescriptor(baseUrl); + if (probed._tag === "descriptor") { + if (probed.descriptor.environmentId !== input.target.descriptor.environmentId) { + return yield* new ServesOtherEnvironmentError({ servePort: input.servePort }); + } + } else { + notes.push( + "The HTTPS endpoint has not answered yet. First use can take a moment while Tailscale provisions certificates.", + ); + } + return { baseUrl, notes }; + }, +); + +const mintPairingLink = Effect.fn("pair.mintPairingLink")(function* (input: { + readonly config: ServerConfig.ServerConfig["Service"]; + readonly ttl: Option.Option; + readonly label: Option.Option; +}) { + return yield* Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + return yield* environmentAuth.createPairingLink({ + scopes: AuthStandardClientScopes, + subject: "one-time-token", + label: Option.getOrElse(input.label, () => "t3 pair"), + ...(Option.isSome(input.ttl) ? { ttl: input.ttl.value } : {}), + }); + }).pipe( + Effect.provide( + EnvironmentAuth.runtimeLayer.pipe( + Layer.provide(ServerConfig.layer(input.config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, input.config.logLevel)), + ), + ), + ); +}); + +const ttlFlag = Flag.string("ttl").pipe( + Flag.withSchema(DurationFromString), + Flag.withDescription( + "Token TTL, for example `5m`, `1h`, or `15 minutes`. Defaults to 5 minutes.", + ), + Flag.optional, +); + +const labelFlag = Flag.string("label").pipe( + Flag.withDescription("Optional label shown in the server's connections list."), + Flag.optional, +); + +const tailscaleFlag = Flag.boolean("tailscale").pipe( + Flag.withDescription( + "Publish the server over Tailscale Serve HTTPS and pair through the tailnet URL.", + ), + Flag.withDefault(false), +); + +const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( + Flag.withSchema(PortSchema), + Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale is enabled."), + Flag.withDefault(DEFAULT_TAILSCALE_SERVE_PORT), +); + +export const pairCommand = Command.make("pair", { + baseDir: baseDirFlag, + ttl: ttlFlag, + label: labelFlag, + tailscale: tailscaleFlag, + tailscaleServePort: tailscaleServePortFlag, +}).pipe( + Command.withDescription( + "Mint a pairing token for a running T3 Code server and print it as a QR code.", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const cliLogLevel = yield* GlobalFlag.LogLevel; + // Default to Warn so storage/migration chatter cannot bury the QR code; + // an explicit --log-level still wins. + const logLevel = Option.getOrElse(cliLogLevel, () => "Warn" as const); + + const target = yield* discoverPairTarget(Option.getOrUndefined(flags.baseDir)); + + const notes: Array = []; + let pairingBaseUrl: string; + if (flags.tailscale) { + const resolved = yield* resolveTailscalePairingBase({ + target, + servePort: flags.tailscaleServePort, + }); + pairingBaseUrl = resolved.baseUrl; + notes.push(...resolved.notes); + } else { + pairingBaseUrl = resolveDirectPairingBaseUrl(target.state); + if (isLoopbackHost(new URL(pairingBaseUrl).hostname)) { + notes.push( + "This URL is only reachable from this machine. Re-run with --tailscale, or restart the server with a reachable --host.", + ); + } + if (target.variant === "dev" && target.state.devUrl === undefined) { + notes.push( + "This dev server did not record its web URL; restart it so pairing can go through the web origin.", + ); + } + } + + const config = yield* makePairServerConfig({ target, logLevel }); + const issued = yield* mintPairingLink({ config, ttl: flags.ttl, label: flags.label }); + const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential); + + yield* Console.log( + formatPairOutput({ + serverLabel: target.descriptor.label, + origin: target.state.origin, + pairingUrl, + token: issued.credential, + expiresAt: issued.expiresAt, + notes, + }), + ); + }).pipe(Effect.provide(FetchHttpClient.layer)), + ), +); diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 655228c047f1..bfac916a59d5 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -545,11 +545,19 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { yield* TestClock.adjust(Duration.seconds(10)); assert.deepEqual(context.commands[3], { command: "systemctl", - args: ["--user", "restart", "t3code.service"], + args: ["--user", "restart", "--no-block", "t3code.service"], }); assert.lengthOf(context.spawns, 0); // systemd replaces the process; the server must not exit itself. assert.equal(context.exitCount(), 0); + + // The queued restart returns while this process is still shutting + // down; the lock must stay held so a second update cannot rewrite the + // unit mid-teardown. + const concurrentError = yield* context.service + .update({ targetVersion: "0.0.30" }) + .pipe(Effect.flip); + assert.include(concurrentError.reason, "already in progress"); }).pipe(Effect.provide(TestClock.layer())), ); @@ -583,7 +591,7 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { assert.deepEqual( context.commands.slice(-2).map((entry) => entry.args), [ - ["--user", "restart", BOOT_SERVICE_UNIT_FILE], + ["--user", "restart", "--no-block", BOOT_SERVICE_UNIT_FILE], ["--user", "daemon-reload"], ], ); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index f786cbea8cfa..9dcb713e1a18 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -354,14 +354,19 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option targetVersion, }); // Restart after the acknowledgement has had time to cross any relay - // hop. If systemd rejects the handoff, restore the previous unit while - // this process is still alive and log the failure for diagnostics. + // hop. --no-block queues the restart job and exits before systemd + // stops this unit: a blocking restart's SIGTERM reaches the systemctl + // child (it shares this service's cgroup), which read as a restart + // failure and rolled the new unit back while the old server finished + // shutting down. With the handoff race gone, a non-zero exit or spawn + // error means systemd genuinely rejected the job while this process is + // still alive, so restoring the previous unit below stays correct. yield* scheduleRestart( Effect.gen(function* () { const restart = yield* runner .run({ command: "systemctl", - args: ["--user", "restart", BOOT_SERVICE_UNIT_FILE], + args: ["--user", "restart", "--no-block", BOOT_SERVICE_UNIT_FILE], }) .pipe( Effect.mapError((cause) => @@ -389,9 +394,13 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option Effect.catch((error) => Effect.logError("Server self-update could not restart the boot service.").pipe( Effect.annotateLogs({ targetVersion, error: error.reason }), + // Permit a retry only after the failed handoff was rolled + // back. A queued restart returns while this process is still + // shutting down; releasing the lock then would let a second + // update rewrite the unit mid-teardown. + Effect.andThen(Ref.set(inFlight, false)), ), ), - Effect.ensuring(Ref.set(inFlight, false)), ), ); } else { diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts index 749fd3062e91..4c2375b29a74 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -33,6 +33,7 @@ describe("serverRuntimeState", () => { host: "127.0.0.1", port: 4_971, origin: "http://127.0.0.1:4971", + devUrl: "http://localhost:5733/", startedAt: "2026-06-20T00:00:00.000Z", }; @@ -43,6 +44,24 @@ describe("serverRuntimeState", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("records the dev web URL when the server fronts a dev server", () => + Effect.gen(function* () { + const state = yield* ServerRuntimeState.makePersistedServerRuntimeState({ + config: { host: undefined, devUrl: new URL("http://localhost:5733") }, + port: 13_773, + }); + + assert.equal(state.devUrl, "http://localhost:5733/"); + assert.equal(state.origin, "http://127.0.0.1:13773"); + + const withoutDev = yield* ServerRuntimeState.makePersistedServerRuntimeState({ + config: { host: undefined, devUrl: undefined }, + port: 13_773, + }); + assert.isFalse("devUrl" in withoutDev); + }), + ); + it.effect("treats a missing runtime state file as absent", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 329b000369a0..b32f3814547c 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -14,6 +14,9 @@ export const PersistedServerRuntimeState = Schema.Struct({ host: Schema.optional(Schema.String), port: Schema.Int, origin: Schema.String, + // Present when the server fronts a dev web server (VITE_DEV_SERVER_URL). + // Dev is single-origin: browsers must pair through this URL, not `origin`. + devUrl: Schema.optional(Schema.String), startedAt: Schema.String, }); export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; @@ -45,7 +48,7 @@ const runtimeOriginForConfig = ( }; export const makePersistedServerRuntimeState = (input: { - readonly config: Pick; + readonly config: Pick; readonly port: number; }): Effect.Effect => Effect.map(DateTime.now, (now) => ({ @@ -54,6 +57,7 @@ export const makePersistedServerRuntimeState = (input: { ...(input.config.host ? { host: input.config.host } : {}), port: input.port, origin: runtimeOriginForConfig(input.config, input.port), + ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), startedAt: DateTime.formatIso(now), })); diff --git a/apps/web/package.json b/apps/web/package.json index a25e42cc84be..53b38b80ba0b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "build:ghostty-wasm": "bash scripts/build-libghostty-wasm.sh", "dev": "vp dev", "build": "vp build", "preview": "vp preview", @@ -31,8 +32,6 @@ "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", - "@xterm/addon-fit": "^0.11.0", - "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "effect": "catalog:", "jose": "catalog:", diff --git a/apps/web/scripts/build-libghostty-wasm.sh b/apps/web/scripts/build-libghostty-wasm.sh new file mode 100755 index 000000000000..d4b1238bbfc0 --- /dev/null +++ b/apps/web/scripts/build-libghostty-wasm.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WEB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +REPO_DIR="$(cd "${WEB_DIR}/../.." && pwd)" +CANONICAL_VENDOR_DIR="${REPO_DIR}/native/libghostty-vt" +VENDOR_DIR="${WEB_DIR}/src/terminal/ghostty/vendor" + +GHOSTTY_REVISION="$(tr -d '[:space:]' < "${CANONICAL_VENDOR_DIR}/VERSION")" +GHOSTTY_SOURCE_DIR="${GHOSTTY_SOURCE_DIR:-${HOME}/.cache/t3code/ghostty-${GHOSTTY_REVISION:0:8}}" +GHOSTTY_ZIG_VERSION="${GHOSTTY_ZIG_VERSION:-0.15.2}" +GHOSTTY_ZIG="${GHOSTTY_ZIG:-}" + +log() { + printf '[libghostty-vt-wasm] %s\n' "$*" +} + +die() { + printf '[libghostty-vt-wasm] error: %s\n' "$*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "missing required command: $1" +} + +ensure_zig() { + if [[ -n "${GHOSTTY_ZIG}" ]]; then + [[ -x "${GHOSTTY_ZIG}" ]] || die "GHOSTTY_ZIG is not executable: ${GHOSTTY_ZIG}" + return + fi + if command -v zig >/dev/null 2>&1 && [[ "$(zig version)" == "${GHOSTTY_ZIG_VERSION}" ]]; then + GHOSTTY_ZIG="$(command -v zig)" + return + fi + + local host_os host_arch cache_dir + host_os="$(uname -s | tr '[:upper:]' '[:lower:]')" + host_arch="$(uname -m)" + case "${host_os}" in + darwin) host_os="macos" ;; + linux) ;; + *) die "unsupported host OS for Zig download: ${host_os}" ;; + esac + case "${host_arch}" in + arm64) host_arch="aarch64" ;; + aarch64 | x86_64) ;; + *) die "unsupported host architecture: ${host_arch}" ;; + esac + + cache_dir="${HOME}/.cache/t3code/zig-${GHOSTTY_ZIG_VERSION}" + GHOSTTY_ZIG="${cache_dir}/zig" + if [[ -x "${GHOSTTY_ZIG}" ]]; then + return + fi + + require_cmd curl + require_cmd tar + mkdir -p "${cache_dir}" + log "downloading Zig ${GHOSTTY_ZIG_VERSION}" + curl -fsSL \ + "https://ziglang.org/download/${GHOSTTY_ZIG_VERSION}/zig-${host_arch}-${host_os}-${GHOSTTY_ZIG_VERSION}.tar.xz" \ + | tar -xJ --strip-components=1 -C "${cache_dir}" +} + +ensure_ghostty_source() { + require_cmd git + if [[ ! -d "${GHOSTTY_SOURCE_DIR}/.git" ]]; then + mkdir -p "$(dirname "${GHOSTTY_SOURCE_DIR}")" + log "cloning Ghostty ${GHOSTTY_REVISION}" + git clone --filter=blob:none --no-checkout https://github.com/ghostty-org/ghostty.git \ + "${GHOSTTY_SOURCE_DIR}" + fi + + # A cached checkout may still be on a previously pinned revision; converge on + # the pinned one instead of failing the rebuild. + local actual_revision + actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD 2>/dev/null || echo none)" + if [[ "${actual_revision}" != "${GHOSTTY_REVISION}" ]]; then + log "checking out Ghostty ${GHOSTTY_REVISION}" + git -C "${GHOSTTY_SOURCE_DIR}" fetch --depth=1 origin "${GHOSTTY_REVISION}" + git -C "${GHOSTTY_SOURCE_DIR}" checkout --detach "${GHOSTTY_REVISION}" + fi + + actual_revision="$(git -C "${GHOSTTY_SOURCE_DIR}" rev-parse HEAD)" + [[ "${actual_revision}" == "${GHOSTTY_REVISION}" ]] || \ + die "expected Ghostty ${GHOSTTY_REVISION}, found ${actual_revision}" +} + +ensure_zig +ensure_ghostty_source + +build_root="$(mktemp -d)" +trap 'rm -rf "${build_root}"' EXIT + +log "building ${GHOSTTY_REVISION} for wasm32-freestanding" +( + cd "${GHOSTTY_SOURCE_DIR}" + # The pinned revision rides along as semver build metadata so the artifact + # identifies its own provenance through ghostty_build_info(); mobile's + # VERSION file stays the single source of truth for the pin. + "${GHOSTTY_ZIG}" build \ + -Demit-lib-vt \ + -Dtarget=wasm32-freestanding \ + -Doptimize=ReleaseSmall \ + -Dstrip=true \ + -Dlib-version-string="0.1.0-dev+${GHOSTTY_REVISION}" \ + -p "${build_root}" +) + +mkdir -p "${VENDOR_DIR}" +cp "${build_root}/bin/ghostty-vt.wasm" "${VENDOR_DIR}/ghostty-vt.wasm" +"${GHOSTTY_ZIG}" build-exe \ + "${SCRIPT_DIR}/ghostty-write-pty.zig" \ + -target wasm32-freestanding \ + -O ReleaseSmall \ + -fno-entry \ + -rdynamic \ + -femit-bin="${VENDOR_DIR}/ghostty-write-pty.wasm" +chmod 0644 "${VENDOR_DIR}/ghostty-write-pty.wasm" +log "wrote ${VENDOR_DIR}/ghostty-vt.wasm" diff --git a/apps/web/scripts/ghostty-write-pty.zig b/apps/web/scripts/ghostty-write-pty.zig new file mode 100644 index 000000000000..dbe6688f5e82 --- /dev/null +++ b/apps/web/scripts/ghostty-write-pty.zig @@ -0,0 +1,5 @@ +extern "env" fn t3_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void; + +export fn ghostty_write_pty(terminal: u32, userdata: u32, data: u32, len: u32) void { + t3_write_pty(terminal, userdata, data, len); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f0a918e136fc..2b9eda1a7875 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -704,6 +704,19 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra () => drawerTerminalSessions.map((session) => session.target.terminalId), [drawerTerminalSessions], ); + // Every client-side id source participates in allocation: the server list + // lags fresh opens, and panel terminals are filtered out of the drawer's + // sessions — an id collision attaches two viewports to one PTY session. + const allocatableTerminalIds = useMemo( + () => [ + ...new Set([ + ...serverOrderedTerminalIds, + ...terminalUiState.terminalIds, + ...panelTerminalIds, + ]), + ], + [panelTerminalIds, serverOrderedTerminalIds, terminalUiState.terminalIds], + ); const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); const storeSplitTerminalVertical = useTerminalUiStateStore( @@ -773,7 +786,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra if (!cwd) { return; } - const terminalId = nextTerminalId(serverOrderedTerminalIds); + const terminalId = nextTerminalId(allocatableTerminalIds); storeSplitTerminal(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ @@ -787,11 +800,11 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }, }); }, [ + allocatableTerminalIds, bumpFocusRequestId, cwd, effectiveWorktreePath, runtimeEnv, - serverOrderedTerminalIds, storeSplitTerminal, threadId, threadRef, @@ -801,7 +814,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra if (!cwd) { return; } - const terminalId = nextTerminalId(serverOrderedTerminalIds); + const terminalId = nextTerminalId(allocatableTerminalIds); storeSplitTerminalVertical(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ @@ -815,12 +828,12 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }, }); }, [ + allocatableTerminalIds, bumpFocusRequestId, cwd, effectiveWorktreePath, openTerminal, runtimeEnv, - serverOrderedTerminalIds, storeSplitTerminalVertical, threadId, threadRef, @@ -830,7 +843,7 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra if (!cwd) { return; } - const terminalId = nextTerminalId(serverOrderedTerminalIds); + const terminalId = nextTerminalId(allocatableTerminalIds); storeNewTerminal(threadRef, terminalId); bumpFocusRequestId(); void openTerminal({ @@ -847,8 +860,8 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra bumpFocusRequestId, cwd, effectiveWorktreePath, + allocatableTerminalIds, runtimeEnv, - serverOrderedTerminalIds, storeNewTerminal, threadId, threadRef, @@ -1521,6 +1534,10 @@ function ChatViewContent(props: ChatViewProps) { ), [rightPanelState.surfaces], ); + const allocatableActiveTerminalIds = useMemo( + () => [...new Set([...activeKnownTerminalIds, ...panelTerminalIds])], + [activeKnownTerminalIds, panelTerminalIds], + ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; @@ -2628,7 +2645,7 @@ function ChatViewContent(props: ChatViewProps) { if (!cwdForOpen) { return; } - const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); + const terminalId = nextTerminalId(allocatableActiveTerminalIds); storeEnsureTerminal(activeThreadRef, terminalId, { open: true }); void openTerminal({ environmentId, @@ -2647,15 +2664,14 @@ function ChatViewContent(props: ChatViewProps) { } setTerminalOpen(nextOpen); }, [ - activeKnownTerminalIds, activeProject, activeThreadId, activeThreadRef, activeThreadWorktreePath, + allocatableActiveTerminalIds, environmentId, gitCwd, openTerminal, - panelTerminalIds, setTerminalOpen, storeEnsureTerminal, terminalUiState.terminalIds.length, @@ -2670,7 +2686,7 @@ function ChatViewContent(props: ChatViewProps) { if (!cwdForOpen) { return; } - const terminalId = nextTerminalId(activeKnownTerminalIds); + const terminalId = nextTerminalId(allocatableActiveTerminalIds); if (direction === "vertical") { storeSplitTerminalVertical(activeThreadRef, terminalId); } else { @@ -2693,8 +2709,8 @@ function ChatViewContent(props: ChatViewProps) { }, [ activeProject, - activeKnownTerminalIds, activeThreadId, + allocatableActiveTerminalIds, activeThreadRef, openTerminal, activeThreadWorktreePath, @@ -2713,7 +2729,7 @@ function ChatViewContent(props: ChatViewProps) { if (!cwdForOpen) { return; } - const terminalId = nextTerminalId(activeKnownTerminalIds); + const terminalId = nextTerminalId(allocatableActiveTerminalIds); storeNewTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ @@ -2731,8 +2747,8 @@ function ChatViewContent(props: ChatViewProps) { }); }, [ activeProject, - activeKnownTerminalIds, activeThreadId, + allocatableActiveTerminalIds, activeThreadRef, openTerminal, activeThreadWorktreePath, @@ -2818,7 +2834,7 @@ function ChatViewContent(props: ChatViewProps) { ...(options?.env ? { extraEnv: options.env } : {}), }); const targetTerminalId = shouldCreateNewTerminal - ? nextTerminalId(activeKnownTerminalIds) + ? nextTerminalId(allocatableActiveTerminalIds) : baseTerminalId; const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal ? { @@ -2886,6 +2902,7 @@ function ChatViewContent(props: ChatViewProps) { environmentId, openTerminal, activeKnownTerminalIds, + allocatableActiveTerminalIds, runningTerminalIds, terminalUiState.activeTerminalId, writeTerminal, @@ -3144,7 +3161,7 @@ function ChatViewContent(props: ChatViewProps) { const addTerminalSurface = useCallback(() => { if (!activeThreadRef || !activeThreadId || !activeProject) return; const cwd = gitCwd ?? activeProject.workspaceRoot; - const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); + const terminalId = nextTerminalId(allocatableActiveTerminalIds); useRightPanelStore.getState().openTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); void openTerminal({ @@ -3161,14 +3178,13 @@ function ChatViewContent(props: ChatViewProps) { }, }); }, [ - activeKnownTerminalIds, activeProject, activeThreadId, activeThreadRef, activeThreadWorktreePath, + allocatableActiveTerminalIds, gitCwd, openTerminal, - panelTerminalIds, ]); const splitPanelTerminal = useCallback( (direction: "horizontal" | "vertical" = "horizontal") => { @@ -3181,7 +3197,7 @@ function ChatViewContent(props: ChatViewProps) { ) { return; } - const terminalId = nextTerminalId([...activeKnownTerminalIds, ...panelTerminalIds]); + const terminalId = nextTerminalId(allocatableActiveTerminalIds); const cwd = gitCwd ?? activeProject.workspaceRoot; useRightPanelStore .getState() @@ -3202,15 +3218,14 @@ function ChatViewContent(props: ChatViewProps) { }); }, [ - activeKnownTerminalIds, activeProject, activeRightPanelSurface, activeThreadId, activeThreadRef, activeThreadWorktreePath, + allocatableActiveTerminalIds, gitCwd, openTerminal, - panelTerminalIds, ], ); const splitPanelTerminalVertical = useCallback(() => { diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 9f483af3c3bd..e60d1d71678f 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveTerminalSelectionActionPosition, + shouldHandleTerminalExit, shouldHandleTerminalSelectionMouseUp, terminalSelectionActionDelayForClickCount, + terminalSelectionLineRange, } from "./ThreadTerminalDrawer"; describe("resolveTerminalSelectionActionPosition", () => { @@ -72,4 +74,19 @@ describe("resolveTerminalSelectionActionPosition", () => { expect(shouldHandleTerminalSelectionMouseUp(false, 0)).toBe(false); expect(shouldHandleTerminalSelectionMouseUp(true, 1)).toBe(false); }); + + it("uses Ghostty's physical screen range for visually wrapped selections", () => { + expect( + terminalSelectionLineRange({ + start: { y: 4 }, + end: { y: 6 }, + }), + ).toEqual({ lineStart: 5, lineEnd: 7 }); + }); + + it("handles an exit that lands while the terminal surface is still loading", () => { + expect(shouldHandleTerminalExit("exited", "running", false)).toBe(true); + expect(shouldHandleTerminalExit("exited", "exited", false)).toBe(false); + expect(shouldHandleTerminalExit("closed", "running", true)).toBe(false); + }); }); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 8591c24c71ab..dd7da738626c 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,9 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; -import { FitAddon } from "@xterm/addon-fit"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; import { Plus, SquareSplitHorizontal, @@ -18,7 +18,6 @@ import { type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { Terminal, type ITheme } from "@xterm/xterm"; import { type PointerEvent as ReactPointerEvent, type ReactNode, @@ -34,15 +33,13 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; -import { useOpenInPreferredEditor } from "../editorPreferences"; import { - collectWrappedTerminalLinkLine, - extractTerminalLinks, - isTerminalLinkActivation, - resolvePathLinkTarget, - resolveWrappedTerminalLinkRange, - wrappedTerminalLinkRangeIntersectsBufferLine, -} from "../terminal-links"; + GhosttyTerminalSurface, + type GhosttyTerminalSurfaceOptions, +} from "~/terminal/ghostty/surface"; +import { type GhosttyColor, type GhosttyTheme } from "~/terminal/ghostty/core"; +import { useOpenInPreferredEditor } from "../editorPreferences"; +import { isTerminalLinkActivation, resolvePathLinkTarget } from "../terminal-links"; import { isDiffToggleShortcut, isTerminalClearShortcut, @@ -82,24 +79,34 @@ function clampDrawerHeight(height: number): number { return Math.min(Math.max(Math.round(safeHeight), MIN_DRAWER_HEIGHT), maxHeight); } -function writeSystemMessage(terminal: Terminal, message: string): void { +function writeSystemMessage(terminal: GhosttyTerminalSurface, message: string): void { terminal.write(`\r\n[terminal] ${message}\r\n`); } -function writeTerminalBuffer(terminal: Terminal, buffer: string): void { - terminal.write("\u001bc"); - if (buffer.length > 0) { - terminal.write(buffer); - } +function writeTerminalBuffer(terminal: GhosttyTerminalSurface, buffer: string): void { + terminal.resetAndWrite(buffer); } -function fitTerminalSafely(fitAddon: FitAddon): boolean { - try { - fitAddon.fit(); - return true; - } catch { - return false; - } +function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor { + if (typeof document === "undefined") return fallback; + + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) return fallback; + + context.clearRect(0, 0, 1, 1); + context.fillStyle = value; + context.fillRect(0, 0, 1, 1); + const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data; + if (alpha === 0) return fallback; + + return { + r: red ?? fallback.r, + g: green ?? fallback.g, + b: blue ?? fallback.b, + }; } function runtimeEnvSignature(runtimeEnv: Record | undefined): string { @@ -124,7 +131,7 @@ function normalizeComputedColor(value: string | null | undefined, fallback: stri return value ?? fallback; } -function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { +function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { const isDark = document.documentElement.classList.contains("dark"); const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; @@ -143,86 +150,22 @@ function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { normalizeComputedColor(bodyStyles.color, fallbackForeground), ); - if (isDark) { - return { + return { + background: parseTerminalColor( background, + isDark ? { r: 14, g: 18, b: 24 } : { r: 255, g: 255, b: 255 }, + ), + foreground: parseTerminalColor( foreground, - cursor: "rgb(180, 203, 255)", - selectionBackground: "rgba(180, 203, 255, 0.25)", - scrollbarSliderBackground: "rgba(255, 255, 255, 0.1)", - scrollbarSliderHoverBackground: "rgba(255, 255, 255, 0.18)", - scrollbarSliderActiveBackground: "rgba(255, 255, 255, 0.22)", - black: "rgb(24, 30, 38)", - red: "rgb(255, 122, 142)", - green: "rgb(134, 231, 149)", - yellow: "rgb(244, 205, 114)", - blue: "rgb(137, 190, 255)", - magenta: "rgb(208, 176, 255)", - cyan: "rgb(124, 232, 237)", - white: "rgb(210, 218, 230)", - brightBlack: "rgb(110, 120, 136)", - brightRed: "rgb(255, 168, 180)", - brightGreen: "rgb(176, 245, 186)", - brightYellow: "rgb(255, 224, 149)", - brightBlue: "rgb(174, 210, 255)", - brightMagenta: "rgb(229, 203, 255)", - brightCyan: "rgb(167, 244, 247)", - brightWhite: "rgb(244, 247, 252)", - }; - } - - return { - background, - foreground, - cursor: "rgb(38, 56, 78)", - selectionBackground: "rgba(37, 63, 99, 0.2)", - scrollbarSliderBackground: "rgba(0, 0, 0, 0.15)", - scrollbarSliderHoverBackground: "rgba(0, 0, 0, 0.25)", - scrollbarSliderActiveBackground: "rgba(0, 0, 0, 0.3)", - black: "rgb(44, 53, 66)", - red: "rgb(191, 70, 87)", - green: "rgb(60, 126, 86)", - yellow: "rgb(146, 112, 35)", - blue: "rgb(72, 102, 163)", - magenta: "rgb(132, 86, 149)", - cyan: "rgb(53, 127, 141)", - white: "rgb(210, 215, 223)", - brightBlack: "rgb(112, 123, 140)", - brightRed: "rgb(212, 95, 112)", - brightGreen: "rgb(85, 148, 111)", - brightYellow: "rgb(173, 133, 45)", - brightBlue: "rgb(91, 124, 194)", - brightMagenta: "rgb(153, 107, 172)", - brightCyan: "rgb(70, 149, 164)", - brightWhite: "rgb(236, 240, 246)", + isDark ? { r: 237, g: 241, b: 247 } : { r: 28, g: 33, b: 41 }, + ), + cursor: isDark ? { r: 180, g: 203, b: 255 } : { r: 38, g: 56, b: 78 }, + // Matches the xterm selection overlays this renderer replaced; the text + // color underneath is left unchanged for contrast in both themes. + selectionBackground: isDark ? "rgba(180, 203, 255, 0.25)" : "rgba(37, 63, 99, 0.2)", }; } -function getTerminalSelectionRect(mountElement: HTMLElement): DOMRect | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { - return null; - } - - const range = selection.getRangeAt(0); - const commonAncestor = range.commonAncestorContainer; - const selectionRoot = - commonAncestor instanceof Element ? commonAncestor : commonAncestor.parentElement; - if (!(selectionRoot instanceof Element) || !mountElement.contains(selectionRoot)) { - return null; - } - - const rects = Array.from(range.getClientRects()).filter( - (rect) => rect.width > 0 || rect.height > 0, - ); - if (rects.length > 0) { - return rects[rects.length - 1] ?? null; - } - - const boundingRect = range.getBoundingClientRect(); - return boundingRect.width > 0 || boundingRect.height > 0 ? boundingRect : null; -} - export function resolveTerminalSelectionActionPosition(options: { bounds: { left: number; top: number; width: number; height: number }; selectionRect: { right: number; bottom: number } | null; @@ -269,6 +212,27 @@ export function shouldHandleTerminalSelectionMouseUp( return selectionGestureActive && button === 0; } +export function terminalSelectionLineRange(position: { + start: { y: number }; + end: { y: number }; +}): { lineStart: number; lineEnd: number } { + const lineStart = position.start.y + 1; + return { + lineStart, + lineEnd: Math.max(lineStart, position.end.y + 1), + }; +} + +export function shouldHandleTerminalExit( + current: TerminalSessionState["status"], + synchronized: TerminalSessionState["status"], + alreadyHandled: boolean, +): boolean { + return ( + (current === "closed" || current === "exited") && current !== synchronized && !alreadyHandled + ); +} + interface TerminalViewportProps { threadRef: ScopedThreadRef; threadId: ThreadId; @@ -309,8 +273,7 @@ export function TerminalViewport({ keybindings, }: TerminalViewportProps) { const containerRef = useRef(null); - const terminalRef = useRef(null); - const fitAddonRef = useRef(null); + const terminalRef = useRef(null); const environmentId = threadRef.environmentId; const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( @@ -367,6 +330,24 @@ export function TerminalViewport({ const terminalBuffer = terminalSession.buffer; const terminalError = terminalSession.error; const terminalStatus = terminalSession.status; + const synchronizedStatusRef = useRef("closed"); + const synchronizeTerminalStatus = useEffectEvent( + (terminal: GhosttyTerminalSurface, status: TerminalSessionState["status"]) => { + const synchronized = synchronizedStatusRef.current; + if (status === "running") { + hasHandledExitRef.current = false; + } else if (shouldHandleTerminalExit(status, synchronized, hasHandledExitRef.current)) { + hasHandledExitRef.current = true; + writeSystemMessage(terminal, status === "closed" ? "Terminal closed" : "Process exited"); + window.setTimeout(() => { + if (hasHandledExitRef.current) { + handleSessionExited(); + } + }, 0); + } + synchronizedStatusRef.current = status; + }, + ); const terminalVersion = terminalSession.version; const previousSessionRef = useRef({ buffer: terminalBuffer, @@ -374,6 +355,13 @@ export function TerminalViewport({ status: terminalStatus, version: terminalVersion, }); + const latestSessionRef = useRef(previousSessionRef.current); + latestSessionRef.current = { + buffer: terminalBuffer, + error: terminalError, + status: terminalStatus, + version: terminalVersion, + }; useEffect(() => { keybindingsRef.current = keybindings; @@ -384,356 +372,353 @@ export function TerminalViewport({ if (!mount) return; const localApi = readLocalApi(); - - const fitAddon = new FitAddon(); - const terminal = new Terminal({ - cursorBlink: true, - lineHeight: 1, - fontSize: 12, - scrollback: 5_000, - fontFamily: - '"SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace', - theme: terminalThemeFromApp(mount), - }); - terminal.loadAddon(fitAddon); - terminal.open(mount); - fitTerminalSafely(fitAddon); - - terminalRef.current = terminal; - fitAddonRef.current = fitAddon; - previousSessionRef.current = { - buffer: "", - status: "closed", - error: null, - version: 0, - }; - - const clearSelectionAction = () => { - selectionActionRequestIdRef.current += 1; - if (selectionActionTimerRef.current !== null) { - window.clearTimeout(selectionActionTimerRef.current); - selectionActionTimerRef.current = null; - } - }; - - const readSelectionAction = (): { - position: { x: number; y: number }; - clipboardText: string; - selection: TerminalContextSelection; - } | null => { - const activeTerminal = terminalRef.current; - const mountElement = containerRef.current; - if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { - return null; - } - const selectionText = activeTerminal.getSelection(); - const selectionPosition = activeTerminal.getSelectionPosition(); - const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); - if (!selectionPosition || normalizedText.length === 0) { + let cancelled = false; + let teardown: (() => void) | null = null; + let setupTerminal: GhosttyTerminalSurface | null = null; + let setupCleanups: Array<() => void> = []; + + const setup = async (): Promise<(() => void) | null> => { + const terminalOptions: GhosttyTerminalSurfaceOptions = { + theme: terminalThemeFromApp(mount), + onData: (data) => handleData(data), + onResize: (cols, rows) => void resizeTerminal(cols, rows), + onSelectionChange: () => handleSelectionChange(), + onCopy: (text) => handleCopy(text), + beforeKey: (event) => handleBeforeKey(event), + onLinkActivate: (text, event) => handleLinkActivate(text, event), + }; + const terminal = await GhosttyTerminalSurface.create(mount, terminalOptions); + if (cancelled) { + terminal.dispose(); return null; } - const lineStart = selectionPosition.start.y + 1; - const lineCount = normalizedText.split("\n").length; - const lineEnd = Math.max(lineStart, lineStart + lineCount - 1); - const bounds = mountElement.getBoundingClientRect(); - const selectionRect = getTerminalSelectionRect(mountElement); - const position = resolveTerminalSelectionActionPosition({ - bounds, - selectionRect: - selectionRect === null - ? null - : { right: selectionRect.right, bottom: selectionRect.bottom }, - pointer: selectionPointerRef.current, - }); - return { - position, - clipboardText: selectionText, - selection: { - terminalId, - terminalLabel: readTerminalLabel(), - lineStart, - lineEnd, - text: normalizedText, - }, + // The theme observer is not installed yet, so re-read the theme in case + // the app toggled light/dark while the WASM surface was loading. + terminal.setTheme(terminalThemeFromApp(mount)); + setupTerminal = terminal; + terminalRef.current = terminal; + const latestSession = latestSessionRef.current; + previousSessionRef.current = latestSession; + if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer); + if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error); + // Attaching to a session that already exited must still run exit handling + // once, so mount synchronization starts from the empty "closed" state. + // (A session that is "closed" at mount is indistinguishable from one that + // never started, so only "exited" triggers the message — as with xterm.) + synchronizedStatusRef.current = "closed"; + synchronizeTerminalStatus(terminal, latestSession.status); + if (autoFocus) window.requestAnimationFrame(() => terminal.focus()); + + const clearSelectionAction = () => { + selectionActionRequestIdRef.current += 1; + if (selectionActionTimerRef.current !== null) { + window.clearTimeout(selectionActionTimerRef.current); + selectionActionTimerRef.current = null; + } }; - }; + setupCleanups.push(clearSelectionAction); - const showSelectionAction = async () => { - if (!localApi) { - clearSelectionAction(); - return; - } - if (selectionActionMenuOpenRef.current) { - return; - } - const nextAction = readSelectionAction(); - if (!nextAction) { - clearSelectionAction(); - return; - } - const requestId = ++selectionActionRequestIdRef.current; - selectionActionMenuOpenRef.current = true; - const clicked = await localApi.contextMenu - .show( - [ - { id: "add-to-chat", label: "Add to chat" }, - { id: "copy", label: "Copy" }, - ], - nextAction.position, - ) - .finally(() => { - selectionActionMenuOpenRef.current = false; + const readSelectionAction = (): { + position: { x: number; y: number }; + clipboardText: string; + selection: TerminalContextSelection; + } | null => { + const activeTerminal = terminalRef.current; + const mountElement = containerRef.current; + if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { + return null; + } + const selectionText = activeTerminal.getSelection(); + const selectionPosition = activeTerminal.getSelectionPosition(); + const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); + if (!selectionPosition || normalizedText.length === 0) { + return null; + } + const { lineStart, lineEnd } = terminalSelectionLineRange(selectionPosition); + const bounds = mountElement.getBoundingClientRect(); + const position = resolveTerminalSelectionActionPosition({ + bounds, + selectionRect: activeTerminal.getSelectionEndClientRect(), + pointer: selectionPointerRef.current, }); - if (requestId !== selectionActionRequestIdRef.current || clicked === null) { - return; - } - switch (clicked) { - case "add-to-chat": - handleAddTerminalContext(nextAction.selection); - terminalRef.current?.clearSelection(); - terminalRef.current?.focus(); + return { + position, + clipboardText: selectionText, + selection: { + terminalId, + terminalLabel: readTerminalLabel(), + lineStart, + lineEnd, + text: normalizedText, + }, + }; + }; + + const showSelectionAction = async () => { + if (!localApi) { + clearSelectionAction(); return; - case "copy": - try { - await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); - } catch (error) { - if (requestId !== selectionActionRequestIdRef.current) { - return; + } + if (selectionActionMenuOpenRef.current) { + return; + } + const nextAction = readSelectionAction(); + if (!nextAction) { + clearSelectionAction(); + return; + } + const requestId = ++selectionActionRequestIdRef.current; + selectionActionMenuOpenRef.current = true; + const clicked = await localApi.contextMenu + .show( + [ + { id: "add-to-chat", label: "Add to chat" }, + { id: "copy", label: "Copy" }, + ], + nextAction.position, + ) + .finally(() => { + selectionActionMenuOpenRef.current = false; + }); + if (requestId !== selectionActionRequestIdRef.current || clicked === null) { + return; + } + switch (clicked) { + case "add-to-chat": + handleAddTerminalContext(nextAction.selection); + terminalRef.current?.clearSelection(); + terminalRef.current?.focus(); + return; + case "copy": + try { + await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); + } catch (error) { + if (requestId !== selectionActionRequestIdRef.current) { + return; + } + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : "Unable to copy terminal selection", + ); + } } - const activeTerminal = terminalRef.current; - if (activeTerminal) { - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : "Unable to copy terminal selection", - ); + if (requestId === selectionActionRequestIdRef.current) { + terminalRef.current?.focus(); } - } - if (requestId === selectionActionRequestIdRef.current) { - terminalRef.current?.focus(); - } - return; - } - }; + return; + } + }; - const sendTerminalInput = async (data: string, fallbackError: string) => { - const activeTerminal = terminalRef.current; - if (!activeTerminal) return; - const result = await writeTerminal(data); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - writeSystemMessage(activeTerminal, error instanceof Error ? error.message : fallbackError); - } - }; + const sendTerminalInput = async (data: string, fallbackError: string) => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + const result = await writeTerminal(data); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : fallbackError, + ); + } + }; - terminal.attachCustomKeyEventHandler((event) => { - const currentKeybindings = keybindingsRef.current; - const options = { context: { terminalFocus: true, terminalOpen: true } }; - if ( - isTerminalToggleShortcut(event, currentKeybindings, options) || - isTerminalSplitShortcut(event, currentKeybindings, options) || - isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || - isTerminalNewShortcut(event, currentKeybindings, options) || - isTerminalCloseShortcut(event, currentKeybindings, options) || - isDiffToggleShortcut(event, currentKeybindings, options) - ) { - return false; - } + function handleBeforeKey(event: KeyboardEvent): boolean { + const currentKeybindings = keybindingsRef.current; + const options = { context: { terminalFocus: true, terminalOpen: true } }; + if ( + isTerminalToggleShortcut(event, currentKeybindings, options) || + isTerminalSplitShortcut(event, currentKeybindings, options) || + isTerminalSplitVerticalShortcut(event, currentKeybindings, options) || + isTerminalNewShortcut(event, currentKeybindings, options) || + isTerminalCloseShortcut(event, currentKeybindings, options) || + isDiffToggleShortcut(event, currentKeybindings, options) + ) { + return false; + } - const navigationData = terminalNavigationShortcutData(event); - if (navigationData !== null) { - event.preventDefault(); - event.stopPropagation(); - void sendTerminalInput(navigationData, "Failed to move cursor"); - return false; - } + const navigationData = terminalNavigationShortcutData(event); + if (navigationData !== null) { + event.preventDefault(); + event.stopPropagation(); + void sendTerminalInput(navigationData, "Failed to move cursor"); + return false; + } + + const deleteData = terminalDeleteShortcutData(event); + if (deleteData !== null) { + event.preventDefault(); + event.stopPropagation(); + void sendTerminalInput(deleteData, "Failed to delete terminal input"); + return false; + } - const deleteData = terminalDeleteShortcutData(event); - if (deleteData !== null) { + if (!isTerminalClearShortcut(event)) return true; event.preventDefault(); event.stopPropagation(); - void sendTerminalInput(deleteData, "Failed to delete terminal input"); + void sendTerminalInput("\u000c", "Failed to clear terminal"); return false; } - if (!isTerminalClearShortcut(event)) return true; - event.preventDefault(); - event.stopPropagation(); - void sendTerminalInput("\u000c", "Failed to clear terminal"); - return false; - }); - - const terminalLinksDisposable = terminal.registerLinkProvider({ - provideLinks: (bufferLineNumber, callback) => { - const activeTerminal = terminalRef.current; - if (!activeTerminal) { - callback(undefined); + function handleLinkActivate(text: string, event: MouseEvent): void { + if (!isTerminalLinkActivation(event)) return; + const latestTerminal = terminalRef.current; + if (!latestTerminal) return; + if (/^https?:\/\//u.test(text)) { + if (!localApi) { + writeSystemMessage(latestTerminal, "Opening links is unavailable in this browser."); + return; + } + const fallbackToBrowser = () => { + void localApi.shell.openExternal(text).catch((error: unknown) => { + writeSystemMessage( + latestTerminal, + error instanceof Error ? error.message : "Unable to open link", + ); + }); + }; + void openTerminalLinkInPreview({ + url: text, + position: { x: event.clientX, y: event.clientY }, + threadRef, + openPreview, + localApi, + fallbackToBrowser, + }); return; } + const target = resolvePathLinkTarget(text, cwd); + void (async () => { + const result = await openTerminalPath(target); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + return; + } + const error = squashAtomCommandFailure(result); + writeSystemMessage( + latestTerminal, + error instanceof Error ? error.message : "Unable to open path", + ); + })(); + } - const wrappedLine = collectWrappedTerminalLinkLine(bufferLineNumber, (bufferLineIndex) => - activeTerminal.buffer.active.getLine(bufferLineIndex), - ); - if (!wrappedLine) { - callback(undefined); - return; - } + function handleCopy(text: string): void { + void writeTextToClipboard(text, "terminal selection").catch((error: unknown) => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : "Unable to copy terminal selection", + ); + }); + } - const links = extractTerminalLinks(wrappedLine.text) - .map((match) => ({ - match, - range: resolveWrappedTerminalLinkRange(wrappedLine, match), - })) - .filter(({ range }) => - wrappedTerminalLinkRangeIntersectsBufferLine(range, bufferLineNumber), + function handleData(data: string): void { + void (async () => { + const result = await writeTerminal(data); + if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + writeSystemMessage( + terminal, + error instanceof Error ? error.message : "Terminal write failed", ); - if (links.length === 0) { - callback(undefined); + })(); + } + + function handleSelectionChange(): void { + if (terminalRef.current?.hasSelection()) { return; } + clearSelectionAction(); + } - callback( - links.map(({ match, range }) => ({ - text: match.text, - range, - activate: (event: MouseEvent) => { - if (!isTerminalLinkActivation(event)) return; - - const latestTerminal = terminalRef.current; - if (!latestTerminal) return; - - if (match.kind === "url") { - if (!localApi) { - writeSystemMessage( - latestTerminal, - "Opening links is unavailable in this browser.", - ); - return; - } - const fallbackToBrowser = () => { - void localApi.shell.openExternal(match.text).catch((error: unknown) => { - writeSystemMessage( - latestTerminal, - error instanceof Error ? error.message : "Unable to open link", - ); - }); - }; - void openTerminalLinkInPreview({ - url: match.text, - position: { x: event.clientX, y: event.clientY }, - threadRef, - openPreview, - localApi, - fallbackToBrowser, - }); - return; - } - - const target = resolvePathLinkTarget(match.text, cwd); - void (async () => { - const result = await openTerminalPath(target); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - const error = squashAtomCommandFailure(result); - writeSystemMessage( - latestTerminal, - error instanceof Error ? error.message : "Unable to open path", - ); - })(); - }, - })), + const handleMouseUp = (event: MouseEvent) => { + const shouldHandle = shouldHandleTerminalSelectionMouseUp( + selectionGestureActiveRef.current, + event.button, ); - }, - }); - - const inputDisposable = terminal.onData((data) => { - void (async () => { - const result = await writeTerminal(data); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + selectionGestureActiveRef.current = false; + if (!shouldHandle) { return; } - const error = squashAtomCommandFailure(result); - writeSystemMessage( - terminal, - error instanceof Error ? error.message : "Terminal write failed", - ); - })(); - }); + selectionPointerRef.current = { x: event.clientX, y: event.clientY }; + const delay = terminalSelectionActionDelayForClickCount(event.detail); + selectionActionTimerRef.current = window.setTimeout(() => { + selectionActionTimerRef.current = null; + window.requestAnimationFrame(() => { + void showSelectionAction(); + }); + }, delay); + }; + const handlePointerDown = (event: PointerEvent) => { + clearSelectionAction(); + selectionGestureActiveRef.current = event.button === 0; + }; + window.addEventListener("mouseup", handleMouseUp); + mount.addEventListener("pointerdown", handlePointerDown); + setupCleanups.push(() => { + window.removeEventListener("mouseup", handleMouseUp); + mount.removeEventListener("pointerdown", handlePointerDown); + }); - const selectionDisposable = terminal.onSelectionChange(() => { - if (terminalRef.current?.hasSelection()) { - return; - } - clearSelectionAction(); - }); + const themeObserver = new MutationObserver(() => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + activeTerminal.setTheme(terminalThemeFromApp(containerRef.current)); + }); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style"], + }); + setupCleanups.push(() => themeObserver.disconnect()); - const handleMouseUp = (event: MouseEvent) => { - const shouldHandle = shouldHandleTerminalSelectionMouseUp( - selectionGestureActiveRef.current, - event.button, - ); - selectionGestureActiveRef.current = false; - if (!shouldHandle) { - return; - } - selectionPointerRef.current = { x: event.clientX, y: event.clientY }; - const delay = terminalSelectionActionDelayForClickCount(event.detail); - selectionActionTimerRef.current = window.setTimeout(() => { - selectionActionTimerRef.current = null; - window.requestAnimationFrame(() => { - void showSelectionAction(); - }); - }, delay); - }; - const handlePointerDown = (event: PointerEvent) => { - clearSelectionAction(); - selectionGestureActiveRef.current = event.button === 0; + const fitTimer = window.setTimeout(() => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + const wasAtBottom = activeTerminal.isAtBottom(); + activeTerminal.fit(); + if (wasAtBottom) { + activeTerminal.scrollToBottom(); + } + }, 30); + setupCleanups.push(() => window.clearTimeout(fitTimer)); + + const cleanups = setupCleanups; + setupCleanups = []; + setupTerminal = null; + return () => { + for (const cleanup of cleanups.toReversed()) cleanup(); + if (terminalRef.current === terminal) terminalRef.current = null; + terminal.dispose(); + }; }; - window.addEventListener("mouseup", handleMouseUp); - mount.addEventListener("pointerdown", handlePointerDown); - - const themeObserver = new MutationObserver(() => { - const activeTerminal = terminalRef.current; - if (!activeTerminal) return; - activeTerminal.options.theme = terminalThemeFromApp(containerRef.current); - activeTerminal.refresh(0, activeTerminal.rows - 1); - }); - themeObserver.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class", "style"], - }); - const fitTimer = window.setTimeout(() => { - const activeTerminal = terminalRef.current; - const activeFitAddon = fitAddonRef.current; - if (!activeTerminal || !activeFitAddon) return; - const wasAtBottom = - activeTerminal.buffer.active.viewportY >= activeTerminal.buffer.active.baseY; - fitTerminalSafely(activeFitAddon); - if (wasAtBottom) { - activeTerminal.scrollToBottom(); - } - void resizeTerminal(activeTerminal.cols, activeTerminal.rows); - }, 30); + void setup() + .then((nextTeardown) => { + if (cancelled) { + nextTeardown?.(); + return; + } + teardown = nextTeardown; + }) + .catch((error: unknown) => { + for (const cleanup of setupCleanups.toReversed()) cleanup(); + setupCleanups = []; + if (terminalRef.current === setupTerminal) terminalRef.current = null; + setupTerminal?.dispose(); + setupTerminal = null; + if (cancelled) return; + const message = + error instanceof Error ? error.message : "Unable to initialize libghostty-vt"; + mount.textContent = `${message} — close and reopen the terminal to retry.`; + }); return () => { - window.clearTimeout(fitTimer); - inputDisposable.dispose(); - selectionDisposable.dispose(); - terminalLinksDisposable.dispose(); - if (selectionActionTimerRef.current !== null) { - window.clearTimeout(selectionActionTimerRef.current); - } - window.removeEventListener("mouseup", handleMouseUp); - mount.removeEventListener("pointerdown", handlePointerDown); - themeObserver.disconnect(); - terminalRef.current = null; - fitAddonRef.current = null; - terminal.dispose(); + cancelled = true; + teardown?.(); }; // autoFocus is intentionally omitted; // it is only read at mount time and must not trigger terminal teardown/recreation. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); useEffect(() => { @@ -750,6 +735,7 @@ export function TerminalViewport({ } const previous = previousSessionRef.current; + synchronizeTerminalStatus(terminal, current.status); if (current.version === previous.version) { return; } @@ -768,25 +754,6 @@ export function TerminalViewport({ writeSystemMessage(terminal, current.error); } - if (current.status === "running") { - hasHandledExitRef.current = false; - } else if ( - (current.status === "closed" || current.status === "exited") && - current.status !== previous.status && - !hasHandledExitRef.current - ) { - hasHandledExitRef.current = true; - writeSystemMessage( - terminal, - current.status === "closed" ? "Terminal closed" : "Process exited", - ); - window.setTimeout(() => { - if (hasHandledExitRef.current) { - handleSessionExited(); - } - }, 0); - } - if (previous.version === 0 && autoFocus) { window.requestAnimationFrame(() => { terminal.focus(); @@ -809,15 +776,15 @@ export function TerminalViewport({ useEffect(() => { const terminal = terminalRef.current; - const fitAddon = fitAddonRef.current; - if (!terminal || !fitAddon) return; - const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; + if (!terminal) return; + const wasAtBottom = terminal.isAtBottom(); + // The surface reports grid changes through onResize, which is the single + // channel for PTY resize RPCs; fitting here only refreshes the layout. const frame = window.requestAnimationFrame(() => { - fitTerminalSafely(fitAddon); + terminal.fit(); if (wasAtBottom) { terminal.scrollToBottom(); } - void resizeTerminal(terminal.cols, terminal.rows); }); return () => { window.cancelAnimationFrame(frame); @@ -1276,7 +1243,7 @@ export default function ThreadTerminalDrawer({ {!hasTerminalSidebar && (
-
+
.scrollbar.vertical { - width: 6px !important; -} - -.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar > .slider { - border-radius: 3px; -} - -.thread-terminal-drawer .xterm .xterm-scrollable-element > .scrollbar.vertical > .slider { - width: 6px !important; - left: 0 !important; -} - /* Reasoning select -- clickable label surface */ label:has(> select#reasoning-effort) { position: relative; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index c03cc65f6541..7406f960cd35 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,7 +8,6 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" import "@fontsource-variable/dm-sans/index.css"; import "@fontsource/jetbrains-mono/400.css"; import "@fontsource/jetbrains-mono/500.css"; -import "@xterm/xterm/css/xterm.css"; import "./index.css"; import { isElectron } from "./env"; diff --git a/apps/web/src/terminal/ghostty/README.md b/apps/web/src/terminal/ghostty/README.md new file mode 100644 index 000000000000..37e1c52fb0ca --- /dev/null +++ b/apps/web/src/terminal/ghostty/README.md @@ -0,0 +1,19 @@ +# Ghostty web terminal + +This directory is the browser adapter for the same official `libghostty-vt` C ABI used by Android. +It is intentionally not an xterm compatibility layer. + +- `runtime.ts` owns the singleton WebAssembly instance and runtime ABI layouts. +- `ghostty-write-pty.wasm` is a 112-byte callback trampoline for terminal-generated PTY replies. +- `core.ts` owns per-terminal Ghostty handles and translates the C ABI into render snapshots. +- `renderer.ts` batches backgrounds and style runs into a Canvas 2D frame. +- `surface.ts` owns browser input, IME, selection, scrolling, sizing, links, and cursor blinking. +- `fonts/` vendors the symbols-only Nerd Font (MIT) the surface registers lazily, so + prompt glyphs render without a locally installed Nerd Font. +- `vendor/` holds only the artifacts, reproducibly generated by + `apps/web/scripts/build-libghostty-wasm.sh`. The upstream pin and license live once, at + `native/libghostty-vt/` at the repository root; the wasm embeds the pinned revision + in its build info and the ABI test verifies it against mobile's `VERSION`. + +Keep browser behavior here and terminal transport in the existing client runtime. Do not add React +state to the render loop. Both WASM artifacts are ordinary read-only assets, not executables. diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts new file mode 100644 index 000000000000..204a94b6a8c5 --- /dev/null +++ b/apps/web/src/terminal/ghostty/core.ts @@ -0,0 +1,1189 @@ +import { + type GhosttyKeyboardLayoutMap, + ghosttyKeyForCode, + ghosttyUnshiftedCodepoint, + loadGhosttyKeyboardLayoutMap, +} from "./keyCodes"; +import { GhosttyRuntime, loadGhosttyRuntime } from "./runtime"; + +const GHOSTTY_SUCCESS = 0; +const GHOSTTY_OUT_OF_SPACE = -3; +const MAX_SCROLLBACK_ROWS = 10_000; +// wasm32 C ABI layout for GhosttyTerminalSelectionFormatOptions at the +// libghostty-vt revision pinned alongside this module. +const SELECTION_FORMAT_OPTIONS_SIZE = 16; + +const RENDER_DATA = { + cols: 1, + rows: 2, + dirty: 3, + rowIterator: 4, + background: 5, + foreground: 6, + cursor: 7, + cursorHasValue: 8, + cursorStyle: 10, + cursorVisible: 11, + cursorBlinking: 12, + cursorInViewport: 14, + cursorX: 15, + cursorY: 16, +} as const; + +const ROW_DATA = { + dirty: 1, + raw: 2, + cells: 3, +} as const; + +const CELL_DATA = { + raw: 1, + style: 2, + graphemesLength: 3, + graphemes: 4, + background: 5, + foreground: 6, + selected: 7, +} as const; + +const RAW_CELL_DATA = { + wide: 3, +} as const; + +export const GHOSTTY_CELL_WIDE = { + narrow: 0, + wide: 1, + spacerTail: 2, + spacerHead: 3, +} as const; + +export interface GhosttyColor { + readonly r: number; + readonly g: number; + readonly b: number; +} + +export interface GhosttyTheme { + readonly foreground: GhosttyColor; + readonly background: GhosttyColor; + readonly cursor: GhosttyColor; + /** CSS color the renderer overlays on selected cells; not sent to Ghostty. */ + readonly selectionBackground?: string; +} + +export interface GhosttyCell { + readonly text: string; + readonly wide: number; + readonly foreground: GhosttyColor; + readonly background: GhosttyColor; + readonly bold: boolean; + readonly italic: boolean; + readonly invisible: boolean; + readonly strikethrough: boolean; + readonly overline: boolean; + readonly underline: boolean; + readonly selected: boolean; +} + +export interface GhosttyRow { + readonly cells: readonly GhosttyCell[]; + readonly text: string; + readonly isWrapContinuation: boolean; + /** Whether this row soft-wraps onto the next row. */ + readonly wrapsToNext: boolean; +} + +export interface GhosttySnapshot { + readonly cols: number; + readonly rows: number; + readonly foreground: GhosttyColor; + readonly background: GhosttyColor; + readonly cursor: GhosttyColor; + readonly cursorX: number; + readonly cursorY: number; + readonly cursorVisible: boolean; + readonly cursorBlinking: boolean; + readonly cursorStyle: number; + readonly dirtyRows: ReadonlySet; + readonly rowData: readonly GhosttyRow[]; +} + +export interface GhosttySelectionRange { + readonly viewport: { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; + }; + readonly screen: { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; + }; +} + +export interface GhosttyScrollbar { + readonly total: number; + readonly offset: number; + readonly len: number; +} + +/** Grid position tagged with its Ghostty coordinate space: 1 viewport, 2 screen. */ +export interface GhosttyPointInput { + readonly x: number; + readonly y: number; + readonly tag?: 1 | 2; +} + +export interface GhosttyMouseInput { + readonly action: "press" | "release" | "motion"; + readonly button: number | null; + readonly mods: number; + readonly x: number; + readonly y: number; + readonly screenWidth: number; + readonly screenHeight: number; + readonly cellWidth: number; + readonly cellHeight: number; + readonly paddingLeft: number; + readonly paddingRight: number; + readonly paddingTop: number; + readonly paddingBottom: number; + readonly anyButtonPressed: boolean; +} + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +function blend(foreground: GhosttyColor, background: GhosttyColor): GhosttyColor { + const channel = (front: number, back: number) => Math.floor((front * 155 + back * 100) / 255); + return { + r: channel(foreground.r, background.r), + g: channel(foreground.g, background.g), + b: channel(foreground.b, background.b), + }; +} + +function sameColor(left: GhosttyColor, right: GhosttyColor): boolean { + return left.r === right.r && left.g === right.g && left.b === right.b; +} + +export class GhosttyTerminalCore { + private readonly runtime: GhosttyRuntime; + private terminalSlot = 0; + private terminal = 0; + private renderStateSlot = 0; + private renderState = 0; + private rowIteratorSlot = 0; + private rowCellsSlot = 0; + private keyEncoderSlot = 0; + private keyEncoder = 0; + private keyEventSlot = 0; + private keyEvent = 0; + private mouseEncoderSlot = 0; + private mouseEncoder = 0; + private mouseEventSlot = 0; + private mouseEvent = 0; + private ptyWriterId = 0; + private ptyWriter: ((data: string) => void) | null = null; + private scratch = 0; + private style = 0; + private scrollbar = 0; + private rows: GhosttyRow[] = []; + private disposed = false; + private keyboardLayoutMap: GhosttyKeyboardLayoutMap | undefined; + + private constructor(runtime: GhosttyRuntime) { + this.runtime = runtime; + void loadGhosttyKeyboardLayoutMap().then((layoutMap) => { + if (!this.disposed) this.keyboardLayoutMap = layoutMap; + }); + } + + static async create( + cols: number, + rows: number, + cellWidth: number, + cellHeight: number, + theme: GhosttyTheme, + onPtyData: (data: string) => void, + ): Promise { + const core = new GhosttyTerminalCore(await loadGhosttyRuntime()); + try { + core.initialize(cols, rows, cellWidth, cellHeight, theme, onPtyData); + return core; + } catch (error) { + core.dispose(); + throw error; + } + } + + private initialize( + cols: number, + rows: number, + cellWidth: number, + cellHeight: number, + theme: GhosttyTheme, + onPtyData: (data: string) => void, + ): void { + const optionsSize = this.runtime.layout("GhosttyTerminalOptions").size; + const options = this.runtime.alloc(optionsSize); + this.runtime.setField(options, "GhosttyTerminalOptions", "cols", cols); + this.runtime.setField(options, "GhosttyTerminalOptions", "rows", rows); + this.runtime.setField(options, "GhosttyTerminalOptions", "max_scrollback", MAX_SCROLLBACK_ROWS); + this.terminalSlot = this.runtime.allocOpaque(); + const terminalResult = this.runtime.call("ghostty_terminal_new", 0, this.terminalSlot, options); + this.runtime.free(options, optionsSize); + this.assertSuccess("ghostty_terminal_new", terminalResult); + this.terminal = this.runtime.readPointer(this.terminalSlot); + this.ptyWriter = onPtyData; + this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, onPtyData); + + this.renderStateSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_render_state_new", + this.runtime.call("ghostty_render_state_new", 0, this.renderStateSlot), + ); + this.renderState = this.runtime.readPointer(this.renderStateSlot); + + this.rowIteratorSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_render_state_row_iterator_new", + this.runtime.call("ghostty_render_state_row_iterator_new", 0, this.rowIteratorSlot), + ); + this.rowCellsSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_render_state_row_cells_new", + this.runtime.call("ghostty_render_state_row_cells_new", 0, this.rowCellsSlot), + ); + + this.keyEncoderSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_key_encoder_new", + this.runtime.call("ghostty_key_encoder_new", 0, this.keyEncoderSlot), + ); + this.keyEncoder = this.runtime.readPointer(this.keyEncoderSlot); + this.keyEventSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_key_event_new", + this.runtime.call("ghostty_key_event_new", 0, this.keyEventSlot), + ); + this.keyEvent = this.runtime.readPointer(this.keyEventSlot); + + this.mouseEncoderSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_mouse_encoder_new", + this.runtime.call("ghostty_mouse_encoder_new", 0, this.mouseEncoderSlot), + ); + this.mouseEncoder = this.runtime.readPointer(this.mouseEncoderSlot); + this.mouseEventSlot = this.runtime.allocOpaque(); + this.assertSuccess( + "ghostty_mouse_event_new", + this.runtime.call("ghostty_mouse_event_new", 0, this.mouseEventSlot), + ); + this.mouseEvent = this.runtime.readPointer(this.mouseEventSlot); + + this.scratch = this.runtime.alloc(16); + const styleSize = this.runtime.layout("GhosttyStyle").size; + this.style = this.runtime.alloc(styleSize); + this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); + this.scrollbar = this.runtime.alloc(this.runtime.layout("GhosttyTerminalScrollbar").size); + this.setTheme(theme); + this.resize(cols, rows, cellWidth, cellHeight); + } + + write(data: string | Uint8Array): void { + this.ensureActive(); + const bytes = typeof data === "string" ? encoder.encode(data) : data; + if (bytes.length === 0) return; + const pointer = this.runtime.alloc(bytes.length); + this.runtime.bytes(pointer, bytes.length).set(bytes); + this.runtime.call("ghostty_terminal_vt_write", this.terminal, pointer, bytes.length); + this.runtime.free(pointer, bytes.length); + } + + resetAndWrite(data: string): void { + this.ensureActive(); + this.runtime.call("ghostty_terminal_reset", this.terminal); + this.rows = []; + if (data.length === 0) return; + const writer = this.ptyWriter; + if (this.ptyWriterId !== 0) { + this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId); + this.ptyWriterId = 0; + } + try { + this.write(data); + } finally { + if (writer !== null && !this.disposed) { + this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, writer); + } + } + } + + resize(cols: number, rows: number, cellWidth: number, cellHeight: number): void { + this.ensureActive(); + this.assertSuccess( + "ghostty_terminal_resize", + this.runtime.call( + "ghostty_terminal_resize", + this.terminal, + Math.max(1, Math.min(65_535, cols)), + Math.max(1, Math.min(65_535, rows)), + Math.max(1, Math.round(cellWidth)), + Math.max(1, Math.round(cellHeight)), + ), + ); + } + + setTheme(theme: GhosttyTheme): void { + this.ensureActive(); + const color = this.runtime.alloc(3); + for (const [option, value] of [ + [11, theme.foreground], + [12, theme.background], + [13, theme.cursor], + ] as const) { + this.runtime.bytes(color, 3).set([value.r, value.g, value.b]); + this.runtime.call("ghostty_terminal_set", this.terminal, option, color); + } + this.runtime.free(color, 3); + } + + scroll(deltaRows: number): void { + this.ensureActive(); + const layout = this.runtime.layout("GhosttyTerminalScrollViewport"); + const scroll = this.runtime.alloc(layout.size); + this.runtime.setField(scroll, "GhosttyTerminalScrollViewport", "tag", 2); + const value = layout.fields.value!; + this.runtime.view(scroll + value.offset, value.size).setInt32(0, deltaRows, true); + this.runtime.call("ghostty_terminal_scroll_viewport", this.terminal, scroll); + this.runtime.free(scroll, layout.size); + } + + scrollToBottom(): void { + this.ensureActive(); + const layout = this.runtime.layout("GhosttyTerminalScrollViewport"); + const scroll = this.runtime.alloc(layout.size); + this.runtime.setField(scroll, "GhosttyTerminalScrollViewport", "tag", 1); + this.runtime.call("ghostty_terminal_scroll_viewport", this.terminal, scroll); + this.runtime.free(scroll, layout.size); + } + + isViewportActive(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_terminal_get", this.terminal, 32, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + scrollbarState(): GhosttyScrollbar | null { + this.ensureActive(); + const layout = this.runtime.layout("GhosttyTerminalScrollbar"); + this.runtime.bytes(this.scrollbar, layout.size).fill(0); + if ( + this.runtime.call("ghostty_terminal_get", this.terminal, 9, this.scrollbar) !== + GHOSTTY_SUCCESS + ) { + return null; + } + return { + total: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "total"), + offset: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "offset"), + len: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "len"), + }; + } + + isMouseTracking(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_terminal_get", this.terminal, 11, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + isMouseAnyEventTracking(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_terminal_mode_get", this.terminal, 1003, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + isAlternateScreen(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 4).fill(0); + return ( + this.runtime.call("ghostty_terminal_get", this.terminal, 6, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.view(this.scratch, 4).getUint32(0, true) === 1 + ); + } + + isApplicationCursorKeys(): boolean { + this.ensureActive(); + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_terminal_mode_get", this.terminal, 1, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + encodeKey(event: KeyboardEvent, action: "press" | "release" = "press"): string { + this.ensureActive(); + this.runtime.call("ghostty_key_encoder_setopt_from_terminal", this.keyEncoder, this.terminal); + this.runtime.call( + "ghostty_key_event_set_action", + this.keyEvent, + action === "release" ? 0 : event.repeat ? 2 : 1, + ); + this.runtime.call("ghostty_key_event_set_key", this.keyEvent, ghosttyKeyForCode(event.code)); + const mods = + (event.shiftKey ? 1 : 0) | + (event.ctrlKey ? 1 << 1 : 0) | + (event.altKey ? 1 << 2 : 0) | + (event.metaKey ? 1 << 3 : 0) | + (event.getModifierState("CapsLock") ? 1 << 4 : 0) | + (event.getModifierState("NumLock") ? 1 << 5 : 0); + this.runtime.call("ghostty_key_event_set_mods", this.keyEvent, mods); + this.runtime.call("ghostty_key_event_set_consumed_mods", this.keyEvent, 0); + this.runtime.call("ghostty_key_event_set_composing", this.keyEvent, event.isComposing ? 1 : 0); + this.runtime.call( + "ghostty_key_event_set_unshifted_codepoint", + this.keyEvent, + ghosttyUnshiftedCodepoint(event, this.keyboardLayoutMap), + ); + + const text = event.key.length === 1 ? event.key : ""; + const textBytes = encoder.encode(text); + const textPointer = textBytes.length === 0 ? 0 : this.runtime.alloc(textBytes.length); + if (textPointer !== 0) this.runtime.bytes(textPointer, textBytes.length).set(textBytes); + this.runtime.call("ghostty_key_event_set_utf8", this.keyEvent, textPointer, textBytes.length); + + const written = this.runtime.call("ghostty_wasm_alloc_usize"); + const encoded = this.encodeOutput(written, (output, outputSize) => + this.runtime.call( + "ghostty_key_encoder_encode", + this.keyEncoder, + this.keyEvent, + output, + outputSize, + written, + ), + ); + this.runtime.call("ghostty_wasm_free_usize", written); + if (textPointer !== 0) this.runtime.free(textPointer, textBytes.length); + return encoded; + } + + encodePaste(data: string): string { + this.ensureActive(); + const input = encoder.encode(data); + if (input.length === 0) return ""; + const inputPointer = this.runtime.alloc(input.length); + this.runtime.bytes(inputPointer, input.length).set(input); + this.runtime.bytes(this.scratch, 1)[0] = 0; + const bracketed = + this.runtime.call("ghostty_terminal_mode_get", this.terminal, 2004, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0; + const written = this.runtime.call("ghostty_wasm_alloc_usize"); + let encoded = ""; + const sizeResult = this.runtime.call( + "ghostty_paste_encode", + inputPointer, + input.length, + bracketed ? 1 : 0, + 0, + 0, + written, + ); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { + const output = this.runtime.alloc(outputSize); + const result = this.runtime.call( + "ghostty_paste_encode", + inputPointer, + input.length, + bracketed ? 1 : 0, + output, + outputSize, + written, + ); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + encoded = + result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : ""; + this.runtime.free(output, outputSize); + } + this.runtime.call("ghostty_wasm_free_usize", written); + this.runtime.free(inputPointer, input.length); + return encoded; + } + + encodeMouse(input: GhosttyMouseInput): string { + this.ensureActive(); + this.runtime.call( + "ghostty_mouse_encoder_setopt_from_terminal", + this.mouseEncoder, + this.terminal, + ); + + const sizeLayout = this.runtime.layout("GhosttyMouseEncoderSize"); + const size = this.runtime.alloc(sizeLayout.size); + for (const [field, value] of [ + ["size", sizeLayout.size], + ["screen_width", input.screenWidth], + ["screen_height", input.screenHeight], + ["cell_width", input.cellWidth], + ["cell_height", input.cellHeight], + ["padding_top", input.paddingTop], + ["padding_bottom", input.paddingBottom], + ["padding_right", input.paddingRight], + ["padding_left", input.paddingLeft], + ] as const) { + this.runtime.setField(size, "GhosttyMouseEncoderSize", field, Math.max(0, Math.round(value))); + } + this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 2, size); + this.runtime.free(size, sizeLayout.size); + + this.runtime.bytes(this.scratch, 1)[0] = input.anyButtonPressed ? 1 : 0; + this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 3, this.scratch); + this.runtime.bytes(this.scratch, 1)[0] = 1; + this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 4, this.scratch); + + this.runtime.call( + "ghostty_mouse_event_set_action", + this.mouseEvent, + input.action === "press" ? 0 : input.action === "release" ? 1 : 2, + ); + if (input.button === null) { + this.runtime.call("ghostty_mouse_event_clear_button", this.mouseEvent); + } else { + this.runtime.call("ghostty_mouse_event_set_button", this.mouseEvent, input.button); + } + this.runtime.call("ghostty_mouse_event_set_mods", this.mouseEvent, input.mods); + const positionLayout = this.runtime.layout("GhosttyMousePosition"); + const position = this.runtime.alloc(positionLayout.size); + const positionView = this.runtime.view(position, positionLayout.size); + positionView.setFloat32(positionLayout.fields.x!.offset, input.x, true); + positionView.setFloat32(positionLayout.fields.y!.offset, input.y, true); + this.runtime.call("ghostty_mouse_event_set_position", this.mouseEvent, position); + this.runtime.free(position, positionLayout.size); + + const written = this.runtime.call("ghostty_wasm_alloc_usize"); + const encoded = this.encodeOutput(written, (output, outputSize) => + this.runtime.call( + "ghostty_mouse_encoder_encode", + this.mouseEncoder, + this.mouseEvent, + output, + outputSize, + written, + ), + ); + this.runtime.call("ghostty_wasm_free_usize", written); + return encoded; + } + + setSelection(anchor: GhosttyPointInput, end: GhosttyPointInput): void { + this.ensureActive(); + const selectionLayout = this.runtime.layout("GhosttySelection"); + const gridRefSize = this.runtime.layout("GhosttyGridRef").size; + const selection = this.runtime.alloc(selectionLayout.size); + let start = 0; + let endRef = 0; + try { + this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size); + start = this.gridRef(anchor.x, anchor.y, anchor.tag ?? 1); + endRef = this.gridRef(end.x, end.y, end.tag ?? 1); + const startField = selectionLayout.fields.start!; + const endField = selectionLayout.fields.end!; + this.runtime + .bytes(selection + startField.offset, startField.size) + .set(this.runtime.bytes(start, startField.size)); + this.runtime + .bytes(selection + endField.offset, endField.size) + .set(this.runtime.bytes(endRef, endField.size)); + this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); + } finally { + this.runtime.free(start, gridRefSize); + this.runtime.free(endRef, gridRefSize); + this.runtime.free(selection, selectionLayout.size); + } + } + + selectAll(): void { + this.ensureActive(); + const layout = this.runtime.layout("GhosttySelection"); + const selection = this.runtime.alloc(layout.size); + this.runtime.setField(selection, "GhosttySelection", "size", layout.size); + if ( + this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) === GHOSTTY_SUCCESS + ) { + this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); + } + this.runtime.free(selection, layout.size); + } + + selectWord(col: number, row: number): GhosttySelectionRange | null { + return this.selectAt( + "GhosttyTerminalSelectWordOptions", + "ghostty_terminal_select_word", + col, + row, + ); + } + + selectLine(col: number, row: number): GhosttySelectionRange | null { + return this.selectAt( + "GhosttyTerminalSelectLineOptions", + "ghostty_terminal_select_line", + col, + row, + ); + } + + hyperlinkAt(col: number, row: number): string | null { + this.ensureActive(); + const ref = this.gridRef(col, row); + const written = this.runtime.call("ghostty_wasm_alloc_usize"); + const sizeResult = this.runtime.call("ghostty_grid_ref_hyperlink_uri", ref, 0, 0, written); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + let hyperlink: string | null = null; + if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { + const output = this.runtime.alloc(outputSize); + const result = this.runtime.call( + "ghostty_grid_ref_hyperlink_uri", + ref, + output, + outputSize, + written, + ); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + if (result === GHOSTTY_SUCCESS && outputLength > 0) { + hyperlink = decoder.decode(this.runtime.bytes(output, outputLength)); + } + this.runtime.free(output, outputSize); + } + this.runtime.call("ghostty_wasm_free_usize", written); + this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); + return hyperlink; + } + + clearSelection(): void { + this.ensureActive(); + this.runtime.call("ghostty_terminal_set", this.terminal, 21, 0); + } + + snapshot(): GhosttySnapshot { + this.ensureActive(); + this.assertSuccess( + "ghostty_render_state_update", + this.runtime.call("ghostty_render_state_update", this.renderState, this.terminal), + ); + const cols = this.getU16(RENDER_DATA.cols); + const rowCount = this.getU16(RENDER_DATA.rows); + const dirty = this.getU32(RENDER_DATA.dirty); + const foreground = this.getColor(RENDER_DATA.foreground, { r: 229, g: 231, b: 235 }); + const background = this.getColor(RENDER_DATA.background, { r: 0, g: 0, b: 0 }); + const cursorHasValue = this.getBool(RENDER_DATA.cursorHasValue); + const cursor = cursorHasValue ? this.getColor(RENDER_DATA.cursor, foreground) : foreground; + const cursorInViewport = this.getBool(RENDER_DATA.cursorInViewport); + const cursorVisible = this.getBool(RENDER_DATA.cursorVisible) && cursorInViewport; + const cursorX = cursorInViewport ? this.getU16(RENDER_DATA.cursorX) : -1; + const cursorY = cursorInViewport ? this.getU16(RENDER_DATA.cursorY) : -1; + + if (this.rows.length !== rowCount || this.rows.some((row) => row.cells.length !== cols)) { + this.rows = Array.from({ length: rowCount }, () => ({ + cells: Array.from({ length: cols }, () => this.emptyCell(foreground, background)), + text: "", + isWrapContinuation: false, + wrapsToNext: false, + })); + } + + const dirtyRows = new Set(); + if (dirty !== 0) { + this.assertSuccess( + "ghostty_render_state_get(row iterator)", + this.runtime.call( + "ghostty_render_state_get", + this.renderState, + RENDER_DATA.rowIterator, + this.rowIteratorSlot, + ), + ); + const iterator = this.runtime.readPointer(this.rowIteratorSlot); + let rowIndex = 0; + while ( + rowIndex < rowCount && + this.runtime.call("ghostty_render_state_row_iterator_next", iterator) !== 0 + ) { + const rowDirty = dirty === 2 || this.getRowBool(iterator, ROW_DATA.dirty); + if (rowDirty) { + this.rows[rowIndex] = this.readRow(iterator, cols, foreground, background); + dirtyRows.add(rowIndex); + this.runtime.bytes(this.scratch, 1)[0] = 0; + this.runtime.call("ghostty_render_state_row_set", iterator, 0, this.scratch); + } + rowIndex += 1; + } + this.runtime.view(this.scratch, 4).setUint32(0, 0, true); + this.runtime.call("ghostty_render_state_set", this.renderState, 0, this.scratch); + } + + return { + cols, + rows: rowCount, + foreground, + background, + cursor, + cursorX, + cursorY, + cursorVisible, + cursorBlinking: this.getBool(RENDER_DATA.cursorBlinking), + cursorStyle: this.getU32(RENDER_DATA.cursorStyle), + dirtyRows, + rowData: this.rows, + }; + } + + selectionText(): string { + this.ensureActive(); + const options = this.runtime.alloc(SELECTION_FORMAT_OPTIONS_SIZE); + const optionsView = this.runtime.view(options, SELECTION_FORMAT_OPTIONS_SIZE); + optionsView.setUint32(0, SELECTION_FORMAT_OPTIONS_SIZE, true); + optionsView.setUint32(4, 0, true); + optionsView.setUint8(8, 1); + optionsView.setUint8(9, 1); + optionsView.setUint32(12, 0, true); + const written = this.runtime.call("ghostty_wasm_alloc_usize"); + const sizeResult = this.runtime.call( + "ghostty_terminal_selection_format_buf", + this.terminal, + options, + 0, + 0, + written, + ); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + let text = ""; + if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) { + const output = this.runtime.alloc(outputSize); + const result = this.runtime.call( + "ghostty_terminal_selection_format_buf", + this.terminal, + options, + output, + outputSize, + written, + ); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + if (result === GHOSTTY_SUCCESS) { + text = decoder.decode(this.runtime.bytes(output, outputLength)); + } + this.runtime.free(output, outputSize); + } + this.runtime.call("ghostty_wasm_free_usize", written); + this.runtime.free(options, SELECTION_FORMAT_OPTIONS_SIZE); + return text; + } + + viewportPointToScreen(col: number, row: number): { x: number; y: number } | null { + return this.convertPoint(col, row, 1, 2); + } + + screenPointToViewport(col: number, row: number): { x: number; y: number } | null { + return this.convertPoint(col, row, 2, 1); + } + + private convertPoint( + col: number, + row: number, + fromTag: 1 | 2, + toTag: 1 | 2, + ): { x: number; y: number } | null { + this.ensureActive(); + const ref = this.gridRef(col, row, fromTag); + const point = this.pointFromGridRef(ref, toTag); + this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); + return point; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.mouseEvent) this.runtime.call("ghostty_mouse_event_free", this.mouseEvent); + if (this.mouseEncoder) this.runtime.call("ghostty_mouse_encoder_free", this.mouseEncoder); + if (this.keyEvent) this.runtime.call("ghostty_key_event_free", this.keyEvent); + if (this.keyEncoder) this.runtime.call("ghostty_key_encoder_free", this.keyEncoder); + if (this.rowCellsSlot) { + const cells = this.runtime.readPointer(this.rowCellsSlot); + if (cells) this.runtime.call("ghostty_render_state_row_cells_free", cells); + } + if (this.rowIteratorSlot) { + const iterator = this.runtime.readPointer(this.rowIteratorSlot); + if (iterator) this.runtime.call("ghostty_render_state_row_iterator_free", iterator); + } + if (this.renderState) this.runtime.call("ghostty_render_state_free", this.renderState); + if (this.terminal) { + if (this.ptyWriterId) this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId); + this.runtime.call("ghostty_terminal_free", this.terminal); + } + if (this.style) this.runtime.free(this.style, this.runtime.layout("GhosttyStyle").size); + if (this.scrollbar) { + this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size); + } + if (this.scratch) this.runtime.free(this.scratch, 16); + for (const slot of [ + this.mouseEventSlot, + this.mouseEncoderSlot, + this.keyEventSlot, + this.keyEncoderSlot, + this.rowCellsSlot, + this.rowIteratorSlot, + this.renderStateSlot, + this.terminalSlot, + ]) { + this.runtime.freeOpaque(slot); + } + } + + private encodeOutput( + written: number, + encode: (output: number, outputSize: number) => number, + ): string { + const sizeResult = encode(0, 0); + const outputSize = this.runtime.view(written, 4).getUint32(0, true); + if (sizeResult === GHOSTTY_SUCCESS && outputSize === 0) return ""; + if (sizeResult !== GHOSTTY_OUT_OF_SPACE || outputSize === 0) return ""; + + const output = this.runtime.alloc(outputSize); + const result = encode(output, outputSize); + const outputLength = this.runtime.view(written, 4).getUint32(0, true); + const encoded = + result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : ""; + this.runtime.free(output, outputSize); + return encoded; + } + + private readRow( + iterator: number, + cols: number, + defaultForeground: GhosttyColor, + defaultBackground: GhosttyColor, + ): GhosttyRow { + this.assertSuccess( + "ghostty_render_state_row_get(raw)", + this.runtime.call("ghostty_render_state_row_get", iterator, ROW_DATA.raw, this.scratch), + ); + const rawRow = this.runtime.view(this.scratch, 8).getBigUint64(0, true); + this.runtime.bytes(this.scratch + 8, 1)[0] = 0; + this.assertSuccess( + "ghostty_row_get(wrap continuation)", + this.runtime.call("ghostty_row_get", rawRow, 2, this.scratch + 8), + ); + const isWrapContinuation = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; + this.runtime.bytes(this.scratch + 8, 1)[0] = 0; + this.assertSuccess( + "ghostty_row_get(wrap)", + this.runtime.call("ghostty_row_get", rawRow, 1, this.scratch + 8), + ); + const wrapsToNext = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0; + + this.assertSuccess( + "ghostty_render_state_row_get(cells)", + this.runtime.call( + "ghostty_render_state_row_get", + iterator, + ROW_DATA.cells, + this.rowCellsSlot, + ), + ); + const cellsIterator = this.runtime.readPointer(this.rowCellsSlot); + const cells: GhosttyCell[] = []; + while ( + cells.length < cols && + this.runtime.call("ghostty_render_state_row_cells_next", cellsIterator) !== 0 + ) { + let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground); + let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground); + const styleSize = this.runtime.layout("GhosttyStyle").size; + this.runtime.bytes(this.style, styleSize).fill(0); + this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize); + this.runtime.call( + "ghostty_render_state_row_cells_get", + cellsIterator, + CELL_DATA.style, + this.style, + ); + const inverse = this.runtime.readField(this.style, "GhosttyStyle", "inverse") !== 0; + if (inverse) [foreground, background] = [background, foreground]; + if (this.runtime.readField(this.style, "GhosttyStyle", "faint") !== 0) { + foreground = blend(foreground, background); + } + const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength); + let text = ""; + if (graphemeLength > 0) { + const bufferSize = graphemeLength * 4; + const codepoints = this.runtime.alloc(bufferSize); + if ( + this.runtime.call( + "ghostty_render_state_row_cells_get", + cellsIterator, + CELL_DATA.graphemes, + codepoints, + ) === GHOSTTY_SUCCESS + ) { + // Read through a DataView: the byte-array allocator guarantees no + // 4-byte alignment, which a Uint32Array view would require. + const codepointView = this.runtime.view(codepoints, bufferSize); + const codes: number[] = []; + for (let index = 0; index < graphemeLength; index += 1) { + codes.push(codepointView.getUint32(index * 4, true)); + } + text = String.fromCodePoint(...codes); + } + this.runtime.free(codepoints, bufferSize); + } + let wide = 0; + if (text.length === 0 && cells.at(-1)?.text.length) { + this.assertSuccess( + "ghostty_render_state_row_cells_get(raw)", + this.runtime.call( + "ghostty_render_state_row_cells_get", + cellsIterator, + CELL_DATA.raw, + this.scratch, + ), + ); + const rawCell = this.runtime.view(this.scratch, 8).getBigUint64(0, true); + this.runtime.view(this.scratch + 8, 4).setUint32(0, 0, true); + this.assertSuccess( + "ghostty_cell_get(wide)", + this.runtime.call("ghostty_cell_get", rawCell, RAW_CELL_DATA.wide, this.scratch + 8), + ); + wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true); + } + cells.push({ + text, + wide, + foreground, + background, + bold: this.runtime.readField(this.style, "GhosttyStyle", "bold") !== 0, + italic: this.runtime.readField(this.style, "GhosttyStyle", "italic") !== 0, + invisible: this.runtime.readField(this.style, "GhosttyStyle", "invisible") !== 0, + strikethrough: this.runtime.readField(this.style, "GhosttyStyle", "strikethrough") !== 0, + overline: this.runtime.readField(this.style, "GhosttyStyle", "overline") !== 0, + underline: this.runtime.readField(this.style, "GhosttyStyle", "underline") !== 0, + selected: this.getCellBool(cellsIterator, CELL_DATA.selected), + }); + } + while (cells.length < cols) cells.push(this.emptyCell(defaultForeground, defaultBackground)); + return { + cells, + text: cells + .map((cell) => cell.text || " ") + .join("") + .trimEnd(), + isWrapContinuation, + wrapsToNext, + }; + } + + private gridRef(col: number, row: number, tag: 1 | 2 = 1): number { + const pointLayout = this.runtime.layout("GhosttyPoint"); + const point = this.runtime.alloc(pointLayout.size); + this.runtime.setField(point, "GhosttyPoint", "tag", tag); + const pointValue = pointLayout.fields.value!; + const valueOffset = pointValue.offset; + const view = this.runtime.view(point + valueOffset, pointValue.size); + view.setUint16(0, Math.max(0, col), true); + view.setUint32(4, Math.max(0, row), true); + const gridRefSize = this.runtime.layout("GhosttyGridRef").size; + const gridRef = this.runtime.alloc(gridRefSize); + this.runtime.setField(gridRef, "GhosttyGridRef", "size", gridRefSize); + const result = this.runtime.call("ghostty_terminal_grid_ref", this.terminal, point, gridRef); + this.runtime.free(point, pointLayout.size); + if (result !== GHOSTTY_SUCCESS) { + this.runtime.free(gridRef, gridRefSize); + this.assertSuccess("ghostty_terminal_grid_ref", result); + } + return gridRef; + } + + private selectAt( + optionsName: "GhosttyTerminalSelectWordOptions" | "GhosttyTerminalSelectLineOptions", + operation: "ghostty_terminal_select_word" | "ghostty_terminal_select_line", + col: number, + row: number, + ): GhosttySelectionRange | null { + this.ensureActive(); + const optionsLayout = this.runtime.layout(optionsName); + const selectionLayout = this.runtime.layout("GhosttySelection"); + const options = this.runtime.alloc(optionsLayout.size); + let ref = 0; + let selection = 0; + let range: GhosttySelectionRange | null = null; + try { + this.runtime.setField(options, optionsName, "size", optionsLayout.size); + ref = this.gridRef(col, row); + const refField = optionsLayout.fields.ref!; + this.runtime + .bytes(options + refField.offset, refField.size) + .set(this.runtime.bytes(ref, refField.size)); + selection = this.runtime.alloc(selectionLayout.size); + this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size); + const result = this.runtime.call(operation, this.terminal, options, selection); + if (result === GHOSTTY_SUCCESS) { + const start = selection + selectionLayout.fields.start!.offset; + const end = selection + selectionLayout.fields.end!.offset; + const viewportStart = this.pointFromGridRef(start, 1); + const viewportEnd = this.pointFromGridRef(end, 1); + const screenStart = this.pointFromGridRef(start, 2); + const screenEnd = this.pointFromGridRef(end, 2); + if (viewportStart && viewportEnd && screenStart && screenEnd) { + range = { + viewport: { start: viewportStart, end: viewportEnd }, + screen: { start: screenStart, end: screenEnd }, + }; + } + this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection); + } + } finally { + this.runtime.free(selection, selectionLayout.size); + this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); + this.runtime.free(options, optionsLayout.size); + } + return range; + } + + private pointFromGridRef(ref: number, tag: 1 | 2): { x: number; y: number } | null { + const coordinateLayout = this.runtime.layout("GhosttyPointCoordinate"); + const coordinate = this.runtime.alloc(coordinateLayout.size); + const result = this.runtime.call( + "ghostty_terminal_point_from_grid_ref", + this.terminal, + ref, + tag, + coordinate, + ); + const point = + result === GHOSTTY_SUCCESS + ? { + x: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "x"), + y: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "y"), + } + : null; + this.runtime.free(coordinate, coordinateLayout.size); + return point; + } + + private getU16(data: number): number { + this.runtime.bytes(this.scratch, 2).fill(0); + this.assertSuccess( + "ghostty_render_state_get", + this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), + ); + return this.runtime.view(this.scratch, 2).getUint16(0, true); + } + + private getU32(data: number): number { + this.runtime.bytes(this.scratch, 4).fill(0); + this.assertSuccess( + "ghostty_render_state_get", + this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), + ); + return this.runtime.view(this.scratch, 4).getUint32(0, true); + } + + private getBool(data: number): boolean { + this.runtime.bytes(this.scratch, 1)[0] = 0; + this.assertSuccess( + "ghostty_render_state_get", + this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch), + ); + return this.runtime.bytes(this.scratch, 1)[0] !== 0; + } + + private getColor(data: number, fallback: GhosttyColor): GhosttyColor { + this.runtime.bytes(this.scratch, 3).fill(0); + const result = this.runtime.call( + "ghostty_render_state_get", + this.renderState, + data, + this.scratch, + ); + return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback; + } + + private getRowBool(iterator: number, data: number): boolean { + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_render_state_row_get", iterator, data, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + private getCellU32(iterator: number, data: number): number { + this.runtime.bytes(this.scratch, 4).fill(0); + const result = this.runtime.call( + "ghostty_render_state_row_cells_get", + iterator, + data, + this.scratch, + ); + return result === GHOSTTY_SUCCESS ? this.runtime.view(this.scratch, 4).getUint32(0, true) : 0; + } + + private getCellBool(iterator: number, data: number): boolean { + this.runtime.bytes(this.scratch, 1)[0] = 0; + return ( + this.runtime.call("ghostty_render_state_row_cells_get", iterator, data, this.scratch) === + GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0 + ); + } + + private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor { + this.runtime.bytes(this.scratch, 3).fill(0); + const result = this.runtime.call( + "ghostty_render_state_row_cells_get", + iterator, + data, + this.scratch, + ); + return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback; + } + + private readColor(pointer: number): GhosttyColor { + const bytes = this.runtime.bytes(pointer, 3); + return { r: bytes[0] ?? 0, g: bytes[1] ?? 0, b: bytes[2] ?? 0 }; + } + + private emptyCell(foreground: GhosttyColor, background: GhosttyColor): GhosttyCell { + return { + text: "", + wide: 0, + foreground, + background, + bold: false, + italic: false, + invisible: false, + strikethrough: false, + overline: false, + underline: false, + selected: false, + }; + } + + private assertSuccess(operation: string, result: number): void { + if (result !== GHOSTTY_SUCCESS) throw new Error(`${operation} failed with result ${result}`); + } + + private ensureActive(): void { + if (this.disposed) throw new Error("libghostty-vt terminal has been disposed"); + } +} + +export function ghosttyColorsEqual(left: GhosttyColor, right: GhosttyColor): boolean { + return sameColor(left, right); +} diff --git a/apps/web/src/terminal/ghostty/fonts/LICENSE b/apps/web/src/terminal/ghostty/fonts/LICENSE new file mode 100644 index 000000000000..06eb073d6b54 --- /dev/null +++ b/apps/web/src/terminal/ghostty/fonts/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Ryan L McIntyre + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/web/src/terminal/ghostty/fonts/SymbolsNerdFontMono-Regular.woff2 b/apps/web/src/terminal/ghostty/fonts/SymbolsNerdFontMono-Regular.woff2 new file mode 100644 index 000000000000..1e2127fadb98 Binary files /dev/null and b/apps/web/src/terminal/ghostty/fonts/SymbolsNerdFontMono-Regular.woff2 differ diff --git a/apps/web/src/terminal/ghostty/keyCodes.test.ts b/apps/web/src/terminal/ghostty/keyCodes.test.ts new file mode 100644 index 000000000000..d6fc0cca45e8 --- /dev/null +++ b/apps/web/src/terminal/ghostty/keyCodes.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ghosttyKeyForCode, ghosttyUnshiftedCodepoint } from "./keyCodes"; + +describe("ghosttyKeyForCode", () => { + it("keeps the tail of the pinned Ghostty key enum in order", () => { + expect(ghosttyKeyForCode("F25")).toBe(ghosttyKeyForCode("F24") + 1); + expect(ghosttyKeyForCode("PrintScreen")).toBe(ghosttyKeyForCode("FnLock") + 1); + expect(ghosttyKeyForCode("Pause")).toBe(ghosttyKeyForCode("ScrollLock") + 1); + expect(ghosttyKeyForCode("Paste")).toBe(ghosttyKeyForCode("Cut") + 1); + }); +}); + +describe("ghosttyUnshiftedCodepoint", () => { + it("provides the logical base character for Kitty keyboard encoding", () => { + expect(ghosttyUnshiftedCodepoint({ code: "KeyC", key: "c", shiftKey: false })).toBe( + "c".codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: "KeyC", key: "C", shiftKey: true })).toBe( + "c".codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: "Digit1", key: "!", shiftKey: true })).toBe( + "1".codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: "Slash", key: "?", shiftKey: true })).toBe( + "/".codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: "Digit1", key: "&", shiftKey: false })).toBe( + "&".codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: "Enter", key: "Enter", shiftKey: false })).toBe(0); + }); + + it("reports unknown instead of the shifted character without layout data", () => { + expect(ghosttyUnshiftedCodepoint({ code: "Digit7", key: "/", shiftKey: true })).toBe(0); + expect(ghosttyUnshiftedCodepoint({ code: "KeyD", key: "Д", shiftKey: true })).toBe( + "д".codePointAt(0), + ); + }); + + it("prefers the active browser layout over US physical key positions", () => { + const layoutMap = new Map([ + ["Digit1", "&"], + ["KeyC", "j"], + ]); + expect(ghosttyUnshiftedCodepoint({ code: "Digit1", key: "1", shiftKey: true }, layoutMap)).toBe( + "&".codePointAt(0), + ); + expect(ghosttyUnshiftedCodepoint({ code: "KeyC", key: "J", shiftKey: true }, layoutMap)).toBe( + "j".codePointAt(0), + ); + }); +}); diff --git a/apps/web/src/terminal/ghostty/keyCodes.ts b/apps/web/src/terminal/ghostty/keyCodes.ts new file mode 100644 index 000000000000..a82c748b2745 --- /dev/null +++ b/apps/web/src/terminal/ghostty/keyCodes.ts @@ -0,0 +1,258 @@ +// This order mirrors GhosttyKey in ghostty/vt/key/event.h. The values are +// intentionally derived from the official W3C-aligned enum instead of +// maintaining a second keyboard protocol. +const ghosttyKeyboardCodes = [ + "Unidentified", + "Backquote", + "Backslash", + "BracketLeft", + "BracketRight", + "Comma", + "Digit0", + "Digit1", + "Digit2", + "Digit3", + "Digit4", + "Digit5", + "Digit6", + "Digit7", + "Digit8", + "Digit9", + "Equal", + "IntlBackslash", + "IntlRo", + "IntlYen", + "KeyA", + "KeyB", + "KeyC", + "KeyD", + "KeyE", + "KeyF", + "KeyG", + "KeyH", + "KeyI", + "KeyJ", + "KeyK", + "KeyL", + "KeyM", + "KeyN", + "KeyO", + "KeyP", + "KeyQ", + "KeyR", + "KeyS", + "KeyT", + "KeyU", + "KeyV", + "KeyW", + "KeyX", + "KeyY", + "KeyZ", + "Minus", + "Period", + "Quote", + "Semicolon", + "Slash", + "AltLeft", + "AltRight", + "Backspace", + "CapsLock", + "ContextMenu", + "ControlLeft", + "ControlRight", + "Enter", + "MetaLeft", + "MetaRight", + "ShiftLeft", + "ShiftRight", + "Space", + "Tab", + "Convert", + "KanaMode", + "NonConvert", + "Delete", + "End", + "Help", + "Home", + "Insert", + "PageDown", + "PageUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "NumLock", + "Numpad0", + "Numpad1", + "Numpad2", + "Numpad3", + "Numpad4", + "Numpad5", + "Numpad6", + "Numpad7", + "Numpad8", + "Numpad9", + "NumpadAdd", + "NumpadBackspace", + "NumpadClear", + "NumpadClearEntry", + "NumpadComma", + "NumpadDecimal", + "NumpadDivide", + "NumpadEnter", + "NumpadEqual", + "NumpadMemoryAdd", + "NumpadMemoryClear", + "NumpadMemoryRecall", + "NumpadMemoryStore", + "NumpadMemorySubtract", + "NumpadMultiply", + "NumpadParenLeft", + "NumpadParenRight", + "NumpadSubtract", + "NumpadSeparator", + "NumpadArrowUp", + "NumpadArrowDown", + "NumpadArrowRight", + "NumpadArrowLeft", + "NumpadBegin", + "NumpadHome", + "NumpadEnd", + "NumpadInsert", + "NumpadDelete", + "NumpadPageUp", + "NumpadPageDown", + "Escape", + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12", + "F13", + "F14", + "F15", + "F16", + "F17", + "F18", + "F19", + "F20", + "F21", + "F22", + "F23", + "F24", + "F25", + "Fn", + "FnLock", + "PrintScreen", + "ScrollLock", + "Pause", + "BrowserBack", + "BrowserFavorites", + "BrowserForward", + "BrowserHome", + "BrowserRefresh", + "BrowserSearch", + "BrowserStop", + "Eject", + "LaunchApp1", + "LaunchApp2", + "LaunchMail", + "MediaPlayPause", + "MediaSelect", + "MediaStop", + "MediaTrackNext", + "MediaTrackPrevious", + "Power", + "Sleep", + "AudioVolumeDown", + "AudioVolumeMute", + "AudioVolumeUp", + "WakeUp", + "Copy", + "Cut", + "Paste", +] as const; + +const codeToGhosttyKey = new Map( + ghosttyKeyboardCodes.map((code, index) => [code, index]), +); + +export function ghosttyKeyForCode(code: string): number { + return codeToGhosttyKey.get(code) ?? 0; +} + +export interface GhosttyKeyboardLayoutMap { + get(code: string): string | undefined; +} + +const shiftedToUnshiftedCharacter = new Map([ + ["!", "1"], + ["@", "2"], + ["#", "3"], + ["$", "4"], + ["%", "5"], + ["^", "6"], + ["&", "7"], + ["*", "8"], + ["(", "9"], + [")", "0"], + ["~", "`"], + ["_", "-"], + ["+", "="], + ["{", "["], + ["}", "]"], + ["|", "\\"], + [":", ";"], + ['"', "'"], + ["<", ","], + [">", "."], + ["?", "/"], +]); + +let keyboardLayoutMapPromise: Promise | undefined; + +export function loadGhosttyKeyboardLayoutMap(): Promise { + if (keyboardLayoutMapPromise) return keyboardLayoutMapPromise; + const browserNavigator = globalThis.navigator as + | (Navigator & { + readonly keyboard?: { + getLayoutMap(): Promise; + }; + }) + | undefined; + const keyboard = browserNavigator?.keyboard; + const promise = keyboard?.getLayoutMap().catch(() => undefined) ?? Promise.resolve(undefined); + keyboardLayoutMapPromise = promise; + return promise; +} + +export function ghosttyUnshiftedCodepoint( + event: Pick, + layoutMap?: GhosttyKeyboardLayoutMap, +): number { + if ([...event.key].length !== 1) return 0; + const layoutCharacter = layoutMap?.get(event.code); + if (layoutCharacter && [...layoutCharacter].length === 1) { + return layoutCharacter.codePointAt(0) ?? 0; + } + if (/^[A-Z]$/u.test(event.key)) return event.key.charCodeAt(0) + 32; + if (event.shiftKey) { + const unshiftedCharacter = shiftedToUnshiftedCharacter.get(event.key); + if (unshiftedCharacter) return unshiftedCharacter.codePointAt(0) ?? 0; + const lowercase = event.key.toLowerCase(); + if (lowercase !== event.key && [...lowercase].length === 1) { + return lowercase.codePointAt(0) ?? 0; + } + // Without layout data the unshifted form of a shifted key is unknowable; + // reporting the shifted character as unshifted corrupts Kitty alternate keys. + return 0; + } + return event.key.codePointAt(0) ?? 0; +} diff --git a/apps/web/src/terminal/ghostty/renderer.test.ts b/apps/web/src/terminal/ghostty/renderer.test.ts new file mode 100644 index 000000000000..e4d292a2c336 --- /dev/null +++ b/apps/web/src/terminal/ghostty/renderer.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { GHOSTTY_CELL_WIDE, type GhosttyCell, type GhosttySnapshot } from "./core"; +import { + ghosttyTextRunEnd, + measureGhosttyCell, + renderGhosttySnapshot, + terminalGridSize, +} from "./renderer"; + +const cell = (text: string, wide = 0): GhosttyCell => ({ + text, + wide, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + bold: false, + italic: false, + invisible: false, + strikethrough: false, + overline: false, + underline: false, + selected: false, +}); + +describe("terminalGridSize", () => { + it("matches the mobile renderer's cell-and-padding sizing model", () => { + expect(terminalGridSize(808, 408, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({ + cols: 80, + rows: 20, + }); + }); + + it("never sends an invalid zero-sized terminal to libghostty", () => { + expect(terminalGridSize(0, 0, { width: 10, height: 20, baseline: 15 }, 4)).toEqual({ + cols: 1, + rows: 1, + }); + }); +}); + +describe("measureGhosttyCell", () => { + it("uses descender-aware metrics and the mobile terminal line-height", () => { + const measureText = (text: string) => + text === "M" + ? { width: 7.2, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 0 } + : { width: 14.4, actualBoundingBoxAscent: 9, actualBoundingBoxDescent: 3 }; + const context = { + font: "", + measureText, + } as unknown as CanvasRenderingContext2D; + + expect(measureGhosttyCell(context, 12, "monospace")).toEqual({ + width: 7.2, + height: 16, + baseline: 11, + }); + }); +}); + +describe("ghosttyTextRunEnd", () => { + it("includes wide spacer tails in the visual clip without rendering spaces", () => { + const cells = [ + cell("界", GHOSTTY_CELL_WIDE.wide), + cell("", GHOSTTY_CELL_WIDE.spacerTail), + cell("🙂", GHOSTTY_CELL_WIDE.wide), + cell("", GHOSTTY_CELL_WIDE.spacerTail), + cell(""), + ]; + expect(ghosttyTextRunEnd(cells, 0, () => true)).toBe(4); + }); +}); + +describe("renderGhosttySnapshot", () => { + it("constrains text runs and cursor glyphs to their terminal cells", () => { + const fillTextCalls: unknown[][] = []; + const context = { + canvas: { width: 200, height: 40 }, + beginPath: () => {}, + clip: () => {}, + fillRect: () => {}, + fillText: (...args: unknown[]) => fillTextCalls.push(args), + rect: () => {}, + resetTransform: () => {}, + restore: () => {}, + save: () => {}, + set fillStyle(_value: string) {}, + set font(_value: string) {}, + set textBaseline(_value: string) {}, + } as unknown as CanvasRenderingContext2D; + const cells = [cell("a"), cell("b"), cell("x")]; + const snapshot: GhosttySnapshot = { + cols: 3, + rows: 1, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + cursorX: 2, + cursorY: 0, + cursorVisible: true, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set([0]), + rowData: [{ cells, text: "abx", isWrapContinuation: false, wrapsToNext: false }], + }; + + renderGhosttySnapshot({ + context, + snapshot, + metrics: { width: 7.2, height: 16, baseline: 11 }, + fontSize: 12, + fontFamily: "monospace", + padding: 4, + forceFull: false, + cursorOn: true, + }); + + expect(fillTextCalls).toEqual([ + ["abx", 4, 15, 21.6], + ["x", 18.4, 15, 7.2], + ]); + }); + + it("repaints the previous cursor row after the cursor moves", () => { + const clearedRows: number[] = []; + const context = { + canvas: { width: 200, height: 80 }, + beginPath: () => {}, + clip: () => {}, + fillRect: (_left: number, top: number, _width: number, height: number) => { + if (height === 16) clearedRows.push(top); + }, + fillText: () => {}, + rect: () => {}, + resetTransform: () => {}, + restore: () => {}, + save: () => {}, + set fillStyle(_value: string) {}, + set font(_value: string) {}, + set textBaseline(_value: string) {}, + } as unknown as CanvasRenderingContext2D; + const snapshot: GhosttySnapshot = { + cols: 1, + rows: 3, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + cursorX: 0, + cursorY: 2, + cursorVisible: true, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set(), + rowData: [0, 1, 2].map(() => ({ + cells: [cell("")], + text: "", + isWrapContinuation: false, + wrapsToNext: false, + })), + }; + + renderGhosttySnapshot({ + context, + snapshot, + metrics: { width: 7.2, height: 16, baseline: 11 }, + fontSize: 12, + fontFamily: "monospace", + padding: 4, + forceFull: false, + cursorOn: true, + previousCursorY: 0, + }); + + expect(clearedRows).toEqual([4, 36, 36]); + }); +}); diff --git a/apps/web/src/terminal/ghostty/renderer.ts b/apps/web/src/terminal/ghostty/renderer.ts new file mode 100644 index 000000000000..0cb7d6f535d6 --- /dev/null +++ b/apps/web/src/terminal/ghostty/renderer.ts @@ -0,0 +1,255 @@ +import { + GHOSTTY_CELL_WIDE, + ghosttyColorsEqual, + type GhosttyCell, + type GhosttyColor, + type GhosttySnapshot, +} from "./core"; + +export interface GhosttyCellMetrics { + readonly width: number; + readonly height: number; + readonly baseline: number; +} + +const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)"; + +function cssColor(color: GhosttyColor): string { + return `rgb(${color.r}, ${color.g}, ${color.b})`; +} + +function sameTextStyle(left: GhosttyCell, right: GhosttyCell): boolean { + // Selection deliberately does not participate: it only tints the background + // overlay, and splitting a text run at a selection boundary visibly shifts + // glyph spacing whenever the face's true advance differs from the cell width. + return ( + ghosttyColorsEqual(left.foreground, right.foreground) && + left.bold === right.bold && + left.italic === right.italic && + left.invisible === right.invisible + ); +} + +export function ghosttyTextRunEnd( + cells: readonly GhosttyCell[], + start: number, + sameStyle: (cell: GhosttyCell) => boolean, +): number { + let end = start + 1; + while (end < cells.length) { + const next = cells[end]; + if (!next) break; + if (next.wide === GHOSTTY_CELL_WIDE.spacerTail) { + end += 1; + continue; + } + if (next.text.length === 0 || !sameStyle(next)) break; + end += 1; + } + return end; +} + +function fontForCell(cell: GhosttyCell, fontSize: number, fontFamily: string): string { + const style = cell.italic ? "italic" : "normal"; + const weight = cell.bold ? "700" : "400"; + return `${style} ${weight} ${fontSize}px ${fontFamily}`; +} + +export function measureGhosttyCell( + context: CanvasRenderingContext2D, + fontSize: number, + fontFamily: string, +): GhosttyCellMetrics { + context.font = `normal 400 ${fontSize}px ${fontFamily}`; + const widthMeasurement = context.measureText("M"); + const verticalMeasurement = context.measureText("Mg"); + const ascent = verticalMeasurement.actualBoundingBoxAscent || fontSize; + const descent = verticalMeasurement.actualBoundingBoxDescent; + const glyphHeight = ascent + descent; + const height = Math.max(1, Math.round(fontSize * 1.35), Math.ceil(glyphHeight)); + return { + width: Math.max(1, widthMeasurement.width), + height, + baseline: Math.round((height - glyphHeight) / 2 + ascent), + }; +} + +export function terminalGridSize( + width: number, + height: number, + metrics: GhosttyCellMetrics, + padding: number, +): { cols: number; rows: number } { + return { + cols: Math.max(1, Math.floor((width - padding * 2) / metrics.width)), + rows: Math.max(1, Math.floor((height - padding * 2) / metrics.height)), + }; +} + +export function renderGhosttySnapshot(options: { + readonly context: CanvasRenderingContext2D; + readonly snapshot: GhosttySnapshot; + readonly metrics: GhosttyCellMetrics; + readonly fontSize: number; + readonly fontFamily: string; + readonly padding: number; + readonly forceFull: boolean; + readonly cursorOn: boolean; + readonly previousCursorY?: number | null; + readonly focused?: boolean; + readonly selectionBackground?: string; + /** Vertical origin of row 0; defaults to the horizontal padding. */ + readonly originY?: number; +}): void { + const { + context, + snapshot, + metrics, + fontSize, + fontFamily, + padding, + forceFull, + cursorOn, + previousCursorY, + } = options; + const focused = options.focused ?? true; + const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND; + const originY = options.originY ?? padding; + const rowsToDraw = forceFull + ? Array.from({ length: snapshot.rows }, (_, index) => index) + : [...snapshot.dirtyRows]; + if ( + previousCursorY !== null && + previousCursorY !== undefined && + previousCursorY >= 0 && + !rowsToDraw.includes(previousCursorY) + ) { + rowsToDraw.push(previousCursorY); + } + if (snapshot.cursorVisible && snapshot.cursorY >= 0 && !rowsToDraw.includes(snapshot.cursorY)) { + rowsToDraw.push(snapshot.cursorY); + } + + if (forceFull) { + context.save(); + context.resetTransform(); + context.fillStyle = cssColor(snapshot.background); + context.fillRect(0, 0, context.canvas.width, context.canvas.height); + context.restore(); + } + + context.textBaseline = "alphabetic"; + for (const rowIndex of rowsToDraw) { + const row = snapshot.rowData[rowIndex]; + if (!row) continue; + const top = originY + rowIndex * metrics.height; + + context.fillStyle = cssColor(snapshot.background); + context.fillRect(padding, top, snapshot.cols * metrics.width, metrics.height); + + let backgroundStart = 0; + while (backgroundStart < row.cells.length) { + const first = row.cells[backgroundStart]; + if (!first) break; + let backgroundEnd = backgroundStart + 1; + while (backgroundEnd < row.cells.length) { + const next = row.cells[backgroundEnd]; + if ( + !next || + next.selected !== first.selected || + !ghosttyColorsEqual(next.background, first.background) + ) { + break; + } + backgroundEnd += 1; + } + if (first.selected || !ghosttyColorsEqual(first.background, snapshot.background)) { + const left = padding + backgroundStart * metrics.width; + const width = (backgroundEnd - backgroundStart) * metrics.width; + if (!ghosttyColorsEqual(first.background, snapshot.background)) { + context.fillStyle = cssColor(first.background); + context.fillRect(left, top, width, metrics.height); + } + if (first.selected) { + context.fillStyle = selectionBackground; + context.fillRect(left, top, width, metrics.height); + } + } + backgroundStart = backgroundEnd; + } + + let runStart = 0; + while (runStart < row.cells.length) { + const first = row.cells[runStart]; + if (!first) break; + if (first.text.length === 0) { + runStart += 1; + continue; + } + const runEnd = ghosttyTextRunEnd(row.cells, runStart, (cell) => sameTextStyle(cell, first)); + const text = row.cells + .slice(runStart, runEnd) + .map((cell) => cell.text) + .join(""); + if (!first.invisible && text.trim().length > 0) { + context.save(); + context.beginPath(); + context.rect( + padding + runStart * metrics.width, + top, + (runEnd - runStart) * metrics.width, + metrics.height, + ); + context.clip(); + context.font = fontForCell(first, fontSize, fontFamily); + context.fillStyle = cssColor(first.foreground); + context.fillText( + text, + padding + runStart * metrics.width, + top + metrics.baseline, + (runEnd - runStart) * metrics.width, + ); + context.restore(); + } + runStart = runEnd; + } + + for (let column = 0; column < row.cells.length; column += 1) { + const cell = row.cells[column]; + if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline)) continue; + context.fillStyle = cssColor(cell.foreground); + const left = padding + column * metrics.width; + if (cell.underline) context.fillRect(left, top + metrics.height - 2, metrics.width, 1); + if (cell.strikethrough) { + context.fillRect(left, top + Math.floor(metrics.height * 0.55), metrics.width, 1); + } + if (cell.overline) context.fillRect(left, top + 1, metrics.width, 1); + } + } + + if (cursorOn && snapshot.cursorVisible && snapshot.cursorX >= 0 && snapshot.cursorY >= 0) { + const left = padding + snapshot.cursorX * metrics.width; + const top = originY + snapshot.cursorY * metrics.height; + context.fillStyle = cssColor(snapshot.cursor); + if (!focused) { + // An unfocused terminal draws a hollow cursor so the active pane is obvious. + context.strokeStyle = cssColor(snapshot.cursor); + context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); + } else if (snapshot.cursorStyle === 0) { + context.fillRect(left, top, 2, metrics.height); + } else if (snapshot.cursorStyle === 2) { + context.fillRect(left, top + metrics.height - 2, metrics.width, 2); + } else if (snapshot.cursorStyle === 3) { + context.strokeStyle = cssColor(snapshot.cursor); + context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); + } else { + context.fillRect(left, top, metrics.width, metrics.height); + const cell = snapshot.rowData[snapshot.cursorY]?.cells[snapshot.cursorX]; + if (cell?.text) { + context.font = fontForCell(cell, fontSize, fontFamily); + context.fillStyle = cssColor(snapshot.background); + context.fillText(cell.text, left, top + metrics.baseline, metrics.width); + } + } + } +} diff --git a/apps/web/src/terminal/ghostty/runtime.ts b/apps/web/src/terminal/ghostty/runtime.ts new file mode 100644 index 000000000000..aca82e7cc3c0 --- /dev/null +++ b/apps/web/src/terminal/ghostty/runtime.ts @@ -0,0 +1,221 @@ +import ghosttyWasmUrl from "./vendor/ghostty-vt.wasm?url"; +import ghosttyWritePtyWasmUrl from "./vendor/ghostty-write-pty.wasm?url&no-inline"; + +type WasmFunction = (...args: Array) => number; + +interface TypeField { + readonly offset: number; + readonly size: number; + readonly type: string; +} + +interface TypeLayout { + readonly size: number; + readonly align: number; + readonly fields: Readonly>; +} + +type TypeLayouts = Readonly>; + +const textDecoder = new TextDecoder(); + +export class GhosttyRuntime { + readonly memory: WebAssembly.Memory; + readonly layouts: TypeLayouts; + private readonly exports: WebAssembly.Exports; + private readonly ptyWriters = new Map void>(); + private nextPtyWriterId = 1; + private writePtyFunctionIndex = 0; + + private constructor(instance: WebAssembly.Instance) { + this.exports = instance.exports; + const memory = instance.exports.memory; + if (!(memory instanceof WebAssembly.Memory)) { + throw new Error("libghostty-vt did not export WebAssembly memory"); + } + this.memory = memory; + const jsonPointer = this.call("ghostty_type_json"); + const bytes = new Uint8Array(memory.buffer); + let end = jsonPointer; + while (end < bytes.length && bytes[end] !== 0) end += 1; + this.layouts = JSON.parse(textDecoder.decode(bytes.subarray(jsonPointer, end))) as TypeLayouts; + } + + static async load(): Promise { + const response = await fetch(ghosttyWasmUrl); + if (!response.ok) { + throw new Error(`Unable to load libghostty-vt (${response.status})`); + } + let instance: WebAssembly.Instance | undefined; + const imports = { + env: { + log: (pointer: number, length: number) => { + if (!instance) return; + const memory = instance.exports.memory; + if (!(memory instanceof WebAssembly.Memory)) return; + const message = textDecoder.decode(new Uint8Array(memory.buffer, pointer, length)); + console.debug("[libghostty-vt]", message); + }, + }, + }; + const result = await WebAssembly.instantiate(await response.arrayBuffer(), imports); + instance = result.instance; + const runtime = new GhosttyRuntime(result.instance); + await runtime.installWritePtyTrampoline(); + return runtime; + } + + call(name: string, ...args: Array): number { + const fn = this.exports[name]; + if (typeof fn !== "function") { + throw new Error(`libghostty-vt export is unavailable: ${name}`); + } + return (fn as WasmFunction)(...args); + } + + layout(name: string): TypeLayout { + const layout = this.layouts[name]; + if (!layout) throw new Error(`libghostty-vt type layout is unavailable: ${name}`); + return layout; + } + + alloc(size: number): number { + const pointer = this.call("ghostty_wasm_alloc_u8_array", size); + if (pointer === 0) throw new Error(`libghostty-vt failed to allocate ${size} bytes`); + new Uint8Array(this.memory.buffer, pointer, size).fill(0); + return pointer; + } + + free(pointer: number, size: number): void { + if (pointer !== 0) this.call("ghostty_wasm_free_u8_array", pointer, size); + } + + allocOpaque(): number { + const pointer = this.call("ghostty_wasm_alloc_opaque"); + if (pointer === 0) throw new Error("libghostty-vt failed to allocate an opaque pointer"); + // The slot is uninitialized until a *_new call writes it; zero it so dispose + // paths that run after a partial initialization never free a garbage pointer. + new DataView(this.memory.buffer).setUint32(pointer, 0, true); + return pointer; + } + + freeOpaque(pointer: number): void { + if (pointer !== 0) this.call("ghostty_wasm_free_opaque", pointer); + } + + readPointer(slot: number): number { + return new DataView(this.memory.buffer).getUint32(slot, true); + } + + attachPtyWriter(terminal: number, writer: (data: string) => void): number { + if (this.writePtyFunctionIndex === 0) { + throw new Error("libghostty-vt PTY callback trampoline is unavailable"); + } + const id = this.nextPtyWriterId++; + this.ptyWriters.set(id, writer); + this.call("ghostty_terminal_set", terminal, 0, id); + this.call("ghostty_terminal_set", terminal, 1, this.writePtyFunctionIndex); + return id; + } + + detachPtyWriter(terminal: number, id: number): void { + this.call("ghostty_terminal_set", terminal, 1, 0); + this.call("ghostty_terminal_set", terminal, 0, 0); + this.ptyWriters.delete(id); + } + + view(pointer: number, size?: number): DataView { + return new DataView(this.memory.buffer, pointer, size); + } + + bytes(pointer: number, size: number): Uint8Array { + return new Uint8Array(this.memory.buffer, pointer, size); + } + + setField(pointer: number, structName: string, fieldName: string, value: number): void { + const field = this.layout(structName).fields[fieldName]; + if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); + const view = this.view(pointer + field.offset, field.size); + switch (field.type) { + case "bool": + case "u8": + view.setUint8(0, value); + return; + case "u16": + view.setUint16(0, value, true); + return; + case "i32": + view.setInt32(0, value, true); + return; + case "u32": + case "enum": + view.setUint32(0, value, true); + return; + case "u64": + view.setBigUint64(0, BigInt(value), true); + return; + default: + throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); + } + } + + readField(pointer: number, structName: string, fieldName: string): number { + const field = this.layout(structName).fields[fieldName]; + if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`); + const view = this.view(pointer + field.offset, field.size); + switch (field.type) { + case "bool": + case "u8": + return view.getUint8(0); + case "u16": + return view.getUint16(0, true); + case "i32": + return view.getInt32(0, true); + case "u32": + case "enum": + return view.getUint32(0, true); + case "u64": + return Number(view.getBigUint64(0, true)); + default: + throw new Error(`Unsupported libghostty-vt field type: ${field.type}`); + } + } + + private async installWritePtyTrampoline(): Promise { + const response = await fetch(ghosttyWritePtyWasmUrl); + if (!response.ok) { + throw new Error(`Unable to load the libghostty-vt PTY trampoline (${response.status})`); + } + const result = await WebAssembly.instantiate(await response.arrayBuffer(), { + env: { + t3_write_pty: (_terminal: number, userdata: number, pointer: number, length: number) => { + const writer = this.ptyWriters.get(userdata); + if (!writer || length === 0) return; + writer(textDecoder.decode(new Uint8Array(this.memory.buffer, pointer, length))); + }, + }, + }); + const trampoline = result.instance.exports.ghostty_write_pty; + const table = this.exports.__indirect_function_table; + if (typeof trampoline !== "function" || !(table instanceof WebAssembly.Table)) { + throw new Error("libghostty-vt did not expose its callback table"); + } + const index = table.length; + // grow-then-set instead of grow(1, fn): WebKit stores a grow init value + // with broken type information and every later call_indirect through the + // entry traps with a signature mismatch. table.set canonicalizes correctly. + table.grow(1); + table.set(index, trampoline); + this.writePtyFunctionIndex = index; + } +} + +let runtimePromise: Promise | null = null; + +export function loadGhosttyRuntime(): Promise { + runtimePromise ??= GhosttyRuntime.load().catch((error) => { + runtimePromise = null; + throw error; + }); + return runtimePromise; +} diff --git a/apps/web/src/terminal/ghostty/runtimeAbi.test.ts b/apps/web/src/terminal/ghostty/runtimeAbi.test.ts new file mode 100644 index 000000000000..b77cfd32bdd5 --- /dev/null +++ b/apps/web/src/terminal/ghostty/runtimeAbi.test.ts @@ -0,0 +1,561 @@ +import { describe, expect, it } from "vite-plus/test"; + +import wasmDataUrl from "./vendor/ghostty-vt.wasm?inline"; +import writePtyWasmDataUrl from "./vendor/ghostty-write-pty.wasm?inline"; +import pinnedVersion from "../../../../../native/libghostty-vt/VERSION?raw"; +import { ghosttyKeyForCode } from "./keyCodes"; + +type WasmFunction = (...args: number[]) => number; + +function decodeWasmDataUrl(dataUrl: string): Uint8Array { + const encoded = dataUrl.split(",", 2)[1]; + if (!encoded) throw new Error("The vendored Ghostty WASM data URL is invalid"); + return Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0)); +} + +describe("vendored libghostty-vt WebAssembly", () => { + it("stays pinned to the canonical revision and size budget", async () => { + const wasm = decodeWasmDataUrl(wasmDataUrl); + expect(wasm.byteLength).toBeLessThan(750_000); + + // The artifact carries its own provenance: the build embeds the pinned + // revision as semver build metadata, so the repository's canonical VERSION + // file is the single source of truth and drift is caught here without a copy. + const result = await WebAssembly.instantiate(wasm.buffer as ArrayBuffer, { + env: { log: () => {} }, + }); + const instance = result instanceof WebAssembly.Instance ? result : result.instance; + const memory = instance.exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => + (instance.exports[name] as WasmFunction)(...args); + const out = call("ghostty_wasm_alloc_u8_array", 8); + expect(call("ghostty_build_info", 10, out)).toBe(0); + const view = new DataView(memory.buffer, out, 8); + const embeddedRevision = new TextDecoder().decode( + new Uint8Array(memory.buffer, view.getUint32(0, true), view.getUint32(4, true)), + ); + call("ghostty_wasm_free_u8_array", out, 8); + expect(embeddedRevision).toBe(pinnedVersion.trim()); + }); + + it("creates, writes multi-codepoint graphemes, and frees repeated terminals", async () => { + const bytes = decodeWasmDataUrl(wasmDataUrl); + let memory: WebAssembly.Memory | null = null; + const instantiated = await WebAssembly.instantiate(bytes.buffer as ArrayBuffer, { + env: { + log: () => {}, + }, + }); + const instance = + instantiated instanceof WebAssembly.Instance ? instantiated : instantiated.instance; + const exports = instance.exports; + memory = exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => (exports[name] as WasmFunction)(...args); + const jsonPointer = call("ghostty_type_json"); + const jsonBytes = new Uint8Array(memory.buffer, jsonPointer); + const jsonEnd = jsonBytes.indexOf(0); + const layouts = JSON.parse(new TextDecoder().decode(jsonBytes.subarray(0, jsonEnd))) as Record< + string, + { size: number } + >; + const optionsSize = layouts.GhosttyTerminalOptions?.size; + expect(optionsSize).toBe(8); + if (optionsSize === undefined) throw new Error("GhosttyTerminalOptions layout is missing"); + + for (let iteration = 0; iteration < 25; iteration += 1) { + const options = call("ghostty_wasm_alloc_u8_array", optionsSize); + const optionsView = new DataView(memory.buffer, options, optionsSize); + optionsView.setUint16(0, 80, true); + optionsView.setUint16(2, 24, true); + optionsView.setUint32(4, 5_000, true); + const terminalSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0); + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); + + const input = new TextEncoder().encode("e\u0301 👨‍👩‍👧‍👦 العربية\r\n"); + const inputPointer = call("ghostty_wasm_alloc_u8_array", input.length); + new Uint8Array(memory.buffer, inputPointer, input.length).set(input); + call("ghostty_terminal_vt_write", terminal, inputPointer, input.length); + call("ghostty_wasm_free_u8_array", inputPointer, input.length); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_opaque", terminalSlot); + call("ghostty_wasm_free_u8_array", options, optionsSize); + } + }); + + it("reports and scrolls the viewport with Ghostty's scrollbar state", async () => { + const result = await WebAssembly.instantiate( + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, + { env: { log: () => {} } }, + ); + const instance = result instanceof WebAssembly.Instance ? result : result.instance; + const memory = instance.exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => + (instance.exports[name] as WasmFunction)(...args); + const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size); + const options = alloc(8); + const optionsView = new DataView(memory.buffer, options, 8); + optionsView.setUint16(0, 80, true); + optionsView.setUint16(2, 10, true); + optionsView.setUint32(4, 1_000, true); + const terminalSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0); + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); + const input = new TextEncoder().encode( + Array.from({ length: 50 }, (_, index) => `${index + 1}\r\n`).join(""), + ); + const inputPointer = alloc(input.length); + new Uint8Array(memory.buffer, inputPointer, input.length).set(input); + call("ghostty_terminal_vt_write", terminal, inputPointer, input.length); + + const scrollbar = alloc(24); + const scrollbarView = new DataView(memory.buffer, scrollbar, 24); + expect(call("ghostty_terminal_get", terminal, 9, scrollbar)).toBe(0); + expect([0, 8, 16].map((offset) => Number(scrollbarView.getBigUint64(offset, true)))).toEqual([ + 51, 41, 10, + ]); + + const scroll = alloc(24); + const scrollView = new DataView(memory.buffer, scroll, 24); + scrollView.setUint32(0, 2, true); + scrollView.setInt32(8, -5, true); + call("ghostty_terminal_scroll_viewport", terminal, scroll); + expect(call("ghostty_terminal_get", terminal, 9, scrollbar)).toBe(0); + expect(Number(scrollbarView.getBigUint64(8, true))).toBe(36); + + call("ghostty_wasm_free_u8_array", scroll, 24); + call("ghostty_wasm_free_u8_array", scrollbar, 24); + call("ghostty_wasm_free_u8_array", inputPointer, input.length); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_opaque", terminalSlot); + call("ghostty_wasm_free_u8_array", options, 8); + }); + + it("routes terminal-generated replies through the shared callback table", async () => { + const mainResult = await WebAssembly.instantiate( + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, + { env: { log: () => {} } }, + ); + const main = mainResult instanceof WebAssembly.Instance ? mainResult : mainResult.instance; + const memory = main.exports.memory as WebAssembly.Memory; + let reply = ""; + const trampolineResult = await WebAssembly.instantiate( + decodeWasmDataUrl(writePtyWasmDataUrl).buffer as ArrayBuffer, + { + env: { + t3_write_pty: (_terminal: number, _userdata: number, pointer: number, length: number) => { + reply += new TextDecoder().decode(new Uint8Array(memory.buffer, pointer, length)); + }, + }, + }, + ); + const trampoline = + trampolineResult instanceof WebAssembly.Instance + ? trampolineResult + : trampolineResult.instance; + const table = main.exports.__indirect_function_table as WebAssembly.Table; + const callbackIndex = table.length; + table.grow(1, trampoline.exports.ghostty_write_pty as CallableFunction); + const call = (name: string, ...args: number[]) => (main.exports[name] as WasmFunction)(...args); + const options = call("ghostty_wasm_alloc_u8_array", 8); + const optionsView = new DataView(memory.buffer, options, 8); + optionsView.setUint16(0, 80, true); + optionsView.setUint16(2, 24, true); + const terminalSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0); + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); + call("ghostty_terminal_set", terminal, 0, 1); + call("ghostty_terminal_set", terminal, 1, callbackIndex); + const query = new TextEncoder().encode("\u001b[5n"); + const queryPointer = call("ghostty_wasm_alloc_u8_array", query.length); + new Uint8Array(memory.buffer, queryPointer, query.length).set(query); + call("ghostty_terminal_vt_write", terminal, queryPointer, query.length); + + expect(reply).toBe("\u001b[0n"); + reply = ""; + call("ghostty_terminal_set", terminal, 1, 0); + call("ghostty_terminal_set", terminal, 0, 0); + call("ghostty_terminal_vt_write", terminal, queryPointer, query.length); + expect(reply).toBe(""); + call("ghostty_terminal_set", terminal, 0, 1); + call("ghostty_terminal_set", terminal, 1, callbackIndex); + call("ghostty_terminal_vt_write", terminal, queryPointer, query.length); + expect(reply).toBe("\u001b[0n"); + call("ghostty_wasm_free_u8_array", queryPointer, query.length); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_opaque", terminalSlot); + call("ghostty_wasm_free_u8_array", options, 8); + }); + + it("formats the active selection with Ghostty's copy semantics", async () => { + const result = await WebAssembly.instantiate( + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, + { env: { log: () => {} } }, + ); + const instance = result instanceof WebAssembly.Instance ? result : result.instance; + const memory = instance.exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => + (instance.exports[name] as WasmFunction)(...args); + const options = call("ghostty_wasm_alloc_u8_array", 8); + const optionsView = new DataView(memory.buffer, options, 8); + optionsView.setUint16(0, 80, true); + optionsView.setUint16(2, 24, true); + const terminalSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0); + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); + const input = new TextEncoder().encode("a\r\n\r\nb"); + const inputPointer = call("ghostty_wasm_alloc_u8_array", input.length); + new Uint8Array(memory.buffer, inputPointer, input.length).set(input); + call("ghostty_terminal_vt_write", terminal, inputPointer, input.length); + + const selection = call("ghostty_wasm_alloc_u8_array", 32); + new DataView(memory.buffer, selection, 32).setUint32(0, 32, true); + expect(call("ghostty_terminal_select_all", terminal, selection)).toBe(0); + expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0); + const formatOptions = call("ghostty_wasm_alloc_u8_array", 16); + const formatView = new DataView(memory.buffer, formatOptions, 16); + formatView.setUint32(0, 16, true); + formatView.setUint8(8, 1); + formatView.setUint8(9, 1); + const written = call("ghostty_wasm_alloc_usize"); + expect( + call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written), + ).toBe(-3); + const outputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const output = call("ghostty_wasm_alloc_u8_array", outputSize); + expect( + call( + "ghostty_terminal_selection_format_buf", + terminal, + formatOptions, + output, + outputSize, + written, + ), + ).toBe(0); + expect(new TextDecoder().decode(new Uint8Array(memory.buffer, output, outputSize))).toBe( + "a\n\nb", + ); + + call("ghostty_wasm_free_u8_array", output, outputSize); + call("ghostty_wasm_free_usize", written); + call("ghostty_wasm_free_u8_array", formatOptions, 16); + call("ghostty_wasm_free_u8_array", selection, 32); + call("ghostty_wasm_free_u8_array", inputPointer, input.length); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_opaque", terminalSlot); + call("ghostty_wasm_free_u8_array", options, 8); + }); + + it("uses Ghostty for mouse encoding, word selection, and OSC 8 hit testing", async () => { + const result = await WebAssembly.instantiate( + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, + { env: { log: () => {} } }, + ); + const instance = result instanceof WebAssembly.Instance ? result : result.instance; + const memory = instance.exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => + (instance.exports[name] as WasmFunction)(...args); + const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size); + const free = (pointer: number, size: number) => + call("ghostty_wasm_free_u8_array", pointer, size); + + const terminalOptions = alloc(8); + const terminalOptionsView = new DataView(memory.buffer, terminalOptions, 8); + terminalOptionsView.setUint16(0, 80, true); + terminalOptionsView.setUint16(2, 24, true); + const terminalSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_terminal_new", 0, terminalSlot, terminalOptions)).toBe(0); + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); + const input = new TextEncoder().encode( + "\u001b[?1000h\u001b[?1006h\u001b]8;;https://t3.codes/docs\u001b\\linked\u001b]8;;\u001b\\ plain", + ); + const inputPointer = alloc(input.length); + new Uint8Array(memory.buffer, inputPointer, input.length).set(input); + call("ghostty_terminal_vt_write", terminal, inputPointer, input.length); + + const modeFlag = alloc(1); + new Uint8Array(memory.buffer, modeFlag, 1)[0] = 0; + expect(call("ghostty_terminal_get", terminal, 11, modeFlag)).toBe(0); + expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(1); + new Uint8Array(memory.buffer, modeFlag, 1)[0] = 1; + expect(call("ghostty_terminal_mode_get", terminal, 1003, modeFlag)).toBe(0); + expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(0); + const anyEventInput = new TextEncoder().encode("\u001b[?1003h"); + const anyEventPointer = alloc(anyEventInput.length); + new Uint8Array(memory.buffer, anyEventPointer, anyEventInput.length).set(anyEventInput); + call("ghostty_terminal_vt_write", terminal, anyEventPointer, anyEventInput.length); + expect(call("ghostty_terminal_mode_get", terminal, 1003, modeFlag)).toBe(0); + expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(1); + const anyEventReset = new TextEncoder().encode("\u001b[?1003l\u001b[?1000h"); + const anyEventResetPointer = alloc(anyEventReset.length); + new Uint8Array(memory.buffer, anyEventResetPointer, anyEventReset.length).set(anyEventReset); + call("ghostty_terminal_vt_write", terminal, anyEventResetPointer, anyEventReset.length); + expect(call("ghostty_terminal_mode_get", terminal, 1003, modeFlag)).toBe(0); + expect(new Uint8Array(memory.buffer, modeFlag, 1)[0]).toBe(0); + free(anyEventResetPointer, anyEventReset.length); + free(anyEventPointer, anyEventInput.length); + free(modeFlag, 1); + + const point = alloc(24); + const pointView = new DataView(memory.buffer, point, 24); + pointView.setUint32(0, 1, true); + pointView.setUint16(8, 1, true); + pointView.setUint32(12, 0, true); + const gridRef = alloc(12); + new DataView(memory.buffer, gridRef, 12).setUint32(0, 12, true); + expect(call("ghostty_terminal_grid_ref", terminal, point, gridRef)).toBe(0); + + const written = call("ghostty_wasm_alloc_usize"); + expect(call("ghostty_grid_ref_hyperlink_uri", gridRef, 0, 0, written)).toBe(-3); + const hyperlinkSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const hyperlink = alloc(hyperlinkSize); + expect(call("ghostty_grid_ref_hyperlink_uri", gridRef, hyperlink, hyperlinkSize, written)).toBe( + 0, + ); + expect(new TextDecoder().decode(new Uint8Array(memory.buffer, hyperlink, hyperlinkSize))).toBe( + "https://t3.codes/docs", + ); + + const wordOptions = alloc(24); + const wordOptionsView = new DataView(memory.buffer, wordOptions, 24); + wordOptionsView.setUint32(0, 24, true); + new Uint8Array(memory.buffer, wordOptions + 4, 12).set( + new Uint8Array(memory.buffer, gridRef, 12), + ); + const selection = alloc(32); + new DataView(memory.buffer, selection, 32).setUint32(0, 32, true); + expect(call("ghostty_terminal_select_word", terminal, wordOptions, selection)).toBe(0); + expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0); + const formatOptions = alloc(16); + const formatView = new DataView(memory.buffer, formatOptions, 16); + formatView.setUint32(0, 16, true); + formatView.setUint8(8, 1); + formatView.setUint8(9, 1); + expect( + call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written), + ).toBe(-3); + const selectionSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const selectionText = alloc(selectionSize); + expect( + call( + "ghostty_terminal_selection_format_buf", + terminal, + formatOptions, + selectionText, + selectionSize, + written, + ), + ).toBe(0); + expect( + new TextDecoder().decode(new Uint8Array(memory.buffer, selectionText, selectionSize)), + ).toBe("linked"); + + const lineOptions = alloc(28); + const lineOptionsView = new DataView(memory.buffer, lineOptions, 28); + lineOptionsView.setUint32(0, 28, true); + new Uint8Array(memory.buffer, lineOptions + 4, 12).set( + new Uint8Array(memory.buffer, gridRef, 12), + ); + expect(call("ghostty_terminal_select_line", terminal, lineOptions, selection)).toBe(0); + expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0); + expect( + call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written), + ).toBe(-3); + const lineSelectionSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const lineSelectionText = alloc(lineSelectionSize); + expect( + call( + "ghostty_terminal_selection_format_buf", + terminal, + formatOptions, + lineSelectionText, + lineSelectionSize, + written, + ), + ).toBe(0); + expect( + new TextDecoder().decode(new Uint8Array(memory.buffer, lineSelectionText, lineSelectionSize)), + ).toBe("linked plain"); + + const mouseEncoderSlot = call("ghostty_wasm_alloc_opaque"); + const mouseEventSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_mouse_encoder_new", 0, mouseEncoderSlot)).toBe(0); + expect(call("ghostty_mouse_event_new", 0, mouseEventSlot)).toBe(0); + const view = new DataView(memory.buffer); + const mouseEncoder = view.getUint32(mouseEncoderSlot, true); + const mouseEvent = view.getUint32(mouseEventSlot, true); + call("ghostty_mouse_encoder_setopt_from_terminal", mouseEncoder, terminal); + const mouseSize = alloc(36); + const mouseSizeView = new DataView(memory.buffer, mouseSize, 36); + for (const [offset, value] of [ + [0, 36], + [4, 800], + [8, 480], + [12, 10], + [16, 20], + ] as const) { + mouseSizeView.setUint32(offset, value, true); + } + call("ghostty_mouse_encoder_setopt", mouseEncoder, 2, mouseSize); + call("ghostty_mouse_event_set_action", mouseEvent, 0); + call("ghostty_mouse_event_set_button", mouseEvent, 1); + const mousePosition = new DataView(new ArrayBuffer(8)); + mousePosition.setFloat32(0, 15, true); + mousePosition.setFloat32(4, 25, true); + const mousePositionPointer = alloc(8); + new Uint8Array(memory.buffer, mousePositionPointer, 8).set( + new Uint8Array(mousePosition.buffer), + ); + call("ghostty_mouse_event_set_position", mouseEvent, mousePositionPointer); + const mouseOutput = alloc(128); + expect( + call("ghostty_mouse_encoder_encode", mouseEncoder, mouseEvent, mouseOutput, 128, written), + ).toBe(0); + const mouseOutputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + expect( + new TextDecoder().decode(new Uint8Array(memory.buffer, mouseOutput, mouseOutputSize)), + ).toBe("\u001b[<0;2;2M"); + + call("ghostty_mouse_event_free", mouseEvent); + call("ghostty_mouse_encoder_free", mouseEncoder); + call("ghostty_wasm_free_opaque", mouseEventSlot); + call("ghostty_wasm_free_opaque", mouseEncoderSlot); + free(mousePositionPointer, 8); + free(mouseOutput, 128); + free(mouseSize, 36); + free(lineSelectionText, lineSelectionSize); + free(lineOptions, 28); + free(selectionText, selectionSize); + free(formatOptions, 16); + free(selection, 32); + free(wordOptions, 24); + free(hyperlink, hyperlinkSize); + call("ghostty_wasm_free_usize", written); + free(gridRef, 12); + free(point, 24); + free(inputPointer, input.length); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_opaque", terminalSlot); + free(terminalOptions, 8); + }); + + it("encodes modified printable keys in Kitty keyboard mode", async () => { + const result = await WebAssembly.instantiate( + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, + { env: { log: () => {} } }, + ); + const instance = result instanceof WebAssembly.Instance ? result : result.instance; + const memory = instance.exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => + (instance.exports[name] as WasmFunction)(...args); + const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size); + const free = (pointer: number, size: number) => + call("ghostty_wasm_free_u8_array", pointer, size); + + const terminalOptions = alloc(8); + const terminalOptionsView = new DataView(memory.buffer, terminalOptions, 8); + terminalOptionsView.setUint16(0, 80, true); + terminalOptionsView.setUint16(2, 24, true); + const terminalSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_terminal_new", 0, terminalSlot, terminalOptions)).toBe(0); + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); + const kittyMode = new TextEncoder().encode("\u001b[>1u"); + const kittyModePointer = alloc(kittyMode.length); + new Uint8Array(memory.buffer, kittyModePointer, kittyMode.length).set(kittyMode); + call("ghostty_terminal_vt_write", terminal, kittyModePointer, kittyMode.length); + + const encoderSlot = call("ghostty_wasm_alloc_opaque"); + const eventSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_key_encoder_new", 0, encoderSlot)).toBe(0); + expect(call("ghostty_key_event_new", 0, eventSlot)).toBe(0); + const view = new DataView(memory.buffer); + const keyEncoder = view.getUint32(encoderSlot, true); + const keyEvent = view.getUint32(eventSlot, true); + call("ghostty_key_encoder_setopt_from_terminal", keyEncoder, terminal); + call("ghostty_key_event_set_action", keyEvent, 1); + call("ghostty_key_event_set_key", keyEvent, ghosttyKeyForCode("KeyC")); + call("ghostty_key_event_set_mods", keyEvent, 1 << 1); + call("ghostty_key_event_set_consumed_mods", keyEvent, 0); + call("ghostty_key_event_set_composing", keyEvent, 0); + call("ghostty_key_event_set_unshifted_codepoint", keyEvent, "c".codePointAt(0)!); + const text = new TextEncoder().encode("c"); + const textPointer = alloc(text.length); + new Uint8Array(memory.buffer, textPointer, text.length).set(text); + call("ghostty_key_event_set_utf8", keyEvent, textPointer, text.length); + + const written = call("ghostty_wasm_alloc_usize"); + expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(-3); + const outputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const output = alloc(outputSize); + expect( + call("ghostty_key_encoder_encode", keyEncoder, keyEvent, output, outputSize, written), + ).toBe(0); + const outputLength = new DataView(memory.buffer, written, 4).getUint32(0, true); + expect(new TextDecoder().decode(new Uint8Array(memory.buffer, output, outputLength))).toBe( + "\u001b[99;5u", + ); + + const remappedText = new TextEncoder().encode("j"); + const remappedTextPointer = alloc(remappedText.length); + new Uint8Array(memory.buffer, remappedTextPointer, remappedText.length).set(remappedText); + call("ghostty_key_event_set_unshifted_codepoint", keyEvent, "j".codePointAt(0)!); + call("ghostty_key_event_set_utf8", keyEvent, remappedTextPointer, remappedText.length); + expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(-3); + const remappedOutputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const remappedOutput = alloc(remappedOutputSize); + expect( + call( + "ghostty_key_encoder_encode", + keyEncoder, + keyEvent, + remappedOutput, + remappedOutputSize, + written, + ), + ).toBe(0); + const remappedOutputLength = new DataView(memory.buffer, written, 4).getUint32(0, true); + expect( + new TextDecoder().decode(new Uint8Array(memory.buffer, remappedOutput, remappedOutputLength)), + ).toBe("\u001b[106;5u"); + + // Without the Kitty report-event-types flag a release encodes nothing, so + // the surface's keyup handler stays silent for legacy sessions. + call("ghostty_key_event_set_action", keyEvent, 0); + expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(0); + expect(new DataView(memory.buffer, written, 4).getUint32(0, true)).toBe(0); + + // With report-event-types enabled the same release encodes an event-typed code. + const reportEvents = new TextEncoder().encode("\u001b[>3u"); + const reportEventsPointer = alloc(reportEvents.length); + new Uint8Array(memory.buffer, reportEventsPointer, reportEvents.length).set(reportEvents); + call("ghostty_terminal_vt_write", terminal, reportEventsPointer, reportEvents.length); + call("ghostty_key_encoder_setopt_from_terminal", keyEncoder, terminal); + expect(call("ghostty_key_encoder_encode", keyEncoder, keyEvent, 0, 0, written)).toBe(-3); + const releaseSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const releaseOutput = alloc(releaseSize); + expect( + call("ghostty_key_encoder_encode", keyEncoder, keyEvent, releaseOutput, releaseSize, written), + ).toBe(0); + const releaseLength = new DataView(memory.buffer, written, 4).getUint32(0, true); + expect( + new TextDecoder().decode(new Uint8Array(memory.buffer, releaseOutput, releaseLength)), + ).toBe("\u001b[106;5:3u"); + free(releaseOutput, releaseSize); + free(reportEventsPointer, reportEvents.length); + + free(remappedOutput, remappedOutputSize); + free(remappedTextPointer, remappedText.length); + free(output, outputSize); + call("ghostty_wasm_free_usize", written); + free(textPointer, text.length); + call("ghostty_key_event_free", keyEvent); + call("ghostty_key_encoder_free", keyEncoder); + call("ghostty_wasm_free_opaque", eventSlot); + call("ghostty_wasm_free_opaque", encoderSlot); + free(kittyModePointer, kittyMode.length); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_opaque", terminalSlot); + free(terminalOptions, 8); + }); +}); diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts new file mode 100644 index 000000000000..1cb9502649bb --- /dev/null +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { GhosttyCell, GhosttyRow } from "./core"; +import { + DEFAULT_TERMINAL_FONT_FAMILY, + DEFAULT_TERMINAL_FONT_SIZE, + advanceTerminalSelectionClickSequence, + ghosttyMouseButton, + isTerminalAltGraphText, + isTerminalCompositionCommitInput, + isTerminalCopyShortcut, + isTerminalLinkPointerGesture, + isTerminalPasteShortcut, + shouldReportTerminalMouse, + terminalScrollbarGeometry, + terminalScrollbarOffsetAtPointer, + terminalLinkAtColumn, + terminalLinkAtPosition, + terminalContentOriginY, + terminalFontFamily, + terminalFontSize, + terminalWheelArrowData, + terminalWheelDeltaRows, +} from "./surface"; + +const cell = (text: string): GhosttyCell => ({ + text, + wide: 0, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + bold: false, + italic: false, + invisible: false, + strikethrough: false, + overline: false, + underline: false, + selected: false, +}); + +describe("isTerminalAltGraphText", () => { + it("defers printable AltGr output to the textarea input event", () => { + expect( + isTerminalAltGraphText({ + key: "@", + getModifierState: (modifier) => modifier === "AltGraph", + }), + ).toBe(true); + expect( + isTerminalAltGraphText({ + key: "ArrowRight", + getModifierState: (modifier) => modifier === "AltGraph", + }), + ).toBe(false); + }); +}); + +describe("terminalLinkAtColumn", () => { + it("maps terminal cells to UTF-16 offsets after a wide emoji", () => { + const cells = [ + cell("🙂"), + cell(""), + ...Array.from("https://t3.codes", (character) => cell(character)), + ]; + const row: GhosttyRow = { + cells, + text: cells + .map((value) => value.text || " ") + .join("") + .trimEnd(), + isWrapContinuation: false, + wrapsToNext: false, + }; + + expect(terminalLinkAtColumn(row, 2)).toBe("https://t3.codes"); + expect(terminalLinkAtColumn(row, cells.length - 1)).toBe("https://t3.codes"); + expect(terminalLinkAtColumn(row, 0)).toBeNull(); + }); + + it("uses shared path matching and reconstructs soft-wrapped links", () => { + const row = (text: string, isWrapContinuation: boolean, wrapsToNext = false): GhosttyRow => ({ + cells: Array.from(text.padEnd(16), (character) => cell(character)), + text: text.trimEnd(), + isWrapContinuation, + wrapsToNext, + }); + const rows = [ + row("https://example.", false), + row("com/reference", true), + row("~/project/file", false), + row("C:\\repo\\file.ts", false), + ]; + + expect(terminalLinkAtPosition(rows, 0, 8)).toBe("https://example.com/reference"); + expect(terminalLinkAtPosition(rows, 1, 4)).toBe("https://example.com/reference"); + expect(terminalLinkAtPosition(rows, 2, 2)).toBe("~/project/file"); + expect(terminalLinkAtPosition(rows, 3, 4)).toBe("C:\\repo\\file.ts"); + }); + + it("refuses links truncated at the viewport edges instead of mis-resolving", () => { + const row = (text: string, isWrapContinuation: boolean, wrapsToNext = false): GhosttyRow => ({ + cells: Array.from(text.padEnd(16), (character) => cell(character)), + text: text.trimEnd(), + isWrapContinuation, + wrapsToNext, + }); + // The head of the wrapped line scrolled above the viewport. + const headCut = [row("ple.com/missing", true), row("head", true)]; + expect(terminalLinkAtPosition(headCut, 0, 4)).toBeNull(); + // The bottom row soft-wraps on below the viewport. + const tailCut = [row("https://t3.codes", false, true)]; + expect(terminalLinkAtPosition(tailCut, 0, 8)).toBeNull(); + // A partial bottom row is provably complete and still resolves. + const complete = [row("https://t3.codes", false), row("", false)]; + expect(terminalLinkAtPosition(complete, 0, 8)).toBe("https://t3.codes"); + // A wide grapheme earlier in the row must not break truncation detection: + // the soft-wrap flag decides, not string-length-versus-cell-count. + const wideFull: GhosttyRow = { + cells: [ + { ...cell("🙂"), wide: 1 }, + { ...cell(""), wide: 2 }, + ...Array.from("https://t3.code", (character) => cell(character)), + ], + text: "🙂 https://t3.code", + isWrapContinuation: false, + wrapsToNext: true, + }; + expect(terminalLinkAtPosition([wideFull], 0, 8)).toBeNull(); + // Unwritten trailing cells prove the bottom row is complete. + const unwrittenTail: GhosttyRow = { + cells: [ + ...Array.from("https://t3.codes", (character) => cell(character)), + cell(""), + cell(""), + ], + text: "https://t3.codes", + isWrapContinuation: false, + wrapsToNext: false, + }; + expect(terminalLinkAtPosition([unwrittenTail], 0, 8)).toBe("https://t3.codes"); + }); +}); + +describe("isTerminalCopyShortcut", () => { + const event = (overrides: Partial[0]> = {}) => ({ + ctrlKey: false, + key: "c", + metaKey: false, + shiftKey: false, + ...overrides, + }); + + it("keeps Ctrl+C available for SIGINT on macOS", () => { + expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "MacIntel")).toBe(false); + expect(isTerminalCopyShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); + }); + + it("uses the conventional Ctrl+Shift+C shortcut elsewhere", () => { + expect(isTerminalCopyShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(false); + expect(isTerminalCopyShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe( + true, + ); + }); + + it("uses the produced character instead of the physical key position", () => { + expect(isTerminalCopyShortcut(event({ key: "C", metaKey: true }), "MacIntel")).toBe(true); + expect(isTerminalCopyShortcut(event({ key: "j", metaKey: true }), "MacIntel")).toBe(false); + }); +}); + +describe("isTerminalPasteShortcut", () => { + const event = (overrides: Partial[0]> = {}) => ({ + ctrlKey: false, + key: "v", + metaKey: false, + shiftKey: false, + ...overrides, + }); + + it("uses Cmd+V on macOS", () => { + expect(isTerminalPasteShortcut(event({ metaKey: true }), "MacIntel")).toBe(true); + expect(isTerminalPasteShortcut(event({ ctrlKey: true }), "MacIntel")).toBe(false); + }); + + it("preserves Ctrl+V and uses Ctrl+Shift+V elsewhere", () => { + expect(isTerminalPasteShortcut(event({ ctrlKey: true }), "Linux x86_64")).toBe(false); + expect(isTerminalPasteShortcut(event({ ctrlKey: true, shiftKey: true }), "Linux x86_64")).toBe( + true, + ); + }); +}); + +describe("isTerminalCompositionCommitInput", () => { + it("identifies browser composition follow-up input", () => { + expect(isTerminalCompositionCommitInput({ inputType: "" })).toBe(true); + expect(isTerminalCompositionCommitInput({ inputType: "insertCompositionText" })).toBe(true); + expect(isTerminalCompositionCommitInput({ inputType: "insertFromComposition" })).toBe(true); + }); + + it("keeps a fast repeated input as legitimate text", () => { + expect(isTerminalCompositionCommitInput({ inputType: "insertText" })).toBe(false); + }); +}); + +describe("application mouse reporting", () => { + const event = (overrides: Partial[1]> = {}) => ({ + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...overrides, + }); + + it("keeps Shift and link activation modifiers available to the browser", () => { + expect(shouldReportTerminalMouse(true, event())).toBe(true); + expect(shouldReportTerminalMouse(true, event({ shiftKey: true }))).toBe(false); + expect(shouldReportTerminalMouse(true, event({ ctrlKey: true }))).toBe(false); + expect(shouldReportTerminalMouse(true, event({ metaKey: true }))).toBe(false); + expect(shouldReportTerminalMouse(false, event())).toBe(false); + }); + + it("maps browser buttons to Ghostty's button enum", () => { + expect([0, 1, 2, 3, 4, 5].map(ghosttyMouseButton)).toEqual([1, 3, 2, 4, 5, null]); + }); +}); + +describe("terminal font resolution", () => { + it("keeps the glyph fallbacks behind a custom text face", () => { + expect(terminalFontFamily()).toBe(DEFAULT_TERMINAL_FONT_FAMILY); + expect(terminalFontFamily(" ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY); + const custom = terminalFontFamily('"Fira Code"'); + expect(custom.startsWith('"Fira Code", ')).toBe(true); + expect(custom).toContain('"Symbols Nerd Font Mono"'); + expect(custom.endsWith("monospace")).toBe(true); + }); + + it("clamps requested font sizes to the supported range", () => { + expect(terminalFontSize()).toBe(DEFAULT_TERMINAL_FONT_SIZE); + expect(terminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE); + expect(terminalFontSize(13.4)).toBe(13); + expect(terminalFontSize(2)).toBe(6); + expect(terminalFontSize(90)).toBe(32); + }); +}); + +describe("terminalContentOriginY", () => { + it("stays top-anchored like a fresh terminal until scrollback exists", () => { + expect(terminalContentOriginY(100, 4, 5, 16, false)).toBe(4); + }); + + it("pins the grid to the bottom by moving the sub-row slack above row 0", () => { + // 100px mount, 4px padding, 5 rows of 16px: 92 - 80 = 12px slack on top. + expect(terminalContentOriginY(100, 4, 5, 16, true)).toBe(16); + // Exact fit keeps the origin at the padding. + expect(terminalContentOriginY(88, 4, 5, 16, true)).toBe(4); + // A mount smaller than the grid never pushes the origin above the padding. + expect(terminalContentOriginY(80, 4, 5, 16, true)).toBe(4); + }); + + it("keeps the prompt stationary while a drag crosses row boundaries", () => { + // Growing the mount pixel by pixel: the bottom edge (origin + rows*cell) + // tracks the mount bottom exactly until a new row fits. + for (let height = 88; height < 104; height += 1) { + const rows = Math.max(1, Math.floor((height - 8) / 16)); + const origin = terminalContentOriginY(height, 4, rows, 16, true); + expect(origin + rows * 16).toBe(height - 4); + } + }); +}); + +describe("terminalWheelDeltaRows", () => { + it("converts line-mode deltas into whole rows", () => { + const result = terminalWheelDeltaRows({ deltaY: 3, deltaMode: 1 }, 16, 24, 0); + expect(result.rows).toBe(3); + expect(result.remainder).toBe(0); + }); + + it("accumulates fractional pixel deltas across events", () => { + let remainder = 0; + let scrolled = 0; + for (let index = 0; index < 4; index += 1) { + const result = terminalWheelDeltaRows({ deltaY: 5, deltaMode: 0 }, 16, 24, remainder); + remainder = result.remainder; + scrolled += result.rows; + } + expect(scrolled).toBe(1); + expect(remainder).toBeCloseTo(4 / 16); + }); + + it("keeps direction for negative page-mode deltas", () => { + const result = terminalWheelDeltaRows({ deltaY: -1, deltaMode: 2 }, 16, 24, 0); + expect(result.rows).toBe(-24); + expect(result.remainder).toBe(0); + }); +}); + +describe("terminalWheelArrowData", () => { + it("emits one arrow per row honoring application cursor keys", () => { + expect(terminalWheelArrowData(-2, false)).toBe("\u001b[A\u001b[A"); + expect(terminalWheelArrowData(3, false)).toBe("\u001b[B\u001b[B\u001b[B"); + expect(terminalWheelArrowData(-1, true)).toBe("\u001bOA"); + expect(terminalWheelArrowData(0, true)).toBe(""); + }); +}); + +describe("isTerminalLinkPointerGesture", () => { + it("uses Command on macOS and Control elsewhere", () => { + expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "MacIntel")).toBe(true); + expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "MacIntel")).toBe(false); + expect(isTerminalLinkPointerGesture({ ctrlKey: true, metaKey: false }, "Linux x86_64")).toBe( + true, + ); + expect(isTerminalLinkPointerGesture({ ctrlKey: false, metaKey: true }, "Linux x86_64")).toBe( + false, + ); + }); +}); + +describe("advanceTerminalSelectionClickSequence", () => { + it("recognizes stationary double and triple pointer presses without PointerEvent.detail", () => { + const first = advanceTerminalSelectionClickSequence(null, { + clientX: 20, + clientY: 30, + timeStamp: 1_000, + }); + const second = advanceTerminalSelectionClickSequence(first, { + clientX: 22, + clientY: 29, + timeStamp: 1_200, + }); + const third = advanceTerminalSelectionClickSequence(second, { + clientX: 21, + clientY: 31, + timeStamp: 1_400, + }); + + expect([first.count, second.count, third.count]).toEqual([1, 2, 3]); + }); + + it("starts over after movement, delay, or a completed triple click", () => { + const previous = { count: 3, time: 1_000, x: 20, y: 30 }; + expect( + advanceTerminalSelectionClickSequence(previous, { + clientX: 20, + clientY: 30, + timeStamp: 1_100, + }).count, + ).toBe(1); + expect( + advanceTerminalSelectionClickSequence(previous, { + clientX: 30, + clientY: 30, + timeStamp: 1_100, + }).count, + ).toBe(1); + expect( + advanceTerminalSelectionClickSequence(previous, { + clientX: 20, + clientY: 30, + timeStamp: 1_501, + }).count, + ).toBe(1); + }); +}); + +describe("terminal scrollbar", () => { + it("maps Ghostty's viewport state to a proportional thumb", () => { + expect(terminalScrollbarGeometry({ total: 100, offset: 40, len: 20 }, 200)).toEqual({ + thumbHeight: 40, + thumbTop: 80, + maxOffset: 80, + }); + expect(terminalScrollbarGeometry({ total: 100, offset: 0, len: 100 }, 200)).toBeNull(); + }); + + it("keeps the thumb usable for large scrollback and maps dragging back to rows", () => { + const state = { total: 10_000, offset: 0, len: 20 }; + expect(terminalScrollbarGeometry(state, 200)).toEqual({ + thumbHeight: 18, + thumbTop: 0, + maxOffset: 9_980, + }); + expect(terminalScrollbarOffsetAtPointer(state, 200, 191, 9)).toBe(9_980); + }); +}); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts new file mode 100644 index 000000000000..86c1ad5329c1 --- /dev/null +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -0,0 +1,1393 @@ +import { isMacPlatform } from "../../lib/utils"; +import { collectWrappedTerminalLinkLine, extractTerminalLinks } from "../../terminal-links"; +import { + GhosttyTerminalCore, + type GhosttyScrollbar, + type GhosttySnapshot, + type GhosttyTheme, +} from "./core"; +import { + measureGhosttyCell, + renderGhosttySnapshot, + terminalGridSize, + type GhosttyCellMetrics, +} from "./renderer"; +import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; + +export const DEFAULT_TERMINAL_FONT_SIZE = 12; +const MIN_TERMINAL_FONT_SIZE = 6; +const MAX_TERMINAL_FONT_SIZE = 32; +// The glyph fallbacks only supply symbols the text faces are missing (powerline +// separators, devicons, and other private-use prompt symbols), so shells +// configured for a locally installed Nerd Font keep their prompt glyphs no +// matter which text face is active. +const TERMINAL_GLYPH_FALLBACKS = + '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono Nerd Font", ' + + '"JetBrainsMono NF", "FiraCode Nerd Font", "Hack Nerd Font", "MesloLGS NF", ' + + '"CaskaydiaCove Nerd Font", "PowerlineSymbols", monospace'; +// SF Mono where the platform has it (macOS), otherwise the bundled JetBrains +// Mono webfont, so the default rendering is identical everywhere else. +export const DEFAULT_TERMINAL_FONT_FAMILY = + '"SF Mono", "SFMono-Regular", "JetBrains Mono", ' + TERMINAL_GLYPH_FALLBACKS; +const CONTENT_PADDING = 4; +const MIN_SCROLLBAR_THUMB_HEIGHT = 18; + +/** Requested terminal font; omitted fields fall back to the defaults. */ +export interface GhosttyTerminalFont { + readonly family?: string; + readonly size?: number; +} + +let symbolsFontLoad: Promise | null = null; + +/** + * Register the bundled symbols-only Nerd Font once per page. It loads lazily + * with the first terminal, and because it carries no regular text glyphs it + * composes with any text face without changing metrics — prompt symbols and + * devicons render even on machines without a locally installed Nerd Font. + */ +function ensureTerminalSymbolsFont(): Promise { + if (symbolsFontLoad !== null) return symbolsFontLoad; + symbolsFontLoad = (async () => { + try { + const face = new FontFace("Symbols Nerd Font Mono", `url(${symbolsFontUrl})`); + document.fonts.add(await face.load()); + } catch { + // Locally installed fallback faces still apply. + } + })(); + return symbolsFontLoad; +} + +export function terminalFontFamily(family?: string): string { + const custom = family?.trim(); + if (!custom) return DEFAULT_TERMINAL_FONT_FAMILY; + // A custom face keeps the glyph fallbacks so prompt symbols stay covered. + return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; +} + +export function terminalFontSize(size?: number): number { + if (size === undefined || !Number.isFinite(size)) return DEFAULT_TERMINAL_FONT_SIZE; + return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size))); +} + +/** + * Vertical origin of the grid inside the mount. While content is shorter than + * the viewport the grid sits at the top like a fresh terminal. Once scrollback + * exists the prompt lives on the bottom row, so the grid anchors to the bottom + * edge instead: the sub-row remainder moves above row 0 and resizing within a + * row boundary keeps the prompt pinned instead of snapping up and down. + */ +export function terminalContentOriginY( + mountHeight: number, + padding: number, + rows: number, + cellHeight: number, + anchorBottom: boolean, +): number { + if (!anchorBottom) return padding; + const slack = mountHeight - padding * 2 - rows * cellHeight; + return padding + Math.max(0, slack); +} + +export interface TerminalScrollbarGeometry { + readonly thumbHeight: number; + readonly thumbTop: number; + readonly maxOffset: number; +} + +export function terminalScrollbarGeometry( + state: GhosttyScrollbar, + trackHeight: number, +): TerminalScrollbarGeometry | null { + const total = Math.max(0, state.total); + const len = Math.max(0, Math.min(state.len, total)); + const maxOffset = Math.max(0, total - len); + if (trackHeight <= 0 || len <= 0 || maxOffset === 0) return null; + const thumbHeight = Math.min( + trackHeight, + Math.max(MIN_SCROLLBAR_THUMB_HEIGHT, (trackHeight * len) / total), + ); + const travel = Math.max(0, trackHeight - thumbHeight); + const offset = Math.max(0, Math.min(state.offset, maxOffset)); + return { + thumbHeight, + thumbTop: travel * (offset / maxOffset), + maxOffset, + }; +} + +export function terminalScrollbarOffsetAtPointer( + state: GhosttyScrollbar, + trackHeight: number, + pointerY: number, + pointerOffset: number, +): number { + const geometry = terminalScrollbarGeometry(state, trackHeight); + if (geometry === null) return 0; + const travel = Math.max(0, trackHeight - geometry.thumbHeight); + if (travel === 0) return 0; + const thumbTop = Math.max(0, Math.min(pointerY - pointerOffset, travel)); + return Math.round((thumbTop / travel) * geometry.maxOffset); +} + +function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { + const text = row.cells.map((cell) => cell.text || " ").join(""); + return trimRight ? text.trimEnd() : text; +} + +function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: number): number { + let offset = 0; + for (let cellIndex = 0; cellIndex < column; cellIndex += 1) { + offset += row.cells[cellIndex]?.text.length || 1; + } + return offset; +} + +export function terminalLinkAtPosition( + rows: GhosttySnapshot["rowData"], + rowIndex: number, + column: number, +): string | null { + const wrappedLine = collectWrappedTerminalLinkLine(rowIndex + 1, (index) => { + const row = rows[index]; + if (!row) return null; + return { + isWrapped: row.isWrapContinuation, + translateToString: (trimRight = false) => terminalRowText(row, trimRight), + }; + }); + if (!wrappedLine) return null; + // Only viewport rows are available: a wrapped line whose head scrolled above + // the viewport would resolve a truncated match into a wrong link. + const firstSegment = wrappedLine.segments[0]; + if (firstSegment && rows[firstSegment.bufferLineNumber - 1]?.isWrapContinuation) { + return null; + } + const segment = wrappedLine.segments.find((value) => value.bufferLineNumber === rowIndex + 1); + const row = rows[rowIndex]; + if (!segment || !row) return null; + const lastSegment = wrappedLine.segments.at(-1); + const lastRow = lastSegment ? rows[lastSegment.bufferLineNumber - 1] : undefined; + // Ghostty's soft-wrap flag is authoritative: when the last collected row + // still wraps onward, its continuation is outside the viewport. + const continuesBelowViewport = lastRow !== undefined && lastRow.wrapsToNext; + const offset = segment.startIndex + terminalColumnOffset(row, column); + for (const match of extractTerminalLinks(wrappedLine.text)) { + if (offset >= match.start && offset < match.end) { + // A truncated tail must not activate as a complete link. + if (match.end === wrappedLine.text.length && continuesBelowViewport) return null; + return match.text; + } + } + return null; +} + +export function terminalLinkAtColumn(row: GhosttySnapshot["rowData"][number], column: number) { + return terminalLinkAtPosition([row], 0, column); +} + +export function isTerminalCopyShortcut( + event: Pick, + platform = navigator.platform, +) { + if (event.key.toLowerCase() !== "c") return false; + return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; +} + +export function isTerminalPasteShortcut( + event: Pick, + platform = navigator.platform, +) { + if (event.key.toLowerCase() !== "v") return false; + return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; +} + +export function isTerminalCompositionCommitInput(event: Pick): boolean { + return ( + event.inputType === "" || + event.inputType === "insertCompositionText" || + event.inputType === "insertFromComposition" + ); +} + +export function isTerminalAltGraphText( + event: Pick, +): boolean { + return event.getModifierState("AltGraph") && [...event.key].length === 1; +} + +export function shouldReportTerminalMouse( + tracking: boolean, + event: Pick, +): boolean { + return tracking && !event.shiftKey && !event.ctrlKey && !event.metaKey; +} + +export function terminalWheelDeltaRows( + event: Pick, + cellHeight: number, + viewportRows: number, + remainder: number, +): { readonly rows: number; readonly remainder: number } { + // deltaMode: 0 pixels, 1 lines, 2 pages. + const pixels = + event.deltaMode === 1 + ? event.deltaY * cellHeight + : event.deltaMode === 2 + ? event.deltaY * viewportRows * cellHeight + : event.deltaY; + const total = remainder + pixels / cellHeight; + const rows = Math.trunc(total); + return { rows, remainder: total - rows }; +} + +export function terminalWheelArrowData(rows: number, applicationCursorKeys: boolean): string { + if (rows === 0) return ""; + const sequence = + rows < 0 + ? applicationCursorKeys + ? "\u001bOA" + : "\u001b[A" + : applicationCursorKeys + ? "\u001bOB" + : "\u001b[B"; + return sequence.repeat(Math.abs(rows)); +} + +export function isTerminalLinkPointerGesture( + event: Pick, + platform = navigator.platform, +): boolean { + return isMacPlatform(platform) + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; +} + +export function ghosttyMouseButton(button: number): number | null { + switch (button) { + case 0: + return 1; + case 1: + return 3; + case 2: + return 2; + case 3: + return 4; + case 4: + return 5; + default: + return null; + } +} + +export interface TerminalSelectionClickSequence { + readonly count: number; + readonly time: number; + readonly x: number; + readonly y: number; +} + +export function advanceTerminalSelectionClickSequence( + previous: TerminalSelectionClickSequence | null, + event: Pick, +): TerminalSelectionClickSequence { + const repeats = + previous !== null && + event.timeStamp - previous.time <= 500 && + Math.hypot(event.clientX - previous.x, event.clientY - previous.y) <= 4; + return { + count: repeats ? (previous.count >= 3 ? 1 : previous.count + 1) : 1, + time: event.timeStamp, + x: event.clientX, + y: event.clientY, + }; +} + +export interface GhosttySelectionPosition { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; +} + +export interface GhosttyTerminalSurfaceOptions { + readonly theme: GhosttyTheme; + readonly font?: GhosttyTerminalFont; + readonly onData: (data: string) => void; + readonly onResize: (cols: number, rows: number) => void; + readonly onSelectionChange: () => void; + readonly onCopy: (text: string) => void; + readonly beforeKey: (event: KeyboardEvent) => boolean; + readonly onLinkActivate: (text: string, event: MouseEvent) => void; +} + +export class GhosttyTerminalSurface { + readonly canvas: HTMLCanvasElement; + readonly input: HTMLTextAreaElement; + readonly scrollbar: HTMLDivElement; + cols = 1; + rows = 1; + + private readonly mount: HTMLElement; + private readonly context: CanvasRenderingContext2D; + private readonly core: GhosttyTerminalCore; + private readonly options: GhosttyTerminalSurfaceOptions; + private metrics: GhosttyCellMetrics; + private fontFamily: string; + private fontSize: number; + private fontEpoch = 0; + private readonly resizeObserver: ResizeObserver; + private readonly scrollbarThumb: HTMLDivElement; + private snapshot: GhosttySnapshot | null = null; + private frame = 0; + private cursorTimer: number | null = null; + private compositionInputToSuppress: string | null = null; + private compositionSuppressionTimer: number | null = null; + private cursorOn = true; + private renderedCursorY: number | null = null; + private forceFullRender = true; + private scrollbarDirty = true; + private scrollbarState: GhosttyScrollbar | null = null; + private scrollbarPointerId: number | null = null; + private scrollbarPointerOffset = 0; + private disposed = false; + private resizeNotifyTimer: number | null = null; + private originY = CONTENT_PADDING; + private mountHeight = 0; + private selectionEnd: { x: number; y: number } | null = null; + private selectionAnchorScreen: { x: number; y: number } | null = null; + private selectionEndScreen: { x: number; y: number } | null = null; + private selectionMode: "cell" | "word" | "line" = "cell"; + // Word/line selection base in screen coordinates so streaming output cannot + // shift the origin of a drag selection. + private selectionBase: { + start: { x: number; y: number }; + end: { x: number; y: number }; + } | null = null; + private selectionScrollTimer: number | null = null; + private selectionScrollDelta = 0; + private selectionPointer: { x: number; y: number } | null = null; + private mouseReportingPointerId: number | null = null; + private mouseReportingButton: number | null = null; + private linkActivationPointerId: number | null = null; + private selectionClickSequence: TerminalSelectionClickSequence | null = null; + private selectionMoved = false; + private composing = false; + private focused = false; + private resizeNotified = false; + private canvasConfigured = false; + private theme: GhosttyTheme; + private readonly suppressedKeyCodes = new Set(); + private pasteShortcutToken = 0; + private wheelRemainder = 0; + private dprMedia: MediaQueryList | null = null; + private inputLeft = -1; + private inputTop = -1; + + private constructor( + mount: HTMLElement, + canvas: HTMLCanvasElement, + input: HTMLTextAreaElement, + scrollbar: HTMLDivElement, + scrollbarThumb: HTMLDivElement, + context: CanvasRenderingContext2D, + core: GhosttyTerminalCore, + metrics: GhosttyCellMetrics, + options: GhosttyTerminalSurfaceOptions, + ) { + this.mount = mount; + this.canvas = canvas; + this.input = input; + this.scrollbar = scrollbar; + this.scrollbarThumb = scrollbarThumb; + this.context = context; + this.core = core; + this.metrics = metrics; + this.options = options; + this.theme = options.theme; + this.fontFamily = terminalFontFamily(options.font?.family); + this.fontSize = terminalFontSize(options.font?.size); + this.resizeObserver = new ResizeObserver(() => this.fit()); + this.installEvents(); + this.watchDevicePixelRatio(); + document.fonts.addEventListener("loadingdone", this.onFontsLoaded); + this.resizeObserver.observe(mount); + } + + static async create( + mount: HTMLElement, + options: GhosttyTerminalSurfaceOptions, + ): Promise { + const canvas = document.createElement("canvas"); + canvas.className = "t3-ghostty-canvas"; + canvas.style.cssText = "display:block;width:100%;height:100%;"; + canvas.setAttribute("aria-hidden", "true"); + + const input = document.createElement("textarea"); + input.className = "t3-ghostty-input"; + input.setAttribute("aria-label", "Terminal input"); + input.autocapitalize = "off"; + input.autocomplete = "off"; + input.spellcheck = false; + input.style.cssText = + "position:absolute;left:4px;top:4px;width:1px;height:1px;opacity:0;padding:0;border:0;resize:none;pointer-events:none;"; + + const scrollbar = document.createElement("div"); + scrollbar.className = "t3-ghostty-scrollbar"; + scrollbar.setAttribute("role", "scrollbar"); + scrollbar.setAttribute("aria-label", "Terminal scrollback"); + scrollbar.setAttribute("aria-orientation", "vertical"); + scrollbar.tabIndex = 0; + scrollbar.hidden = true; + const scrollbarThumb = document.createElement("div"); + scrollbarThumb.className = "t3-ghostty-scrollbar-thumb"; + scrollbar.append(scrollbarThumb); + mount.replaceChildren(canvas, input, scrollbar); + + const context = canvas.getContext("2d", { alpha: false }); + if (!context) throw new Error("Canvas 2D is unavailable"); + const fontFamily = terminalFontFamily(options.font?.family); + const fontSize = terminalFontSize(options.font?.size); + try { + // Cell metrics must come from the faces that will render; measuring before + // the bundled webfonts load would size the grid from a fallback font. + await ensureTerminalSymbolsFont(); + await document.fonts.load(`${fontSize}px ${fontFamily}`); + } catch { + // Metrics fall back to whichever faces are already available. + } + const metrics = measureGhosttyCell(context, fontSize, fontFamily); + const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING); + const core = await GhosttyTerminalCore.create( + grid.cols, + grid.rows, + metrics.width, + metrics.height, + options.theme, + options.onData, + ); + const surface = new GhosttyTerminalSurface( + mount, + canvas, + input, + scrollbar, + scrollbarThumb, + context, + core, + metrics, + options, + ); + surface.fit(); + surface.requestRender(); + return surface; + } + + write(data: string): void { + if (this.disposed) return; + this.core.write(data); + // Restart the blink cycle from the visible phase so the cursor never sits + // invisible through a stream of output or a burst of typing echo. + this.cursorOn = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + resetAndWrite(data: string): void { + if (this.disposed) return; + this.core.resetAndWrite(data); + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + setTheme(theme: GhosttyTheme): void { + if (this.disposed) return; + this.theme = theme; + this.core.setTheme(theme); + this.forceFullRender = true; + this.requestRender(); + } + + async setFont(font: GhosttyTerminalFont): Promise { + if (this.disposed) return; + const fontFamily = terminalFontFamily(font.family); + const fontSize = terminalFontSize(font.size); + // The fields only change together with their metrics after the load, and + // the epoch lets the newest overlapping call win regardless of load order. + const epoch = ++this.fontEpoch; + try { + await document.fonts.load(`${fontSize}px ${fontFamily}`); + } catch { + // Metrics fall back to whichever faces are already available. + } + if (this.disposed || epoch !== this.fontEpoch) return; + this.fontFamily = fontFamily; + this.fontSize = fontSize; + this.applyFontMetrics(); + } + + private applyFontMetrics(): void { + this.metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); + this.core.resize(this.cols, this.rows, this.metrics.width, this.metrics.height); + // Cached IME textarea coordinates are stale in the new cell geometry. + this.inputLeft = -1; + this.inputTop = -1; + this.forceFullRender = true; + this.scrollbarDirty = true; + this.fit(); + this.requestRender(); + } + + private readonly onFontsLoaded = () => { + if (this.disposed) return; + // A face that finished loading after the initial measurement changes glyph + // advances; re-measure and refit so the grid matches what actually renders. + const metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); + if ( + metrics.width === this.metrics.width && + metrics.height === this.metrics.height && + metrics.baseline === this.metrics.baseline + ) { + return; + } + this.applyFontMetrics(); + }; + + fit(): boolean { + if (this.disposed) return false; + const width = this.mount.clientWidth; + const height = this.mount.clientHeight; + if (width <= 0 || height <= 0) return false; + const ratio = window.devicePixelRatio || 1; + const pixelWidth = Math.max(1, Math.round(width * ratio)); + const pixelHeight = Math.max(1, Math.round(height * ratio)); + let shouldRender = false; + // The DPR transform must be installed even when the target size happens to + // equal the canvas default 300x150 backing store, so the first fit always + // schedules a canvas configuration. + if ( + this.canvas.width !== pixelWidth || + this.canvas.height !== pixelHeight || + !this.canvasConfigured + ) { + this.canvas.width = pixelWidth; + this.canvas.height = pixelHeight; + this.context.setTransform(ratio, 0, 0, ratio, 0, 0); + this.canvasConfigured = true; + this.forceFullRender = true; + this.scrollbarDirty = true; + shouldRender = true; + } + const grid = terminalGridSize(width, height, this.metrics, CONTENT_PADDING); + this.mountHeight = height; + // onResize is the only PTY resize channel, so the first successful fit must + // notify even when the measured grid equals the 1x1 construction sentinel. + if (grid.cols !== this.cols || grid.rows !== this.rows || !this.resizeNotified) { + this.cols = grid.cols; + this.rows = grid.rows; + this.core.resize(grid.cols, grid.rows, this.metrics.width, this.metrics.height); + this.notifyResize(); + this.forceFullRender = true; + this.scrollbarDirty = true; + shouldRender = true; + } + // Rendering synchronously keeps the repaint inside the same frame as the + // layout change: ResizeObserver fires before paint, so the browser never + // composites the old backing store stretched into the new element box. + if (shouldRender) this.renderFrame(); + return true; + } + + /** + * The local grid reflows immediately, but the PTY only hears about settled + * dimensions: notifying on every drag step makes the shell reprint its + * prompt mid-drag, which reads as jitter. + */ + private notifyResize(): void { + this.resizeNotified = true; + if (this.resizeNotifyTimer !== null) window.clearTimeout(this.resizeNotifyTimer); + this.resizeNotifyTimer = window.setTimeout(() => { + this.resizeNotifyTimer = null; + if (!this.disposed) this.options.onResize(this.cols, this.rows); + }, 150); + } + + focus(): void { + this.input.focus({ preventScroll: true }); + } + + hasSelection(): boolean { + return this.core.selectionText().length > 0; + } + + getSelection(): string { + return this.core.selectionText(); + } + + getSelectionPosition(): GhosttySelectionPosition | null { + if (!this.selectionAnchorScreen || !this.selectionEndScreen || !this.hasSelection()) + return null; + const before = + this.selectionAnchorScreen.y < this.selectionEndScreen.y || + (this.selectionAnchorScreen.y === this.selectionEndScreen.y && + this.selectionAnchorScreen.x <= this.selectionEndScreen.x); + return before + ? { start: this.selectionAnchorScreen, end: this.selectionEndScreen } + : { start: this.selectionEndScreen, end: this.selectionAnchorScreen }; + } + + getSelectionEndClientRect(): { readonly right: number; readonly bottom: number } | null { + const position = this.getSelectionPosition(); + if (!position) return null; + const viewportEnd = this.core.screenPointToViewport(position.end.x, position.end.y); + if (!viewportEnd) return null; + const bounds = this.canvas.getBoundingClientRect(); + return { + right: bounds.left + CONTENT_PADDING + (viewportEnd.x + 1) * this.metrics.width, + bottom: bounds.top + this.originY + (viewportEnd.y + 1) * this.metrics.height, + }; + } + + clearSelection(): void { + this.core.clearSelection(); + this.selectionEnd = null; + this.selectionAnchorScreen = null; + this.selectionEndScreen = null; + this.selectionMode = "cell"; + this.selectionBase = null; + this.setSelectionAutoscroll(0); + this.options.onSelectionChange(); + // Selection highlights span rows Ghostty may not mark dirty for this change. + this.forceFullRender = true; + this.requestRender(); + } + + scrollToBottom(): void { + this.core.scrollToBottom(); + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + isAtBottom(): boolean { + return this.core.isViewportActive(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.resizeObserver.disconnect(); + document.fonts.removeEventListener("loadingdone", this.onFontsLoaded); + this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); + this.dprMedia = null; + if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer); + if (this.resizeNotifyTimer !== null) { + window.clearTimeout(this.resizeNotifyTimer); + this.resizeNotifyTimer = null; + // Flush the settled dimensions so the PTY keeps the final size even when + // the surface unmounts inside the debounce window. + this.options.onResize(this.cols, this.rows); + } + if (this.frame !== 0) window.cancelAnimationFrame(this.frame); + if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer); + if (this.compositionSuppressionTimer !== null) { + window.clearTimeout(this.compositionSuppressionTimer); + } + this.removeEvents(); + this.core.dispose(); + if ( + this.canvas.parentElement === this.mount || + this.input.parentElement === this.mount || + this.scrollbar.parentElement === this.mount + ) { + this.canvas.remove(); + this.input.remove(); + this.scrollbar.remove(); + } + } + + private readonly onKeyDown = (event: KeyboardEvent) => { + // Presses handled outside the terminal must also swallow their release: + // beforeKey runs side effects (keybindings, navigation sends), so it cannot + // be consulted again on keyup, and Kitty report-event-types sessions would + // otherwise receive a release for a press the shell never saw. + if (isTerminalAltGraphText(event) || !this.options.beforeKey(event)) { + this.suppressedKeyCodes.add(event.code); + return; + } + if (isTerminalCopyShortcut(event) && this.hasSelection()) { + event.preventDefault(); + this.suppressedKeyCodes.add(event.code); + this.options.onCopy(this.getSelection()); + return; + } + if (isTerminalPasteShortcut(event)) { + this.suppressedKeyCodes.add(event.code); + const clipboard = navigator.clipboard; + if (typeof clipboard?.readText === "function") { + // Race the async clipboard read against the browser's own paste event: + // the native event (dispatched synchronously with the default action) + // always claims the token first when it fires, and the read covers + // browsers whose paste shortcut produces no paste event. Not preventing + // the default keeps the native path alive when the read is denied. + const token = ++this.pasteShortcutToken; + void clipboard.readText().then( + (text) => { + if (this.disposed || this.pasteShortcutToken !== token) return; + this.pasteShortcutToken += 1; + if (text.length > 0) this.options.onData(this.core.encodePaste(text)); + }, + () => { + // Clipboard read denied; the native paste event remains the path. + }, + ); + } + return; + } + // keyCode 229 is Safari's only signal that this keydown opens an IME + // composition; encoding it would double the committed text. + if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) { + return; + } + const data = this.core.encodeKey(event); + if (data.length === 0) return; + this.suppressedKeyCodes.delete(event.code); + event.preventDefault(); + event.stopPropagation(); + this.options.onData(data); + }; + + private readonly onKeyUp = (event: KeyboardEvent) => { + if (this.suppressedKeyCodes.delete(event.code)) return; + if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) { + return; + } + // Ghostty's encoder only emits release codes when the terminal enabled the + // Kitty report-event-types flag, so legacy sessions send nothing here. + const data = this.core.encodeKey(event, "release"); + if (data.length === 0) return; + event.preventDefault(); + event.stopPropagation(); + this.options.onData(data); + }; + + private readonly onFocus = () => { + this.focused = true; + this.cursorOn = true; + this.requestRender(); + }; + + private readonly onBlur = () => { + this.focused = false; + // Suppressions survive blur deliberately: a shortcut that moves focus (for + // example terminal-toggle) must still swallow its own keyup if focus comes + // back before release. Stale entries are harmless — an encoding keydown + // always removes its code first. + // The steady unfocused hollow cursor must not inherit an off blink phase. + this.cursorOn = true; + this.requestRender(); + }; + + private readonly onDevicePixelRatioChange = () => { + this.watchDevicePixelRatio(); + this.fit(); + }; + + private watchDevicePixelRatio(): void { + this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); + // A resolution media query only fires once for the ratio it was created at, + // so re-arm it after every change (monitor moves, browser zoom). + this.dprMedia = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + this.dprMedia.addEventListener("change", this.onDevicePixelRatioChange); + } + + private readonly onPaste = (event: ClipboardEvent) => { + // Always suppress the browser's default insertion: content the textarea + // would receive (for example an html-only clipboard converted to text) + // leaks through onInput without bracketed-paste encoding. + event.preventDefault(); + const data = event.clipboardData?.getData("text/plain") ?? ""; + if (data.length === 0) return; + // The native paste won the race with actual text; a pending clipboard read + // must not double. An empty native paste leaves the read as the only path. + this.pasteShortcutToken += 1; + this.options.onData(this.core.encodePaste(data)); + }; + + private readonly onCompositionStart = () => { + this.clearCompositionInputSuppression(); + this.composing = true; + }; + + private readonly onCompositionEnd = (event: CompositionEvent) => { + this.composing = false; + const data = this.input.value || event.data; + if (data.length > 0) this.options.onData(data); + this.input.value = ""; + this.compositionInputToSuppress = data; + this.compositionSuppressionTimer = window.setTimeout(() => { + this.compositionInputToSuppress = null; + this.compositionSuppressionTimer = null; + }, 100); + }; + + private readonly onInput = (event: Event) => { + const inputEvent = event as InputEvent; + if (this.composing || inputEvent.isComposing) return; + const data = this.input.value || inputEvent.data || ""; + if (data === this.compositionInputToSuppress && isTerminalCompositionCommitInput(inputEvent)) { + this.clearCompositionInputSuppression(); + this.input.value = ""; + return; + } + this.clearCompositionInputSuppression(); + if (data.length > 0) this.options.onData(data); + this.input.value = ""; + }; + + private clearCompositionInputSuppression(): void { + if (this.compositionSuppressionTimer !== null) { + window.clearTimeout(this.compositionSuppressionTimer); + this.compositionSuppressionTimer = null; + } + this.compositionInputToSuppress = null; + } + + private readonly onPointerDown = (event: PointerEvent) => { + this.focus(); + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { + const button = ghosttyMouseButton(event.button); + if (button === null) return; + event.preventDefault(); + event.stopPropagation(); + this.mouseReportingPointerId = event.pointerId; + this.mouseReportingButton = button; + this.sendMouse("press", button, event); + this.canvas.setPointerCapture(event.pointerId); + return; + } + if (event.button !== 0) return; + if (isTerminalLinkPointerGesture(event)) { + event.preventDefault(); + event.stopPropagation(); + this.linkActivationPointerId = event.pointerId; + this.canvas.setPointerCapture(event.pointerId); + return; + } + const cell = this.cellAt(event.clientX, event.clientY); + this.selectionMoved = false; + this.selectionClickSequence = advanceTerminalSelectionClickSequence( + this.selectionClickSequence, + event, + ); + const clickCount = this.selectionClickSequence.count; + this.selectionMode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : "cell"; + const range = + this.selectionMode === "line" + ? this.core.selectLine(cell.x, cell.y) + : this.selectionMode === "word" + ? this.core.selectWord(cell.x, cell.y) + : null; + if (range) { + this.selectionBase = range.screen; + this.selectionEnd = range.viewport.end; + this.selectionAnchorScreen = range.screen.start; + this.selectionEndScreen = range.screen.end; + this.options.onSelectionChange(); + } else { + this.selectionMode = "cell"; + this.selectionBase = null; + this.selectionEnd = cell; + const screen = this.core.viewportPointToScreen(cell.x, cell.y); + this.selectionAnchorScreen = screen; + this.selectionEndScreen = screen; + if (screen) { + this.core.setSelection({ ...screen, tag: 2 }, { ...screen, tag: 2 }); + } else { + this.core.setSelection(cell, cell); + } + } + this.forceFullRender = true; + this.canvas.setPointerCapture(event.pointerId); + this.requestRender(); + }; + + private readonly onPointerMove = (event: PointerEvent) => { + if (this.linkActivationPointerId === event.pointerId) return; + // Hover motion is only reportable in any-event tracking (DEC 1003); normal and + // button-event tracking never report motion without a captured pressed button. + if ( + this.mouseReportingPointerId === event.pointerId || + shouldReportTerminalMouse(this.core.isMouseAnyEventTracking(), event) + ) { + event.preventDefault(); + this.canvas.style.cursor = "default"; + this.sendMouse("motion", this.buttonFromButtons(event.buttons), event); + return; + } + if (!this.selectionAnchorScreen || !this.canvas.hasPointerCapture(event.pointerId)) { + this.updateHoverCursor(event); + return; + } + this.selectionPointer = { x: event.clientX, y: event.clientY }; + const bounds = this.canvas.getBoundingClientRect(); + this.setSelectionAutoscroll( + event.clientY < bounds.top ? -1 : event.clientY > bounds.bottom ? 1 : 0, + ); + const cell = this.cellAt(event.clientX, event.clientY); + if (cell.x === this.selectionEnd?.x && cell.y === this.selectionEnd.y) return; + this.extendSelectionTo(event.clientX, event.clientY); + }; + + private extendSelectionTo(clientX: number, clientY: number): void { + const anchorScreen = this.selectionAnchorScreen; + if (anchorScreen === null) return; + const cell = this.cellAt(clientX, clientY); + this.selectionMoved = true; + this.selectionEnd = cell; + const range = + this.selectionMode === "line" + ? this.core.selectLine(cell.x, cell.y) + : this.selectionMode === "word" + ? this.core.selectWord(cell.x, cell.y) + : null; + const cellScreen = this.core.viewportPointToScreen(cell.x, cell.y); + if (cellScreen === null) return; + const base = this.selectionBase; + const beforeBase = + base !== null && + (cellScreen.y < base.start.y || + (cellScreen.y === base.start.y && cellScreen.x < base.start.x)); + const anchor = base === null ? anchorScreen : beforeBase ? base.end : base.start; + const end = range === null ? cellScreen : beforeBase ? range.screen.start : range.screen.end; + this.selectionAnchorScreen = anchor; + this.selectionEndScreen = end; + this.core.setSelection({ ...anchor, tag: 2 }, { ...end, tag: 2 }); + this.options.onSelectionChange(); + this.forceFullRender = true; + this.requestRender(); + } + + private setSelectionAutoscroll(delta: number): void { + this.selectionScrollDelta = delta; + if (delta === 0) { + if (this.selectionScrollTimer !== null) { + window.clearInterval(this.selectionScrollTimer); + this.selectionScrollTimer = null; + } + return; + } + if (this.selectionScrollTimer !== null) return; + // Dragging past the edge scrolls the viewport and keeps extending the + // selection into the newly revealed rows, like xterm's drag scroller. + this.selectionScrollTimer = window.setInterval(() => { + if (this.disposed || this.selectionScrollDelta === 0) return; + this.scrollViewport(this.selectionScrollDelta); + const pointer = this.selectionPointer; + if (pointer) this.extendSelectionTo(pointer.x, pointer.y); + }, 80); + } + + private updateHoverCursor(event: PointerEvent): void { + const overLink = + isTerminalLinkPointerGesture(event) && this.linkAt(event.clientX, event.clientY) !== null; + const cursor = overLink ? "pointer" : ""; + if (this.canvas.style.cursor !== cursor) this.canvas.style.cursor = cursor; + } + + private readonly onPointerUp = (event: PointerEvent) => { + this.setSelectionAutoscroll(0); + if (this.linkActivationPointerId === event.pointerId) { + event.preventDefault(); + event.stopPropagation(); + this.linkActivationPointerId = null; + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + if (event.type !== "pointercancel") { + const link = this.linkAt(event.clientX, event.clientY); + if (link) this.options.onLinkActivate(link, event); + } + return; + } + if (this.mouseReportingPointerId === event.pointerId) { + event.preventDefault(); + event.stopPropagation(); + this.sendMouse("release", this.mouseReportingButton, event); + this.mouseReportingPointerId = null; + this.mouseReportingButton = null; + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + return; + } + if (this.canvas.hasPointerCapture(event.pointerId)) { + this.canvas.releasePointerCapture(event.pointerId); + } + if (event.button !== 0) return; + if (!this.selectionMoved && this.selectionMode === "cell") { + this.clearSelection(); + } + this.options.onSelectionChange(); + }; + + private readonly onWheel = (event: WheelEvent) => { + if (event.deltaY === 0) return; + event.preventDefault(); + const delta = terminalWheelDeltaRows( + event, + this.metrics.height, + this.rows, + this.wheelRemainder, + ); + this.wheelRemainder = delta.remainder; + if (delta.rows === 0) return; + const magnitude = Math.abs(delta.rows); + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { + const button = delta.rows < 0 ? 4 : 5; + for (let index = 0; index < magnitude; index += 1) { + this.sendMouse("press", button, event); + } + return; + } + if (this.core.isAlternateScreen()) { + // The alternate screen has no scrollback: translate wheel motion into + // arrow keys so full-screen apps like vim and less scroll, matching xterm. + this.options.onData(terminalWheelArrowData(delta.rows, this.core.isApplicationCursorKeys())); + return; + } + this.scrollViewport(delta.rows); + }; + + private readonly onMouseDown = (event: MouseEvent) => { + if (event.button === 0) event.preventDefault(); + this.focus(); + }; + + private readonly onContextMenu = (event: MouseEvent) => { + if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { + event.preventDefault(); + } + }; + + private readonly onScrollbarPointerDown = (event: PointerEvent) => { + if (event.button !== 0) return; + const state = this.readScrollbarState(); + if (state === null) return; + const bounds = this.scrollbar.getBoundingClientRect(); + const geometry = terminalScrollbarGeometry(state, bounds.height); + if (geometry === null) return; + event.preventDefault(); + event.stopPropagation(); + this.scrollbarPointerId = event.pointerId; + this.scrollbarPointerOffset = + event.target === this.scrollbarThumb + ? event.clientY - bounds.top - geometry.thumbTop + : geometry.thumbHeight / 2; + this.scrollbar.setPointerCapture(event.pointerId); + this.scrollbarToPointer(event.clientY, bounds); + }; + + private readonly onScrollbarPointerMove = (event: PointerEvent) => { + if (event.pointerId !== this.scrollbarPointerId || this.scrollbarState === null) return; + event.preventDefault(); + this.scrollbarToPointer(event.clientY, this.scrollbar.getBoundingClientRect()); + }; + + private readonly onScrollbarPointerUp = (event: PointerEvent) => { + if (event.pointerId !== this.scrollbarPointerId) return; + event.preventDefault(); + this.scrollbarPointerId = null; + if (this.scrollbar.hasPointerCapture(event.pointerId)) { + this.scrollbar.releasePointerCapture(event.pointerId); + } + }; + + private readonly onScrollbarKeyDown = (event: KeyboardEvent) => { + const state = this.readScrollbarState(); + if (state === null) return; + let delta = 0; + switch (event.key) { + case "ArrowUp": + delta = -1; + break; + case "ArrowDown": + delta = 1; + break; + case "PageUp": + delta = -Math.max(1, state.len); + break; + case "PageDown": + delta = Math.max(1, state.len); + break; + case "Home": + delta = -state.offset; + break; + case "End": + delta = state.total - state.len - state.offset; + break; + default: + return; + } + event.preventDefault(); + event.stopPropagation(); + this.scrollViewport(delta); + }; + + private installEvents(): void { + this.input.addEventListener("keydown", this.onKeyDown); + this.input.addEventListener("keyup", this.onKeyUp); + this.input.addEventListener("focus", this.onFocus); + this.input.addEventListener("blur", this.onBlur); + this.input.addEventListener("input", this.onInput); + this.input.addEventListener("paste", this.onPaste); + this.input.addEventListener("compositionstart", this.onCompositionStart); + this.input.addEventListener("compositionend", this.onCompositionEnd); + this.canvas.addEventListener("pointerdown", this.onPointerDown); + this.canvas.addEventListener("pointermove", this.onPointerMove); + this.canvas.addEventListener("pointerup", this.onPointerUp); + this.canvas.addEventListener("pointercancel", this.onPointerUp); + this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); + this.canvas.addEventListener("mousedown", this.onMouseDown); + this.canvas.addEventListener("contextmenu", this.onContextMenu); + this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown); + this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove); + this.scrollbar.addEventListener("pointerup", this.onScrollbarPointerUp); + this.scrollbar.addEventListener("pointercancel", this.onScrollbarPointerUp); + this.scrollbar.addEventListener("keydown", this.onScrollbarKeyDown); + } + + private removeEvents(): void { + this.input.removeEventListener("keydown", this.onKeyDown); + this.input.removeEventListener("keyup", this.onKeyUp); + this.input.removeEventListener("focus", this.onFocus); + this.input.removeEventListener("blur", this.onBlur); + this.input.removeEventListener("input", this.onInput); + this.input.removeEventListener("paste", this.onPaste); + this.input.removeEventListener("compositionstart", this.onCompositionStart); + this.input.removeEventListener("compositionend", this.onCompositionEnd); + this.canvas.removeEventListener("pointerdown", this.onPointerDown); + this.canvas.removeEventListener("pointermove", this.onPointerMove); + this.canvas.removeEventListener("pointerup", this.onPointerUp); + this.canvas.removeEventListener("pointercancel", this.onPointerUp); + this.canvas.removeEventListener("wheel", this.onWheel); + this.canvas.removeEventListener("mousedown", this.onMouseDown); + this.canvas.removeEventListener("contextmenu", this.onContextMenu); + this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown); + this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove); + this.scrollbar.removeEventListener("pointerup", this.onScrollbarPointerUp); + this.scrollbar.removeEventListener("pointercancel", this.onScrollbarPointerUp); + this.scrollbar.removeEventListener("keydown", this.onScrollbarKeyDown); + } + + private scrollViewport(deltaRows: number): void { + let delta = Math.trunc(deltaRows); + const state = this.readScrollbarState(); + if (state !== null) { + const maxOffset = Math.max(0, state.total - state.len); + const offset = Math.max(0, Math.min(state.offset + delta, maxOffset)); + delta = offset - state.offset; + this.scrollbarState = { ...state, offset }; + } + if (delta === 0) return; + this.core.scroll(delta); + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + private scrollbarToPointer(clientY: number, bounds: DOMRect): void { + const state = this.scrollbarState; + if (state === null) return; + const offset = terminalScrollbarOffsetAtPointer( + state, + bounds.height, + clientY - bounds.top, + this.scrollbarPointerOffset, + ); + this.scrollViewport(offset - state.offset); + } + + private updateScrollbar(): void { + const state = this.readScrollbarState(); + const geometry = + state === null + ? null + : terminalScrollbarGeometry( + state, + Math.max(0, this.mount.clientHeight - CONTENT_PADDING * 2), + ); + this.scrollbar.hidden = geometry === null; + if (state === null || geometry === null) return; + this.scrollbar.setAttribute("aria-valuemin", "0"); + this.scrollbar.setAttribute("aria-valuemax", String(geometry.maxOffset)); + this.scrollbar.setAttribute( + "aria-valuenow", + String(Math.max(0, Math.min(state.offset, geometry.maxOffset))), + ); + this.scrollbarThumb.style.height = `${geometry.thumbHeight}px`; + this.scrollbarThumb.style.transform = `translateY(${geometry.thumbTop}px)`; + } + + private readScrollbarState(): GhosttyScrollbar | null { + const state = this.core.scrollbarState(); + this.scrollbarState = state; + return state; + } + + private requestRender(): void { + if (this.disposed || this.frame !== 0) return; + this.frame = window.requestAnimationFrame(() => { + this.frame = 0; + this.renderFrame(); + }); + } + + private renderFrame(): void { + if (this.disposed) return; + if (this.frame !== 0) { + window.cancelAnimationFrame(this.frame); + this.frame = 0; + } + this.snapshot = this.core.snapshot(); + if (!this.snapshot.cursorBlinking) this.cursorOn = true; + // The origin only moves together with a forced full repaint: partial + // dirty-row redraws must never composite rows at a shifted origin over + // rows painted at the previous one. Bottom anchoring starts once + // scrollback exists, i.e. when the prompt actually lives at the bottom. + const scrollState = this.readScrollbarState(); + const anchorBottom = scrollState !== null && scrollState.total > scrollState.len; + const nextOriginY = terminalContentOriginY( + this.mountHeight, + CONTENT_PADDING, + this.rows, + this.metrics.height, + anchorBottom, + ); + if (nextOriginY !== this.originY) { + this.originY = nextOriginY; + this.forceFullRender = true; + } + renderGhosttySnapshot({ + context: this.context, + snapshot: this.snapshot, + metrics: this.metrics, + fontSize: this.fontSize, + fontFamily: this.fontFamily, + padding: CONTENT_PADDING, + originY: this.originY, + forceFull: this.forceFullRender, + cursorOn: this.cursorOn, + previousCursorY: this.renderedCursorY, + focused: this.focused, + ...(this.theme.selectionBackground !== undefined + ? { selectionBackground: this.theme.selectionBackground } + : {}), + }); + this.positionInput(); + this.renderedCursorY = + this.cursorOn && this.snapshot.cursorVisible && this.snapshot.cursorY >= 0 + ? this.snapshot.cursorY + : null; + if (this.scrollbarDirty) { + this.scrollbarDirty = false; + this.updateScrollbar(); + } + this.forceFullRender = false; + this.scheduleCursorBlink(); + } + + private scheduleCursorBlink(): void { + if (this.cursorTimer !== null) window.clearTimeout(this.cursorTimer); + this.cursorTimer = null; + // An unfocused surface shows a steady hollow cursor instead of blinking. + if (!this.focused || !this.snapshot?.cursorBlinking || !this.snapshot.cursorVisible) return; + this.cursorTimer = window.setTimeout(() => { + this.cursorTimer = null; + this.cursorOn = !this.cursorOn; + this.requestRender(); + }, 500); + } + + private positionInput(): void { + const snapshot = this.snapshot; + if (!snapshot || !snapshot.cursorVisible || snapshot.cursorX < 0 || snapshot.cursorY < 0) { + return; + } + // The IME candidate window anchors to the textarea, so it must follow the + // terminal cursor for composition to appear where the user is typing. + const left = CONTENT_PADDING + snapshot.cursorX * this.metrics.width; + const top = this.originY + snapshot.cursorY * this.metrics.height; + if (left === this.inputLeft && top === this.inputTop) return; + this.inputLeft = left; + this.inputTop = top; + this.input.style.left = `${left}px`; + this.input.style.top = `${top}px`; + this.input.style.height = `${this.metrics.height}px`; + } + + private cellAt(clientX: number, clientY: number): { x: number; y: number } { + const bounds = this.canvas.getBoundingClientRect(); + return { + x: Math.max( + 0, + Math.min( + this.cols - 1, + Math.floor((clientX - bounds.left - CONTENT_PADDING) / this.metrics.width), + ), + ), + y: Math.max( + 0, + Math.min( + this.rows - 1, + Math.floor((clientY - bounds.top - this.originY) / this.metrics.height), + ), + ), + }; + } + + private linkAt(clientX: number, clientY: number): string | null { + if (!this.snapshot) return null; + const cell = this.cellAt(clientX, clientY); + const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y); + if (explicitHyperlink) return explicitHyperlink; + return terminalLinkAtPosition(this.snapshot.rowData, cell.y, cell.x); + } + + private sendMouse( + action: "press" | "release" | "motion", + button: number | null, + event: MouseEvent, + ): void { + const bounds = this.canvas.getBoundingClientRect(); + const data = this.core.encodeMouse({ + action, + button, + mods: + (event.shiftKey ? 1 : 0) | + (event.ctrlKey ? 1 << 1 : 0) | + (event.altKey ? 1 << 2 : 0) | + (event.metaKey ? 1 << 3 : 0), + x: Math.max(0, event.clientX - bounds.left), + y: Math.max(0, event.clientY - bounds.top), + screenWidth: bounds.width, + screenHeight: bounds.height, + cellWidth: this.metrics.width, + cellHeight: this.metrics.height, + paddingLeft: CONTENT_PADDING, + paddingRight: CONTENT_PADDING, + paddingTop: this.originY, + paddingBottom: Math.max(0, bounds.height - this.originY - this.rows * this.metrics.height), + anyButtonPressed: event.buttons !== 0, + }); + if (data.length > 0) this.options.onData(data); + } + + private buttonFromButtons(buttons: number): number | null { + if ((buttons & 1) !== 0) return 1; + if ((buttons & 4) !== 0) return 3; + if ((buttons & 2) !== 0) return 2; + if ((buttons & 8) !== 0) return 4; + if ((buttons & 16) !== 0) return 5; + return null; + } +} diff --git a/apps/web/src/terminal/ghostty/vendor/ghostty-vt.wasm b/apps/web/src/terminal/ghostty/vendor/ghostty-vt.wasm new file mode 100644 index 000000000000..a8e2405adefd Binary files /dev/null and b/apps/web/src/terminal/ghostty/vendor/ghostty-vt.wasm differ diff --git a/apps/web/src/terminal/ghostty/vendor/ghostty-write-pty.wasm b/apps/web/src/terminal/ghostty/vendor/ghostty-write-pty.wasm new file mode 100644 index 000000000000..66835717dd8d Binary files /dev/null and b/apps/web/src/terminal/ghostty/vendor/ghostty-write-pty.wasm differ diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e1d590ecdf23..24d76c2d9f33 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -126,6 +126,7 @@ const allowedHosts = [".ts.net", ...configuredAllowedHosts]; export default defineConfig(() => { return { + assetsInclude: ["**/*.wasm"], plugins: [ tanstackRouter(), react(), diff --git a/docs/architecture/terminal-renderers.md b/docs/architecture/terminal-renderers.md new file mode 100644 index 000000000000..4b66a13f68c4 --- /dev/null +++ b/docs/architecture/terminal-renderers.md @@ -0,0 +1,42 @@ +# Terminal renderers + +Terminal sessions remain server-owned PTYs. Clients receive the existing raw byte stream and send +input and resize events over the existing terminal contracts; renderer choices never cross the +wire. + +## Ghostty alignment + +Android and web use the official `libghostty-vt` C ABI for parsing, terminal state, grapheme +boundaries, keyboard encoding, selection, and scrollback: + +- Android links the native shared library and converts render state into a compact JNI snapshot. +- Web loads a separately cached WebAssembly build and reads render state into a Canvas 2D surface. +- Both artifacts are built from the revision in + `native/libghostty-vt/VERSION`. + +The platform adapters deliberately own only platform behavior. Android owns its Kotlin Canvas and +touch integration. Web owns browser font shaping, the hidden IME textarea, clipboard and DOM input, +and its Canvas renderer. The web adapter also delegates application mouse encoding, word and line +selection, and OSC 8 hyperlink metadata to the official ABI. Browser conventions remain available: +holding Shift bypasses application mouse capture, and the platform link modifier opens hyperlinks. +React does not participate in terminal frames. + +The web runtime is singleton-scoped per browser tab so split terminals share one compiled module +and memory. Each visible terminal owns and frees its own terminal, render state, row iterator, cell +iterator, key and mouse encoder, and input event handles. Restoring captured scrollback temporarily +detaches the PTY callback so historical device queries cannot emit replies into the current shell. + +## Updating Ghostty + +Update and rebuild Android first, because mobile's `VERSION` file is the single source of truth for +the upstream pin (the upstream `LICENSE` lives beside it). Then run: + +```sh +pnpm --dir apps/web build:ghostty-wasm +``` + +Commit the regenerated web `wasm` artifacts. The build embeds the pinned revision into the binary as +semver build metadata, and the focused web ABI test reads it back through `ghostty_build_info` and +compares it against mobile's `VERSION` — so the web vendor directory holds only the artifacts, drift +cannot hide, and there is no second pin to keep in sync. The same test enforces the artifact budget +and exercises repeated create/write/free cycles with multi-codepoint graphemes. diff --git a/docs/operations/mobile-app-store-screenshots.md b/docs/operations/mobile-app-store-screenshots.md index e0e7a365a668..f271e54098aa 100644 --- a/docs/operations/mobile-app-store-screenshots.md +++ b/docs/operations/mobile-app-store-screenshots.md @@ -61,7 +61,7 @@ The default matrix is: | ----------------------------- | ------------------------- | ----------------- | ----------------------------------------- | | `apple/iphone-6.9/dark/` | iPhone 17 Pro Max | 1320×2868 | App Store Connect iPhone 6.9-inch | | `apple/iphone-6.5/dark/` | disposable iPhone 14 Plus | 1284×2778 | App Store Connect iPhone 6.5-inch | -| `apple/ipad-13/dark/` | iPad Pro 13-inch (M5) | 2064×2752 | App Store Connect iPad 13-inch | +| `apple/ipad-13/dark/` | iPad Pro 13-inch (M5) | 2752×2064 | App Store Connect iPad 13-inch, landscape | | `google-play/phone/dark/` | Pixel AVD at 420 dpi | 1080×1920 | Google Play phone, portrait 9:16 | | `google-play/tablet-7/dark/` | Pixel AVD at 600dp width | 1080×1920 | Google Play 7-inch tablet, portrait 9:16 | | `google-play/tablet-10/dark/` | Pixel AVD at 800dp width | 1440×2560 | Google Play 10-inch tablet, portrait 9:16 | @@ -86,7 +86,8 @@ A light-only run writes the same tree under `light/`; `--appearance both` writes folders. Edit [mobile-showcase.config.ts](../../scripts/mobile-showcase.config.ts) to change simulator or AVD -names, light/dark appearance, scenes, output directory, capture delay, Android ABI, or viewport. +names, light/dark appearance, iOS orientation, scenes, output directory, capture delay, Android ABI, +or viewport. ## Capture in GitHub Actions diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 60cfc44d2922..05418022d233 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -2,6 +2,26 @@ Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app. +## Quick Pairing for a Running Server + +If a server is already running on this machine, mint a fresh pairing token and QR code without restarting anything: + +```bash +npx t3 pair +``` + +`t3 pair` finds the running server (the shared `~/.t3` install, or the current worktree's dev server when run inside one), issues a one-time pairing token, and prints the pairing URL as a QR code you can scan from your phone. + +If the server is only bound to loopback, the printed URL is not reachable from another device. Pair over your tailnet instead: + +```bash +npx t3 pair --tailscale +``` + +This publishes the server over Tailscale Serve HTTPS (configuring the mapping if needed — it persists until you run `tailscale serve --https=443 off`) and pairs through the `https://machine.tailnet.ts.net/` URL. Use `--tailscale-serve-port` for a different HTTPS port, `--ttl` to change the token lifetime, and `--base-dir` to target a specific data directory. + +If no server is running, `t3 pair` says so and points you at `npx t3 serve` or `npx t3 connect`. + ## Recommended Setup Use a trusted private network that meshes your devices together, such as a tailnet. diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/LICENSE b/native/libghostty-vt/LICENSE similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/LICENSE rename to native/libghostty-vt/LICENSE diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION b/native/libghostty-vt/VERSION similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/VERSION rename to native/libghostty-vt/VERSION diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt.h b/native/libghostty-vt/include/ghostty/vt.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt.h rename to native/libghostty-vt/include/ghostty/vt.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/allocator.h b/native/libghostty-vt/include/ghostty/vt/allocator.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/allocator.h rename to native/libghostty-vt/include/ghostty/vt/allocator.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/build_info.h b/native/libghostty-vt/include/ghostty/vt/build_info.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/build_info.h rename to native/libghostty-vt/include/ghostty/vt/build_info.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/color.h b/native/libghostty-vt/include/ghostty/vt/color.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/color.h rename to native/libghostty-vt/include/ghostty/vt/color.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/device.h b/native/libghostty-vt/include/ghostty/vt/device.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/device.h rename to native/libghostty-vt/include/ghostty/vt/device.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/focus.h b/native/libghostty-vt/include/ghostty/vt/focus.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/focus.h rename to native/libghostty-vt/include/ghostty/vt/focus.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/formatter.h b/native/libghostty-vt/include/ghostty/vt/formatter.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/formatter.h rename to native/libghostty-vt/include/ghostty/vt/formatter.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref.h b/native/libghostty-vt/include/ghostty/vt/grid_ref.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref.h rename to native/libghostty-vt/include/ghostty/vt/grid_ref.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref_tracked.h b/native/libghostty-vt/include/ghostty/vt/grid_ref_tracked.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/grid_ref_tracked.h rename to native/libghostty-vt/include/ghostty/vt/grid_ref_tracked.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key.h b/native/libghostty-vt/include/ghostty/vt/key.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key.h rename to native/libghostty-vt/include/ghostty/vt/key.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/encoder.h b/native/libghostty-vt/include/ghostty/vt/key/encoder.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/encoder.h rename to native/libghostty-vt/include/ghostty/vt/key/encoder.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/event.h b/native/libghostty-vt/include/ghostty/vt/key/event.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/key/event.h rename to native/libghostty-vt/include/ghostty/vt/key/event.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h b/native/libghostty-vt/include/ghostty/vt/kitty_graphics.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h rename to native/libghostty-vt/include/ghostty/vt/kitty_graphics.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/modes.h b/native/libghostty-vt/include/ghostty/vt/modes.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/modes.h rename to native/libghostty-vt/include/ghostty/vt/modes.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse.h b/native/libghostty-vt/include/ghostty/vt/mouse.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse.h rename to native/libghostty-vt/include/ghostty/vt/mouse.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/encoder.h b/native/libghostty-vt/include/ghostty/vt/mouse/encoder.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/encoder.h rename to native/libghostty-vt/include/ghostty/vt/mouse/encoder.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/event.h b/native/libghostty-vt/include/ghostty/vt/mouse/event.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/mouse/event.h rename to native/libghostty-vt/include/ghostty/vt/mouse/event.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/osc.h b/native/libghostty-vt/include/ghostty/vt/osc.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/osc.h rename to native/libghostty-vt/include/ghostty/vt/osc.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/paste.h b/native/libghostty-vt/include/ghostty/vt/paste.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/paste.h rename to native/libghostty-vt/include/ghostty/vt/paste.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/point.h b/native/libghostty-vt/include/ghostty/vt/point.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/point.h rename to native/libghostty-vt/include/ghostty/vt/point.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/render.h b/native/libghostty-vt/include/ghostty/vt/render.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/render.h rename to native/libghostty-vt/include/ghostty/vt/render.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/screen.h b/native/libghostty-vt/include/ghostty/vt/screen.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/screen.h rename to native/libghostty-vt/include/ghostty/vt/screen.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/selection.h b/native/libghostty-vt/include/ghostty/vt/selection.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/selection.h rename to native/libghostty-vt/include/ghostty/vt/selection.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sgr.h b/native/libghostty-vt/include/ghostty/vt/sgr.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sgr.h rename to native/libghostty-vt/include/ghostty/vt/sgr.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/size_report.h b/native/libghostty-vt/include/ghostty/vt/size_report.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/size_report.h rename to native/libghostty-vt/include/ghostty/vt/size_report.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/style.h b/native/libghostty-vt/include/ghostty/vt/style.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/style.h rename to native/libghostty-vt/include/ghostty/vt/style.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sys.h b/native/libghostty-vt/include/ghostty/vt/sys.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/sys.h rename to native/libghostty-vt/include/ghostty/vt/sys.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/terminal.h b/native/libghostty-vt/include/ghostty/vt/terminal.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/terminal.h rename to native/libghostty-vt/include/ghostty/vt/terminal.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/types.h b/native/libghostty-vt/include/ghostty/vt/types.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/types.h rename to native/libghostty-vt/include/ghostty/vt/types.h diff --git a/apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/wasm.h b/native/libghostty-vt/include/ghostty/vt/wasm.h similarity index 100% rename from apps/mobile/modules/t3-terminal/Vendor/libghostty-vt/include/ghostty/vt/wasm.h rename to native/libghostty-vt/include/ghostty/vt/wasm.h diff --git a/oxlint-plugin-t3code/test/utils.ts b/oxlint-plugin-t3code/test/utils.ts index eb91d32d7d46..740da8a4f2ba 100644 --- a/oxlint-plugin-t3code/test/utils.ts +++ b/oxlint-plugin-t3code/test/utils.ts @@ -9,6 +9,17 @@ import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as NodeModule from "node:module"; + +// oxlint is only a transitive dependency (via vite-plus), so its bin placement +// varies by package manager: pnpm hoists it into the virtual store, while +// other layouts expose vite-plus's LSP-only wrapper instead. Resolve the real +// package through vite-plus rather than hardcoding either layout, and do it at +// module scope so a broken install fails once with a resolution error instead +// of as an opaque defect in every test. +const oxlintPackageJsonPath = NodeModule.createRequire( + NodeModule.createRequire(import.meta.url).resolve("vite-plus/package.json"), +).resolve("oxlint/package.json"); class OxlintFixtureFailure extends Data.TaggedError("OxlintFixtureFailure")<{ readonly exitCode: number; @@ -93,14 +104,7 @@ export const createOxlintRuleHarness = ( const configPath = path.join(fixtureDir, ".oxlintrc.json"); const sourcePath = path.join(fixtureDir, options.filename ?? "fixture.ts"); const repoRoot = path.join(import.meta.dirname, "..", ".."); - const oxlintBin = path.join( - repoRoot, - "node_modules", - ".pnpm", - "node_modules", - ".bin", - "oxlint", - ); + const oxlintBin = path.join(path.dirname(oxlintPackageJsonPath), "bin", "oxlint"); const pluginPath = path.join(repoRoot, "oxlint-plugin-t3code", "index.ts"); yield* fs.writeFileString( @@ -112,8 +116,13 @@ export const createOxlintRuleHarness = ( ); yield* fs.writeFileString(sourcePath, source); + // Run through the current Node binary: oxlint's bin is an extensionless + // shebang script, which Windows cannot spawn directly and which would + // otherwise pick up whatever node is first on PATH. const output = yield* spawnAndCollectOutput( - ChildProcess.make(oxlintBin, ["--config", configPath, sourcePath], { cwd: repoRoot }), + ChildProcess.make(process.execPath, [oxlintBin, "--config", configPath, sourcePath], { + cwd: repoRoot, + }), ); if (output.exitCode !== 0) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5c6c87895ca..85afdd75d7a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -570,12 +570,6 @@ importers: '@tanstack/react-router': specifier: ^1.160.2 version: 1.170.10(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@xterm/addon-fit': - specifier: ^0.11.0 - version: 0.11.0 - '@xterm/xterm': - specifier: ^6.0.0 - version: 6.0.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -5156,12 +5150,6 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} - '@xterm/addon-fit@0.11.0': - resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} - - '@xterm/xterm@6.0.0': - resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} - '@yuuang/ffi-rs-android-arm64@1.3.2': resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==} engines: {node: '>= 12'} @@ -15283,10 +15271,6 @@ snapshots: '@xmldom/xmldom@0.9.10': {} - '@xterm/addon-fit@0.11.0': {} - - '@xterm/xterm@6.0.0': {} - '@yuuang/ffi-rs-android-arm64@1.3.2': optional: true diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index 9c04c7e9dd19..f78546753515 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off globalDate:off - This host-side fixture creates an isolated local T3 environment. +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off - This host-side fixture creates an isolated local T3 environment. import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; @@ -387,6 +387,48 @@ function insertThread( .run(input.id, isWorking ? "running" : "ready", isWorking ? turnId : null, updatedAt); } +const SEEDED_PROJECTION_TABLES = [ + "projection_pending_approvals", + "projection_thread_proposed_plans", + "projection_thread_activities", + "projection_thread_messages", + "projection_thread_sessions", + "projection_turns", + "projection_threads", + "projection_projects", + "projection_state", +] as const; + +function hasSeedableSchema(dbPath: string): boolean { + let database: NodeSqlite.DatabaseSync; + try { + database = new NodeSqlite.DatabaseSync(dbPath, { readOnly: true }); + } catch { + return false; + } + try { + const row = database + .prepare( + `SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name IN (${SEEDED_PROJECTION_TABLES.map(() => "?").join(", ")})`, + ) + .get(...SEEDED_PROJECTION_TABLES) as { count: number }; + return row.count === SEEDED_PROJECTION_TABLES.length; + } catch { + return false; + } finally { + database.close(); + } +} + +async function waitForSeedableSchema(dbPath: string, timeoutMs = 60_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (hasSeedableSchema(dbPath)) return; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`The environment server did not migrate ${dbPath} within ${timeoutMs}ms.`); +} + function seedDatabase( dbPath: string, workspaceRoots: ReadonlyMap, @@ -401,17 +443,7 @@ function seedDatabase( const database = new NodeSqlite.DatabaseSync(dbPath, { timeout: 30_000 }); try { database.exec("BEGIN IMMEDIATE"); - for (const table of [ - "projection_pending_approvals", - "projection_thread_proposed_plans", - "projection_thread_activities", - "projection_thread_messages", - "projection_thread_sessions", - "projection_turns", - "projection_threads", - "projection_projects", - "projection_state", - ]) { + for (const table of SEEDED_PROJECTION_TABLES) { database.exec(`DELETE FROM ${table}`); } const insertProject = database.prepare( @@ -594,6 +626,9 @@ export async function seedShowcaseEnvironment(input: { }); }), ); + // The environment server begins listening before it finishes migrating the + // database, so wait for the schema before deleting from and reseeding it. + await waitForSeedableSchema(dbPath); seedDatabase(dbPath, workspaceRoots, projects, threads, now); const terminalDirectory = NodePath.join(input.baseDir, "userdata", "logs", "terminals"); diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts index 3237933bec8a..7f1123968385 100644 --- a/scripts/mobile-showcase.config.ts +++ b/scripts/mobile-showcase.config.ts @@ -25,6 +25,8 @@ export interface ShowcaseIosDevice { readonly simulatorDeviceType?: string; /** Appearance used when the CLI does not pass --appearance. */ readonly appearance: ShowcaseAppearance; + /** Orientation applied by the capture harness. Defaults to portrait. */ + readonly orientation?: "portrait" | "landscape"; readonly scenes: ReadonlyArray; readonly storeAsset: ShowcaseStoreAssetSpec; } @@ -122,12 +124,13 @@ const config: ShowcaseConfig = { simulator: "iPad Pro 13-inch (M5)", simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB", appearance: "dark", + orientation: "landscape", scenes: ["thread", "terminal", "review", "threads", "environments"], storeAsset: { store: "apple", directory: "apple/ipad-13", - width: 2064, - height: 2752, + width: 2752, + height: 2064, minimumUploadCount: 1, maximumUploadCount: 10, }, diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index a0deadbef651..16fb3e230bb2 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -216,17 +216,18 @@ it("configures every default device with an exact upload-ready store target", () assert.deepStrictEqual( showcaseConfig.devices.map((device) => [ device.id, + device.platform === "ios" ? (device.orientation ?? "portrait") : null, device.storeAsset.directory, device.storeAsset.width, device.storeAsset.height, ]), [ - ["iphone-6.9", "apple/iphone-6.9", 1320, 2868], - ["iphone-6.5", "apple/iphone-6.5", 1284, 2778], - ["ipad-13", "apple/ipad-13", 2064, 2752], - ["pixel", "google-play/phone", 1080, 1920], - ["android-tablet-7", "google-play/tablet-7", 1080, 1920], - ["android-tablet-10", "google-play/tablet-10", 1440, 2560], + ["iphone-6.9", "portrait", "apple/iphone-6.9", 1320, 2868], + ["iphone-6.5", "portrait", "apple/iphone-6.5", 1284, 2778], + ["ipad-13", "landscape", "apple/ipad-13", 2752, 2064], + ["pixel", null, "google-play/phone", 1080, 1920], + ["android-tablet-7", null, "google-play/tablet-7", 1080, 1920], + ["android-tablet-10", null, "google-play/tablet-10", 1440, 2560], ], ); }); diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 35d6ffd5202f..d9991f423612 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -60,6 +60,9 @@ const MOBILE_BUILD_ENV = { ANDROID_HOME: ANDROID_SDK_ROOT, APP_VARIANT: "production", EXPO_NO_GIT_STATUS: "1", + // Lets the capture build require full screen on iPad so the app can rotate + // itself to landscape (see app.config.ts). + T3_SHOWCASE_CAPTURE_BUILD: "1", JAVA_HOME: NodeProcess.env.JAVA_HOME ?? (NodeProcess.platform === "darwin" @@ -500,6 +503,23 @@ async function waitForPort(port: number, label = "Process", timeoutMs = 60_000): throw new Error(`${label} did not begin listening on port ${port} within ${timeoutMs}ms.`); } +async function waitForFileContent( + filePath: string, + label: string, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const content = await NodeFSP.readFile(filePath, "utf8").then( + (value) => value.trim(), + () => "", + ); + if (content) return content; + await delay(250); + } + throw new Error(`${label} was not written to ${filePath} within ${timeoutMs}ms.`); +} + async function reserveAvailablePort(): Promise { return await new Promise((resolve, reject) => { const server = NodeNet.createServer(); @@ -775,6 +795,60 @@ async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: strin ]); } +// iPadOS 26 windowing ("Chamois") runs UIRequiresFullScreen apps in a fixed +// portrait compatibility window, which defeats the in-app landscape rotation +// the capture build relies on. Switch the device to Full Screen Apps mode +// (Settings > Multitasking & Gestures) and restart SpringBoard to apply it. +async function ensureIosFullScreenAppsMode(udid: string): Promise { + const current = await commandOutput("xcrun", [ + "simctl", + "spawn", + udid, + "defaults", + "read", + "com.apple.springboard", + "SBChamoisWindowingEnabled", + ]).catch(() => ""); + if (current.trim() === "0") return; + // The Settings toggle writes all three keys; SBChamoisWindowingEnabled + // alone is not honored on a freshly created device. + for (const key of [ + "SBChamoisWindowingEnabled", + "SBMedusaMultitaskingEnabled", + "SBFlexibleWindowingPreviouslyEnabledAutomaticStageCreation", + ]) { + await runCommand("xcrun", [ + "simctl", + "spawn", + udid, + "defaults", + "write", + "com.apple.springboard", + key, + "-bool", + "false", + ]); + } + // A SpringBoard restart is not enough on a freshly created simulator (the + // first CI run captured with windowing still active), so reboot the device + // and verify the mode actually stuck. + await runCommand("xcrun", ["simctl", "shutdown", udid]); + await runCommand("xcrun", ["simctl", "boot", udid]); + await runCommand("xcrun", ["simctl", "bootstatus", udid, "-b"]); + const applied = await commandOutput("xcrun", [ + "simctl", + "spawn", + udid, + "defaults", + "read", + "com.apple.springboard", + "SBChamoisWindowingEnabled", + ]).catch(() => ""); + if (applied.trim() !== "0") { + throw new Error(`Simulator ${udid} did not switch to Full Screen Apps mode.`); + } +} + async function iosAppContainer(udid: string): Promise { return ( await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"]) @@ -819,6 +893,9 @@ async function captureIos( } await runCommand("xcrun", ["simctl", "boot", simulator.udid]); await runCommand("xcrun", ["simctl", "bootstatus", simulator.udid, "-b"]); + if (capture.device.orientation === "landscape") { + await ensureIosFullScreenAppsMode(simulator.udid); + } await normalizeIosSimulator(capture.appearance, simulator.udid); if (appPath) { await runCommand("xcrun", ["simctl", "uninstall", simulator.udid, ANDROID_PACKAGE]).catch( @@ -869,6 +946,10 @@ async function captureIos( JSON.stringify(pairingUrls), "--showcaseScene", firstScene, + // The app rotates itself; Simulator menu UI scripting needs macOS + // Accessibility permission that CI runners do not grant to osascript. + "--showcaseOrientation", + capture.device.orientation ?? "portrait", ]); }; await NodeFSP.rm(readyPath, { force: true }); @@ -900,6 +981,15 @@ async function captureIos( `${scene}.png`, ); await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]); + if (capture.device.orientation === "landscape") { + // A headless simulator keeps its display portrait while the rotated app + // renders sideways inside it; with Simulator.app attached the display + // itself rotates. Only post-rotate the former. + const { width, height } = readPngDimensions(await NodeFSP.readFile(destination)); + if (height > width) { + await runCommand("sips", ["--rotate", "270", destination]); + } + } await finalizeCapture(destination, capture.device); } } @@ -1225,12 +1315,12 @@ async function main(): Promise { showcaseServers.push(server); await waitForPort(port, `${environment.label} server`); await seedShowcaseEnvironment({ baseDir, projectIds: environment.projectIds }); - const environmentId = ( - await NodeFSP.readFile(NodePath.join(baseDir, "userdata", "environment-id"), "utf8") - ).trim(); - if (!environmentId) { - throw new Error(`${environment.label} did not persist an environment id.`); - } + // The server begins listening before the ServerEnvironment layer + // persists the environment id, so poll rather than read once. + const environmentId = await waitForFileContent( + NodePath.join(baseDir, "userdata", "environment-id"), + `${environment.label} environment id`, + ); showcaseEnvironments.push({ baseDir, environmentId, label: environment.label, port }); }