diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 92e30000427..df07459cd11 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -57,11 +57,11 @@ 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") ?? "", - "connect-src 'self' http: https: ws: wss:", + "connect-src 'self' data: http: https: ws: wss:", ); assert.include( response.headers.get("content-security-policy") ?? "", @@ -212,10 +212,18 @@ describe("ElectronProtocol", () => { assert.deepEqual(directives["script-src"], [ "'self'", "'unsafe-inline'", + "'wasm-unsafe-eval'", "https://clerk.t3.codes", "https://challenges.cloudflare.com", ]); - assert.deepEqual(directives["connect-src"], ["'self'", "http:", "https:", "ws:", "wss:"]); + assert.deepEqual(directives["connect-src"], [ + "'self'", + "data:", + "http:", + "https:", + "ws:", + "wss:", + ]); assert.deepEqual(directives["img-src"], [ "'self'", "t3code:", diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 03c7ef64fd7..fedecbed127 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -71,6 +71,8 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat const scriptSources = [ "'self'", "'unsafe-inline'", + // Required to compile the ghostty-web terminal's WebAssembly module. + "'wasm-unsafe-eval'", ...(clerkOrigin ? [clerkOrigin] : []), "https://challenges.cloudflare.com", ]; @@ -79,7 +81,8 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat // the build-configured Clerk, relay, and OTLP endpoints. Those environment // origins are not known when this response policy is created, so restrict // connections by the network schemes the client supports instead of by host. - const connectSources = ["'self'", "http:", "https:", "ws:", "wss:"]; + // data: allows ghostty-web to fetch its embedded WebAssembly payload. + const connectSources = ["'self'", "data:", "http:", "https:", "ws:", "wss:"]; return [ "default-src 'self'", diff --git a/apps/server/src/terminal/ghosttyStyle.test.ts b/apps/server/src/terminal/ghosttyStyle.test.ts new file mode 100644 index 00000000000..568d00d1461 --- /dev/null +++ b/apps/server/src/terminal/ghosttyStyle.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + ghosttyThemeSearchPaths, + parseGhosttyConfig, + splitThemeSelection, +} from "./ghosttyStyle.ts"; + +const posixPath = { + isAbsolute: (value: string) => value.startsWith("/"), + join: (...segments: ReadonlyArray) => + segments + .map((segment, index) => + index === 0 ? segment.replace(/\/$/, "") : segment.replace(/^\//, ""), + ) + .join("/"), +}; + +const windowsPath = { + isAbsolute: (value: string) => /^[A-Za-z]:[\\/]/.test(value), + join: (...segments: ReadonlyArray) => segments.join("\\"), +}; + +describe("parseGhosttyConfig", () => { + it("resets scalar and palette colors with empty assignments", () => { + const config = parseGhosttyConfig(` + background = #000000 + foreground = #ffffff + palette = 0=#111111 + palette = 1=#222222 + background = + foreground = "" + palette = 0= + cursor-color = #c0ffee + `); + + expect(config.colors.background).toBeUndefined(); + expect(config.colors.foreground).toBeUndefined(); + expect(config.colors.cursor).toBe("#c0ffee"); + expect(config.colors.palette[0]).toBe(""); + expect(config.colors.palette[1]).toBe("#222222"); + }); +}); + +describe("splitThemeSelection", () => { + it("keeps a Windows absolute theme path as a bare selection", () => { + expect(splitThemeSelection("C:/Users/Alex/Ghostty Themes/t3code")).toEqual({ + light: "C:/Users/Alex/Ghostty Themes/t3code", + dark: "C:/Users/Alex/Ghostty Themes/t3code", + }); + }); + + it("still parses explicit light and dark theme selections", () => { + expect(splitThemeSelection("light:Day,dark:Night")).toEqual({ + light: "Day", + dark: "Night", + }); + }); +}); + +describe("ghosttyThemeSearchPaths", () => { + it("searches beside the macOS Application Support config", () => { + const candidates = ghosttyThemeSearchPaths(posixPath, { + home: "/Users/alex", + xdgConfigHome: "/Users/alex/.config", + themeName: "t3code", + }); + + expect(candidates).toContain( + "/Users/alex/Library/Application Support/com.mitchellh.ghostty/themes/t3code", + ); + }); + + it("tries an absolute theme file before named-theme directories", () => { + const candidates = ghosttyThemeSearchPaths(windowsPath, { + home: "C:\\Users\\alex", + xdgConfigHome: "C:\\Users\\alex\\.config", + themeName: "D:\\themes\\t3code", + }); + + expect(candidates[0]).toBe("D:\\themes\\t3code"); + }); +}); diff --git a/apps/server/src/terminal/ghosttyStyle.ts b/apps/server/src/terminal/ghosttyStyle.ts new file mode 100644 index 00000000000..22c957ba36f --- /dev/null +++ b/apps/server/src/terminal/ghosttyStyle.ts @@ -0,0 +1,290 @@ +import * as NodeOS from "node:os"; + +import type { ServerTerminalStyle, ServerTerminalThemeColors } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +const PALETTE_SIZE = 16; + +interface GhosttyConfigValues { + readonly fontFamily: ReadonlyArray; + readonly fontSize: number | undefined; + readonly theme: string | undefined; + readonly colors: MutableThemeColors; +} + +interface MutableThemeColors { + background?: string | undefined; + foreground?: string | undefined; + cursor?: string | undefined; + selectionBackground?: string | undefined; + selectionForeground?: string | undefined; + palette: Array; +} + +function emptyColors(): MutableThemeColors { + return { palette: Array.from({ length: PALETTE_SIZE }, () => "") }; +} + +function stripQuotes(value: string): string { + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { + return value.slice(1, -1); + } + return value; +} + +function applyColorEntry(colors: MutableThemeColors, key: string, value: string): void { + switch (key) { + case "background": + if (value.length === 0) delete colors.background; + else colors.background = value; + return; + case "foreground": + if (value.length === 0) delete colors.foreground; + else colors.foreground = value; + return; + case "cursor-color": + if (value.length === 0) delete colors.cursor; + else colors.cursor = value; + return; + case "selection-background": + if (value.length === 0) delete colors.selectionBackground; + else colors.selectionBackground = value; + return; + case "selection-foreground": + if (value.length === 0) delete colors.selectionForeground; + else colors.selectionForeground = value; + return; + case "palette": { + const match = value.match(/^(\d+)\s*=\s*(\S*)$/); + if (!match) return; + const index = Number(match[1]); + if (!Number.isInteger(index) || index < 0 || index >= PALETTE_SIZE) return; + colors.palette[index] = match[2] ?? ""; + return; + } + default: + return; + } +} + +/** Parse Ghostty's `key = value` config format, keeping only what the web terminal uses. */ +export function parseGhosttyConfig(source: string): GhosttyConfigValues { + const fontFamily: Array = []; + let fontSize: number | undefined; + let theme: string | undefined; + const colors = emptyColors(); + + for (const rawLine of source.split("\n")) { + const line = rawLine.trim(); + if (line.length === 0 || line.startsWith("#")) continue; + const separatorIndex = line.indexOf("="); + if (separatorIndex <= 0) continue; + const key = line.slice(0, separatorIndex).trim(); + const value = stripQuotes(line.slice(separatorIndex + 1).trim()); + + switch (key) { + case "font-family": + // Repeated entries build a fallback chain; an empty value resets it. + if (value.length === 0) { + fontFamily.length = 0; + } else { + fontFamily.push(value); + } + break; + case "font-size": { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) { + fontSize = parsed; + } + break; + } + case "theme": + theme = value.length > 0 ? value : undefined; + break; + default: + applyColorEntry(colors, key, value); + break; + } + } + + return { fontFamily, fontSize, theme, colors }; +} + +/** Split `theme = light:A,dark:B` into per-mode names; a bare name applies to both. */ +export function splitThemeSelection(theme: string): { light?: string; dark?: string } { + const result: { light?: string; dark?: string } = {}; + let hasModeSelection = false; + for (const part of theme.split(",")) { + const match = /^\s*(light|dark):(.*)$/.exec(part); + if (!match) continue; + const mode = match[1]; + const name = (match[2] ?? "").trim(); + if (name.length === 0) continue; + hasModeSelection = true; + if (mode === "light") result.light = name; + if (mode === "dark") result.dark = name; + } + // A colon is also part of a Windows drive path (C:/theme), and may be part + // of another valid bare theme identifier. Only enter per-mode parsing when + // an explicit light: or dark: selection was recognized. + return hasModeSelection ? result : { light: theme, dark: theme }; +} + +function hasAnyColor(colors: MutableThemeColors): boolean { + return ( + colors.background !== undefined || + colors.foreground !== undefined || + colors.cursor !== undefined || + colors.selectionBackground !== undefined || + colors.selectionForeground !== undefined || + colors.palette.some((entry) => entry.length > 0) + ); +} + +function mergeColors(base: MutableThemeColors, override: MutableThemeColors): MutableThemeColors { + return { + background: override.background ?? base.background, + foreground: override.foreground ?? base.foreground, + cursor: override.cursor ?? base.cursor, + selectionBackground: override.selectionBackground ?? base.selectionBackground, + selectionForeground: override.selectionForeground ?? base.selectionForeground, + palette: base.palette.map((entry, index) => { + const overrideEntry = override.palette[index] ?? ""; + return overrideEntry.length > 0 ? overrideEntry : entry; + }), + }; +} + +function toThemeColors(colors: MutableThemeColors): ServerTerminalThemeColors | undefined { + if (!hasAnyColor(colors)) return undefined; + return { + ...(colors.background !== undefined ? { background: colors.background } : {}), + ...(colors.foreground !== undefined ? { foreground: colors.foreground } : {}), + ...(colors.cursor !== undefined ? { cursor: colors.cursor } : {}), + ...(colors.selectionBackground !== undefined + ? { selectionBackground: colors.selectionBackground } + : {}), + ...(colors.selectionForeground !== undefined + ? { selectionForeground: colors.selectionForeground } + : {}), + palette: colors.palette, + }; +} + +// Bundled theme locations by platform: the macOS app bundle and the +// Linux system resource dirs documented by Ghostty. +const GHOSTTY_SYSTEM_THEME_DIRS = [ + "/Applications/Ghostty.app/Contents/Resources/ghostty/themes", + "/usr/share/ghostty/themes", + "/usr/local/share/ghostty/themes", +]; + +export function ghosttyThemeSearchPaths( + path: Pick, + input: { readonly home: string; readonly xdgConfigHome: string; readonly themeName: string }, +): ReadonlyArray { + return [ + ...(path.isAbsolute(input.themeName) ? [input.themeName] : []), + path.join(input.xdgConfigHome, "ghostty", "themes", input.themeName), + path.join( + input.home, + "Library", + "Application Support", + "com.mitchellh.ghostty", + "themes", + input.themeName, + ), + ...GHOSTTY_SYSTEM_THEME_DIRS.map((dir) => path.join(dir, input.themeName)), + ]; +} + +/** + * Load terminal font and theme colors from the user's local Ghostty config. + * Best effort by design: any missing file or parse problem yields `undefined` + * so the client falls back to its built-in terminal appearance. + */ +export const loadGhosttyTerminalStyle: Effect.Effect< + ServerTerminalStyle | undefined, + never, + FileSystem.FileSystem | Path.Path +> = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = NodeOS.homedir(); + // Per the XDG spec an empty XDG_CONFIG_HOME must be treated as unset. + const xdgConfigHomeEnv = process.env["XDG_CONFIG_HOME"]; + const xdgConfigHome = + xdgConfigHomeEnv !== undefined && xdgConfigHomeEnv.length > 0 + ? xdgConfigHomeEnv + : path.join(home, ".config"); + + const readFirstExisting = (candidates: ReadonlyArray) => + Effect.gen(function* () { + for (const candidate of candidates) { + const exists = yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false)); + if (!exists) continue; + const content = yield* fs + .readFileString(candidate) + .pipe(Effect.orElseSucceed(() => undefined)); + if (content !== undefined) return content; + } + return undefined; + }); + + // Ghostty reads the XDG config first and the macOS Application Support + // config after it, so later files override earlier values. It also accepts + // config.ghostty as the file name. Merge every location that exists instead + // of stopping at the first match. + const configDirs = [ + path.join(xdgConfigHome, "ghostty"), + path.join(home, "Library", "Application Support", "com.mitchellh.ghostty"), + ]; + const configSources: Array = []; + for (const dir of configDirs) { + // Read both file names when they coexist so neither shadows the other; + // Ghostty loads config.ghostty before config, so config wins conflicts. + for (const candidate of [path.join(dir, "config.ghostty"), path.join(dir, "config")]) { + const source = yield* readFirstExisting([candidate]); + if (source !== undefined) configSources.push(source); + } + } + if (configSources.length === 0) return undefined; + + const config = parseGhosttyConfig(configSources.join("\n")); + + const loadThemeColors = (themeName: string) => + Effect.gen(function* () { + const themeSource = yield* readFirstExisting( + ghosttyThemeSearchPaths(path, { home, xdgConfigHome, themeName }), + ); + if (themeSource === undefined) return emptyColors(); + return parseGhosttyConfig(themeSource).colors; + }); + + const themeSelection = config.theme ? splitThemeSelection(config.theme) : {}; + const resolveModeColors = (themeName: string | undefined) => + Effect.gen(function* () { + const themeColors = themeName ? yield* loadThemeColors(themeName) : emptyColors(); + // Explicit colors in the user config override the selected theme. + return toThemeColors(mergeColors(themeColors, config.colors)); + }); + + const light = yield* resolveModeColors(themeSelection.light); + const dark = yield* resolveModeColors(themeSelection.dark); + + const style: ServerTerminalStyle = { + ...(config.fontFamily.length > 0 ? { fontFamily: config.fontFamily } : {}), + ...(config.fontSize !== undefined ? { fontSize: config.fontSize } : {}), + ...(light !== undefined ? { light } : {}), + ...(dark !== undefined ? { dark } : {}), + }; + + const hasAnyValue = + style.fontFamily !== undefined || + style.fontSize !== undefined || + style.light !== undefined || + style.dark !== undefined; + return hasAnyValue ? style : undefined; +}).pipe(Effect.orElseSucceed(() => undefined)); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 08b1770a0a2..18d3c83a860 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -81,6 +81,7 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; +import { loadGhosttyTerminalStyle } from "./terminal/ghosttyStyle.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; @@ -1065,6 +1066,7 @@ const makeWsRpcLayer = ( const loadServerConfig = Effect.gen(function* () { const keybindingsConfig = yield* keybindings.loadConfigState; + const terminalStyle = yield* loadGhosttyTerminalStyle; const providers = yield* providerRegistry.getProviders; const settings = ServerSettings.redactServerSettingsForClient( yield* serverSettings.getSettings, @@ -1081,6 +1083,7 @@ const makeWsRpcLayer = ( issues: keybindingsConfig.issues, providers, availableEditors: yield* externalLauncher.resolveAvailableEditors(), + ...(terminalStyle !== undefined ? { terminalStyle } : {}), observability: { logsDirectoryPath: config.logsDir, localTracingEnabled: true, diff --git a/apps/web/package.json b/apps/web/package.json index 5a1579a478b..38c44abec80 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,10 +31,9 @@ "@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:", + "ghostty-web": "0.4.0", "jose": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index 9f483af3c3b..0d7cb601d6b 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -4,8 +4,45 @@ import { resolveTerminalSelectionActionPosition, shouldHandleTerminalSelectionMouseUp, terminalSelectionActionDelayForClickCount, + writePreservingScrollback, } from "./ThreadTerminalDrawer"; +describe("writePreservingScrollback", () => { + it("restores a scrolled-back viewport with a relative scroll delta", () => { + let scrollbackLength = 100; + const writes: Array = []; + const scrollDeltas: Array = []; + const terminal = { + getScrollbackLength: () => scrollbackLength, + getViewportY: () => 20, + scrollLines: (amount: number) => scrollDeltas.push(amount), + write: (data: string) => { + writes.push(data); + scrollbackLength = 103; + }, + }; + + writePreservingScrollback(terminal, "one\ntwo\nthree\n"); + + expect(writes).toEqual(["one\ntwo\nthree\n"]); + expect(scrollDeltas).toEqual([-23]); + }); + + it("accounts for evicted lines when scrollback is already capped", () => { + const scrollDeltas: Array = []; + const terminal = { + getScrollbackLength: () => 5_000, + getViewportY: () => 20, + scrollLines: (amount: number) => scrollDeltas.push(amount), + write: (_data: string) => undefined, + }; + + writePreservingScrollback(terminal, "one\ntwo\n"); + + expect(scrollDeltas).toEqual([-22]); + }); +}); + describe("resolveTerminalSelectionActionPosition", () => { it("prefers the selection rect over the last pointer position", () => { expect( diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 8591c24c71a..d9517173310 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,5 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; -import { FitAddon } from "@xterm/addon-fit"; +import { FitAddon, Terminal, init as initGhostty, type ITheme } from "ghostty-web"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -15,10 +15,10 @@ import { import { type ResolvedKeybindingsConfig, type ScopedThreadRef, + type ServerTerminalStyle, 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, @@ -35,6 +35,7 @@ import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { useOpenInPreferredEditor } from "../editorPreferences"; +import { shouldFocusTerminalAfterSetup } from "../terminal-focus"; import { collectWrappedTerminalLinkLine, extractTerminalLinks, @@ -71,6 +72,34 @@ const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; +// ghostty-web needs its WASM module loaded once before any Terminal is constructed. +let ghosttyReadyPromise: Promise | null = null; +function ensureGhosttyReady(): Promise { + // Drop a failed init from the cache so a later mount can retry instead of + // reusing the rejected promise until the page reloads. + ghosttyReadyPromise ??= initGhostty().catch((error: unknown) => { + ghosttyReadyPromise = null; + throw error; + }); + return ghosttyReadyPromise; +} + +// ghostty-web registers built-in OSC8/URL link providers on open() whose activate() +// calls window.open directly, bypassing the app's preview/editor flow. There is no +// public API to remove them, so clear the provider list before installing ours, +// which covers the same URL schemes and routes them through the app. +function clearBuiltInLinkProviders(terminal: Terminal): void { + const detector = (terminal as unknown as { linkDetector?: { providers?: unknown[] } }) + .linkDetector; + detector?.providers?.splice(0); +} + +function isScrolledToBottom(terminal: Terminal): boolean { + // ghostty-web viewportY counts lines scrolled back from the bottom (0 = bottom), + // unlike xterm.js where viewportY >= baseY meant "at bottom". + return terminal.getViewportY() <= 0; +} + function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO)); @@ -93,6 +122,43 @@ function writeTerminalBuffer(terminal: Terminal, buffer: string): void { } } +const TERMINAL_SCROLLBACK_LIMIT = 5_000; + +interface ScrollbackTerminal { + getScrollbackLength(): number; + getViewportY(): number; + scrollLines(amount: number): void; + write(data: string): void; +} + +// ghostty-web snaps the viewport to the bottom on every write, unlike +// xterm.js which keeps a scrolled-back viewport anchored. Restore the anchor +// by advancing viewportY by however many lines the write pushed into +// scrollback (viewportY counts lines back from the bottom). +export function writePreservingScrollback(terminal: ScrollbackTerminal, data: string): void { + const viewportY = terminal.getViewportY(); + if (viewportY <= 0) { + terminal.write(data); + return; + } + const scrollbackBefore = terminal.getScrollbackLength(); + terminal.write(data); + const scrollbackAfter = terminal.getScrollbackLength(); + const measuredGrowth = scrollbackAfter - scrollbackBefore; + // At the scrollback limit the length stops growing even though each new + // line still evicts the oldest, so measured growth alone would let the + // anchor drift toward the live tail. Fall back to counting written + // newlines — approximate (ignores soft wraps) but drift-free. + const scrollbackAdded = + scrollbackAfter >= TERMINAL_SCROLLBACK_LIMIT + ? Math.max(measuredGrowth, data.split("\n").length - 1) + : measuredGrowth; + // scrollToLine() is documented in absolute buffer coordinates, while + // scrollLines() explicitly accepts a relative delta. The write reset us to + // the bottom, so a negative delta restores the bottom-relative anchor. + terminal.scrollLines(-(Math.round(viewportY) + scrollbackAdded)); +} + function fitTerminalSafely(fitAddon: FitAddon): boolean { try { fitAddon.fit(); @@ -149,9 +215,6 @@ function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { 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)", @@ -176,9 +239,6 @@ function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { 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)", @@ -198,6 +258,88 @@ function terminalThemeFromApp(mountElement?: HTMLElement | null): ITheme { }; } +const TERMINAL_PALETTE_KEYS = [ + "black", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "white", + "brightBlack", + "brightRed", + "brightGreen", + "brightYellow", + "brightBlue", + "brightMagenta", + "brightCyan", + "brightWhite", +] as const; + +// Nerd Font fallbacks resolve private-use-area glyphs (powerline segments, +// devicons) from whichever patched font is installed locally. Ghostty embeds +// its own symbols fallback, but the browser canvas only walks this list. +const NERD_FONT_FALLBACK_STACK = + '"Symbols Nerd Font Mono", "Symbols Nerd Font", "MesloLGS NF", "FiraCode Nerd Font Mono", "JetBrainsMono Nerd Font Mono", "Hack Nerd Font Mono"'; +const DEFAULT_TERMINAL_FONT_STACK = `"SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, ${NERD_FONT_FALLBACK_STACK}, monospace`; +const DEFAULT_TERMINAL_FONT_SIZE = 12; + +const CSS_GENERIC_FONT_FAMILIES = new Set([ + "serif", + "sans-serif", + "monospace", + "cursive", + "fantasy", + "system-ui", + "ui-serif", + "ui-sans-serif", + "ui-monospace", + "ui-rounded", +]); + +function quoteFontFamily(family: string): string { + // Generic families must stay unquoted; quoting turns them into literal font + // name lookups (e.g. a font named "monospace") instead of CSS keywords. + if (CSS_GENERIC_FONT_FAMILIES.has(family.toLowerCase())) return family; + return family.includes('"') ? family : `"${family}"`; +} + +function resolveTerminalFontFamily(style: ServerTerminalStyle | undefined): string { + const configured = (style?.fontFamily ?? []).map(quoteFontFamily); + return configured.length > 0 + ? `${configured.join(", ")}, ${DEFAULT_TERMINAL_FONT_STACK}` + : DEFAULT_TERMINAL_FONT_STACK; +} + +/** + * App theme colors, overridden by the user's Ghostty theme when the server + * found one. Ghostty light/dark variants follow the app's active mode. + */ +function resolveTerminalTheme( + mountElement: HTMLElement | null | undefined, + style: ServerTerminalStyle | undefined, +): ITheme { + const base = terminalThemeFromApp(mountElement); + const isDark = document.documentElement.classList.contains("dark"); + const colors = isDark ? (style?.dark ?? style?.light) : (style?.light ?? style?.dark); + if (!colors) return base; + + const theme: ITheme = { ...base }; + if (colors.background) theme.background = colors.background; + if (colors.foreground) theme.foreground = colors.foreground; + if (colors.cursor) theme.cursor = colors.cursor; + if (colors.selectionBackground) theme.selectionBackground = colors.selectionBackground; + if (colors.selectionForeground) theme.selectionForeground = colors.selectionForeground; + colors.palette?.forEach((hex, index) => { + const key = TERMINAL_PALETTE_KEYS[index]; + if (hex && key) { + theme[key] = hex; + } + }); + return theme; +} + function getTerminalSelectionRect(mountElement: HTMLElement): DOMRect | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { @@ -313,6 +455,10 @@ export function TerminalViewport({ const fitAddonRef = useRef(null); const environmentId = threadRef.environmentId; const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const terminalStyle = serverConfig?.terminalStyle; + const terminalStyleKey = JSON.stringify(terminalStyle ?? null); + const readTerminalStyle = useEffectEvent(() => terminalStyle); + const readThreadRef = useEffectEvent(() => threadRef); const openInPreferredEditor = useOpenInPreferredEditor( environmentId, serverConfig?.availableEditors ?? [], @@ -328,6 +474,13 @@ export function TerminalViewport({ reportFailure: false, }); const hasHandledExitRef = useRef(false); + const hasMountedTerminalRef = useRef(false); + const restoreTerminalFocusOnSetupRef = useRef(false); + const focusOnInitialSessionSyncRef = useRef(false); + // Bumped when the async WASM gate finishes creating a terminal, so the + // session sync effect re-runs against the live terminal even when no + // session field changed while the WASM module was loading. + const [terminalEpoch, setTerminalEpoch] = useState(0); const selectionPointerRef = useRef<{ x: number; y: number } | null>(null); const selectionGestureActiveRef = useRef(false); const selectionActionRequestIdRef = useRef(0); @@ -342,6 +495,7 @@ export function TerminalViewport({ onAddTerminalContext(selection); }); const readTerminalLabel = useEffectEvent(() => terminalLabel); + const readAutoFocus = useEffectEvent(() => autoFocus); const terminalSession = useAttachedTerminalSession({ environmentId, terminal: { @@ -374,367 +528,466 @@ export function TerminalViewport({ status: terminalStatus, version: terminalVersion, }); + const readSessionSnapshot = useEffectEvent(() => ({ + buffer: terminalBuffer, + error: terminalError, + status: terminalStatus, + version: terminalVersion, + })); useEffect(() => { keybindingsRef.current = keybindings; }, [keybindings]); useEffect(() => { - const mount = containerRef.current; - 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 mountElement = containerRef.current; + if (!mountElement) return; + + let effectDisposed = false; + let teardown: (() => void) | undefined; + + const setUpTerminal = (mount: HTMLDivElement): (() => void) => { + // Clear any failure message left by an earlier init attempt. + mount.textContent = ""; + const localApi = readLocalApi(); + + const style = readTerminalStyle(); + const initialTheme = resolveTerminalTheme(mount, style); + // The canvas is sized to whole cells, so paint the leftover fringe of the + // container in the terminal background color instead of the app surface. + const applyMountBackground = (theme: ITheme) => { + mount.style.backgroundColor = theme.background ?? ""; + }; + applyMountBackground(initialTheme); + const fitAddon = new FitAddon(); + const previouslyFocusedElement = + document.activeElement instanceof HTMLElement && document.activeElement !== document.body + ? document.activeElement + : null; + const shouldFocusAfterSetup = shouldFocusTerminalAfterSetup({ + autoFocus: readAutoFocus(), + hasMounted: hasMountedTerminalRef.current, + restorePreviousTerminalFocus: restoreTerminalFocusOnSetupRef.current, + }); + restoreTerminalFocusOnSetupRef.current = false; + hasMountedTerminalRef.current = true; + focusOnInitialSessionSyncRef.current = shouldFocusAfterSetup; + const terminal = new Terminal({ + cursorBlink: true, + // Ghostty font-size is in points; treat it as CSS px, which reads + // slightly smaller in the drawer than in Ghostty itself. + fontSize: style?.fontSize ?? DEFAULT_TERMINAL_FONT_SIZE, + scrollback: TERMINAL_SCROLLBACK_LIMIT, + fontFamily: resolveTerminalFontFamily(style), + theme: initialTheme, + }); + terminal.loadAddon(fitAddon); + terminal.open(mount); + clearBuiltInLinkProviders(terminal); + fitTerminalSafely(fitAddon); + // ghostty-web's open() focuses unconditionally; in split view the + // last-mounted terminal would steal focus from the active one. + if (shouldFocusAfterSetup) { + terminal.focus(); + } else { + terminal.blur(); + // open() focuses unconditionally. Restore whatever external control + // (commonly the chat composer) had focus before a style-driven remount. + if (previouslyFocusedElement?.isConnected) { + previouslyFocusedElement.focus({ preventScroll: true }); + } } - }; - 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; + terminalRef.current = terminal; + fitAddonRef.current = fitAddon; + const initialSession = readSessionSnapshot(); + if (initialSession.version > 0 && initialSession.buffer.length > 0) { + writeTerminalBuffer(terminal, initialSession.buffer); } - 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, - }, + // Seed with version 0 and a closed status (keeping the already-written + // buffer) so the sync effect still surfaces an exited/errored session + // that predates the mount instead of early-returning on equal versions. + previousSessionRef.current = { + buffer: initialSession.buffer, + error: null, + status: "closed", + version: 0, }; - }; + setTerminalEpoch((value) => value + 1); - 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 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) { + 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, }); - 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; + } + if (selectionActionMenuOpenRef.current) { return; - case "copy": - try { - await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); - } catch (error) { - if (requestId !== selectionActionRequestIdRef.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; - } + // ghostty-web's custom key handler semantics are inverted from xterm.js: + // returning true means "consumed, do not process as terminal input", + // returning false lets the terminal handle the key normally. + 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 true; + } - 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 true; + } - const deleteData = terminalDeleteShortcutData(event); - if (deleteData !== null) { + const deleteData = terminalDeleteShortcutData(event); + if (deleteData !== null) { + event.preventDefault(); + event.stopPropagation(); + void sendTerminalInput(deleteData, "Failed to delete terminal input"); + return true; + } + + if (!isTerminalClearShortcut(event)) return false; event.preventDefault(); event.stopPropagation(); - void sendTerminalInput(deleteData, "Failed to delete terminal input"); - 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); - return; - } + void sendTerminalInput("\u000c", "Failed to clear terminal"); + return true; + }); - const wrappedLine = collectWrappedTerminalLinkLine(bufferLineNumber, (bufferLineIndex) => - activeTerminal.buffer.active.getLine(bufferLineIndex), - ); - if (!wrappedLine) { - callback(undefined); - return; - } + // ghostty-web rows are 0-based; the terminal-links helpers use xterm's 1-based + // convention, so convert on the way in and out. + terminal.registerLinkProvider({ + provideLinks: (bufferRow, callback) => { + const bufferLineNumber = bufferRow + 1; + const activeTerminal = terminalRef.current; + if (!activeTerminal) { + callback(undefined); + return; + } - const links = extractTerminalLinks(wrappedLine.text) - .map((match) => ({ - match, - range: resolveWrappedTerminalLinkRange(wrappedLine, match), - })) - .filter(({ range }) => - wrappedTerminalLinkRangeIntersectsBufferLine(range, bufferLineNumber), + const wrappedLine = collectWrappedTerminalLinkLine(bufferLineNumber, (bufferLineIndex) => + activeTerminal.buffer.active.getLine(bufferLineIndex), ); - if (links.length === 0) { - callback(undefined); - return; - } - - callback( - links.map(({ match, range }) => ({ - text: match.text, - range, - activate: (event: MouseEvent) => { - if (!isTerminalLinkActivation(event)) return; + if (!wrappedLine) { + callback(undefined); + return; + } - const latestTerminal = terminalRef.current; - if (!latestTerminal) return; + const links = extractTerminalLinks(wrappedLine.text) + .map((match) => ({ + match, + range: resolveWrappedTerminalLinkRange(wrappedLine, match), + })) + .filter(({ range }) => + wrappedTerminalLinkRangeIntersectsBufferLine(range, bufferLineNumber), + ); + if (links.length === 0) { + callback(undefined); + 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) => { + callback( + links.map(({ match, range }) => ({ + text: match.text, + range: { + start: { x: range.start.x - 1, y: range.start.y - 1 }, + end: { x: range.end.x - 1, y: range.end.y - 1 }, + }, + activate: (event: MouseEvent) => { + if (!isTerminalLinkActivation(event)) return; + + const latestTerminal = terminalRef.current; + if (!latestTerminal) return; + + if (match.kind === "url") { + if (!localApi) { writeSystemMessage( latestTerminal, - error instanceof Error ? error.message : "Unable to open link", + "Opening links is unavailable in this browser.", ); + return; + } + const openExternally = () => { + void localApi.shell.openExternal(match.text).catch((error: unknown) => { + writeSystemMessage( + latestTerminal, + error instanceof Error ? error.message : "Unable to open link", + ); + }); + }; + // Only http(s) renders in the preview panel; hand other + // schemes (mailto, ssh, tel, ...) straight to the OS. + if (!/^https?:\/\//i.test(match.text)) { + openExternally(); + return; + } + void openTerminalLinkInPreview({ + url: match.text, + position: { x: event.clientX, y: event.clientY }, + threadRef: readThreadRef(), + openPreview, + localApi, + fallbackToBrowser: openExternally, }); - }; - 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 inputDisposable = terminal.onData((data) => { - void (async () => { - const result = await writeTerminal(data); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { + 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 inputDisposable = terminal.onData((data) => { + 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", + ); + })(); + }); + + const selectionDisposable = terminal.onSelectionChange(() => { + if (terminalRef.current?.hasSelection()) { return; } - const error = squashAtomCommandFailure(result); - writeSystemMessage( - terminal, - error instanceof Error ? error.message : "Terminal write failed", + clearSelectionAction(); + }); + + 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; + }; + window.addEventListener("mouseup", handleMouseUp); + mount.addEventListener("pointerdown", handlePointerDown); - const selectionDisposable = terminal.onSelectionChange(() => { - if (terminalRef.current?.hasSelection()) { - return; - } - clearSelectionAction(); - }); + const themeObserver = new MutationObserver(() => { + const activeTerminal = terminalRef.current; + if (!activeTerminal) return; + // ghostty-web bakes cell colors in WASM at write time, so already-rendered + // content keeps its colors; this only updates default fg/bg/cursor. + const nextTheme = resolveTerminalTheme(containerRef.current, readTerminalStyle()); + applyMountBackground(nextTheme); + activeTerminal.renderer?.setTheme(nextTheme); + }); + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style"], + }); - 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; + const activeFitAddon = fitAddonRef.current; + if (!activeTerminal || !activeFitAddon) return; + const wasAtBottom = isScrolledToBottom(activeTerminal); + fitTerminalSafely(activeFitAddon); + if (wasAtBottom) { + activeTerminal.scrollToBottom(); + } + void resizeTerminal(activeTerminal.cols, activeTerminal.rows); + }, 30); + + return () => { + restoreTerminalFocusOnSetupRef.current = mount.contains(document.activeElement); + window.clearTimeout(fitTimer); + inputDisposable.dispose(); + selectionDisposable.dispose(); + if (selectionActionTimerRef.current !== null) { + window.clearTimeout(selectionActionTimerRef.current); + } + window.removeEventListener("mouseup", handleMouseUp); + mount.removeEventListener("pointerdown", handlePointerDown); + themeObserver.disconnect(); + mount.style.backgroundColor = ""; + terminalRef.current = null; + fitAddonRef.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 ensureGhosttyReady().then( + () => { + if (effectDisposed || containerRef.current !== mountElement) return; + teardown = setUpTerminal(mountElement); + }, + (error: unknown) => { + if (effectDisposed || containerRef.current !== mountElement) return; + console.error("ghostty-web terminal failed to initialize", error); + mountElement.textContent = + "Terminal failed to initialize. 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(); + effectDisposed = 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]); + }, [ + cwd, + environmentId, + openPreview, + runtimeEnvKey, + terminalId, + terminalStyleKey, + threadId, + worktreePath, + ]); useEffect(() => { const terminal = terminalRef.current; @@ -758,7 +1011,12 @@ export function TerminalViewport({ current.buffer.length >= previous.buffer.length && current.buffer.startsWith(previous.buffer) ) { - terminal.write(current.buffer.slice(previous.buffer.length)); + const appended = current.buffer.slice(previous.buffer.length); + // ghostty-web 0.4.0 crashes on zero-length writes (alloc(0) returns an + // out-of-bounds pointer), so skip no-op updates such as status-only bumps. + if (appended.length > 0) { + writePreservingScrollback(terminal, appended); + } } else { writeTerminalBuffer(terminal, current.buffer); } @@ -787,13 +1045,14 @@ export function TerminalViewport({ }, 0); } - if (previous.version === 0 && autoFocus) { + if (previous.version === 0 && autoFocus && focusOnInitialSessionSyncRef.current) { window.requestAnimationFrame(() => { terminal.focus(); }); } + focusOnInitialSessionSyncRef.current = false; previousSessionRef.current = current; - }, [autoFocus, terminalBuffer, terminalError, terminalStatus, terminalVersion]); + }, [autoFocus, terminalBuffer, terminalEpoch, terminalError, terminalStatus, terminalVersion]); useEffect(() => { if (!autoFocus) return; @@ -811,7 +1070,7 @@ export function TerminalViewport({ const terminal = terminalRef.current; const fitAddon = fitAddonRef.current; if (!terminal || !fitAddon) return; - const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; + const wasAtBottom = isScrolledToBottom(terminal); const frame = window.requestAnimationFrame(() => { fitTerminalSafely(fitAddon); if (wasAtBottom) { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index c03cc65f654..7406f960cd3 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-focus.test.ts b/apps/web/src/terminal-focus.test.ts new file mode 100644 index 00000000000..f510ac18e22 --- /dev/null +++ b/apps/web/src/terminal-focus.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { shouldFocusTerminalAfterSetup } from "./terminal-focus"; + +describe("shouldFocusTerminalAfterSetup", () => { + it("focuses the active terminal on its first setup", () => { + expect( + shouldFocusTerminalAfterSetup({ + autoFocus: true, + hasMounted: false, + restorePreviousTerminalFocus: false, + }), + ).toBe(true); + }); + + it("does not steal focus when terminal style causes a remount", () => { + expect( + shouldFocusTerminalAfterSetup({ + autoFocus: true, + hasMounted: true, + restorePreviousTerminalFocus: false, + }), + ).toBe(false); + }); + + it("restores focus when the terminal itself was focused before remount", () => { + expect( + shouldFocusTerminalAfterSetup({ + autoFocus: false, + hasMounted: true, + restorePreviousTerminalFocus: true, + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/terminal-focus.ts b/apps/web/src/terminal-focus.ts new file mode 100644 index 00000000000..2a50b109bdb --- /dev/null +++ b/apps/web/src/terminal-focus.ts @@ -0,0 +1,7 @@ +export function shouldFocusTerminalAfterSetup(input: { + readonly autoFocus: boolean; + readonly hasMounted: boolean; + readonly restorePreviousTerminalFocus: boolean; +}): boolean { + return input.restorePreviousTerminalFocus || (!input.hasMounted && input.autoFocus); +} diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index 0f8e6196240..4622329dda5 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -37,6 +37,24 @@ describe("extractTerminalLinks", () => { ]); }); + it("finds non-http scheme urls", () => { + const line = "email mailto:dev@example.com or clone ssh://git@github.com/t3/t3code.git now"; + expect(extractTerminalLinks(line)).toEqual([ + { + kind: "url", + text: "mailto:dev@example.com", + start: 6, + end: 28, + }, + { + kind: "url", + text: "ssh://git@github.com/t3/t3code.git", + start: 38, + end: 72, + }, + ]); + }); + it("trims trailing punctuation from links", () => { const line = "(https://example.com/docs), ./src/main.ts:12."; expect(extractTerminalLinks(line)).toEqual([ diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index a4eeda4279c..a048f25b868 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -36,7 +36,10 @@ export interface WrappedTerminalLinkLine { segments: ReadonlyArray; } -const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g; +// Also matches the non-http schemes ghostty and other terminals linkify; +// authority-style schemes require // while opaque ones take a bare payload. +const URL_PATTERN = + /(?:https?|ftp|ssh|git|gemini|gopher):\/\/[^\s"'`<>]+|(?:mailto|tel|news|magnet):[^\s"'`<>:][^\s"'`<>]*/g; const FILE_PATH_PATTERN = /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:[\\/]|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g; const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 316f09693ec..7b40abdc873 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -406,6 +406,28 @@ export const ServerSignalProcessResult = Schema.Struct({ }); export type ServerSignalProcessResult = typeof ServerSignalProcessResult.Type; +/** Terminal colors parsed from the user's Ghostty theme (hex strings like "#1e1e2e"). */ +export const ServerTerminalThemeColors = Schema.Struct({ + background: Schema.optional(TrimmedNonEmptyString), + foreground: Schema.optional(TrimmedNonEmptyString), + cursor: Schema.optional(TrimmedNonEmptyString), + selectionBackground: Schema.optional(TrimmedNonEmptyString), + selectionForeground: Schema.optional(TrimmedNonEmptyString), + /** ANSI palette slots 0-15; empty string for slots the theme does not define. */ + palette: Schema.optional(Schema.Array(Schema.String)), +}); +export type ServerTerminalThemeColors = typeof ServerTerminalThemeColors.Type; + +/** Terminal appearance derived from the user's local Ghostty config, when present. */ +export const ServerTerminalStyle = Schema.Struct({ + /** font-family fallback chain in config order. */ + fontFamily: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + fontSize: Schema.optional(Schema.Number), + light: Schema.optional(ServerTerminalThemeColors), + dark: Schema.optional(ServerTerminalThemeColors), +}); +export type ServerTerminalStyle = typeof ServerTerminalStyle.Type; + export const ServerConfig = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, auth: ServerAuthDescriptor, @@ -421,6 +443,8 @@ export const ServerConfig = Schema.Struct({ shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */ threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** Absent when no Ghostty config exists or it cannot be read. */ + terminalStyle: Schema.optional(ServerTerminalStyle), }); export type ServerConfig = typeof ServerConfig.Type; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c58ccffc420..a69e39b24e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,30 +6,12 @@ settings: catalogs: default: - '@effect/openapi-generator': - specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78 - '@effect/tsgo': - specifier: 0.13.2 - version: 0.13.2 - '@noble/curves': - specifier: 1.9.1 - version: 1.9.1 - '@noble/hashes': - specifier: 1.8.0 - version: 1.8.0 '@pierre/diffs': specifier: 1.3.0-beta.5 version: 1.3.0-beta.5 - '@typescript/native-preview': - specifier: 7.0.0-dev.20260604.1 - version: 7.0.0-dev.20260604.1 jose: specifier: 6.2.2 version: 6.2.2 - typescript: - specifier: ~6.0.3 - version: 6.0.3 vite-plus: specifier: 0.2.2 version: 0.2.2 @@ -562,18 +544,15 @@ 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 effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + ghostty-web: + specifier: 0.4.0 + version: 0.4.0 jose: specifier: 'catalog:' version: 6.2.2 @@ -3300,56 +3279,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.57.0': resolution: {integrity: sha512-p4Y/+RYk9Bk5WO+zHSUXAClRmZ2fbJCejMuCAsU2HhyME4jqf6Ftt/mJYEwIah1wGCBDYOB7wEGV1x5bCEZ6hA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.57.0': resolution: {integrity: sha512-By6tRALAZsno0F4zedmtG+wdMvJiJmJoXM4d3+A9zHE4HRXLqXITwRH8mgrlcXc5yJM2g2W3riRPwTYdgemZLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.57.0': resolution: {integrity: sha512-skYeG+RgvyzspqVEBsEprL90OYYZfoVNqB3HcCNR6QDJyXKOzfDRT3zncnHmUaFluIlBHuY23mU1b5WGgR98hA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.57.0': resolution: {integrity: sha512-FFgACrZOXAXUh5KQh2mt1CDOVOZmn+QzHP71wM9QobNwyQvoFfyAeefVUltW83g3sm7LTiH3yfFqLLVUpA5ZFQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.57.0': resolution: {integrity: sha512-Nm/BAOfQeFiiKd502mZn/GAVKJwtd0RdCg17G3Wz/WSOIQmDi3+7/SZH4BHn1Ye5KvTVH3ua8WvfwLLycNIuvA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.57.0': resolution: {integrity: sha512-BiSy5Ku3mQqyxS6YIqAJgd403wEUWvI7kerfzPxc2l/txZVmZM0pSj7oDM+4bGBExowxOi7o73jEam1W0EDTZg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.57.0': resolution: {integrity: sha512-BCRkJiotz5s9afLYD2LuMvzAoDYx9H17E/YbDyu4xK7l4zHDPeny9ErSXL//i/nJyaOwRk08x4b8cgJC00+JDg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxfmt/binding-openharmony-arm64@0.57.0': resolution: {integrity: sha512-4Oaxe1qrGgXfpCJ1C/ERJ2iCtV2rN1R79ga9fsfyVHfSQRu/hVW780u2KDqZWFZ/iGTHODJji0JemxqFZ63eIQ==} @@ -3452,56 +3423,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.72.0': resolution: {integrity: sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.72.0': resolution: {integrity: sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.72.0': resolution: {integrity: sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.72.0': resolution: {integrity: sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.72.0': resolution: {integrity: sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.72.0': resolution: {integrity: sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-musl@1.72.0': resolution: {integrity: sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxlint/binding-openharmony-arm64@1.72.0': resolution: {integrity: sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==} @@ -4037,7 +4000,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-gnu@1.0.1': resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} @@ -4051,14 +4013,12 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-arm64-musl@1.0.1': resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} @@ -4072,14 +4032,12 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-ppc64-gnu@1.0.1': resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} @@ -4093,14 +4051,12 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.1': resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} @@ -4114,14 +4070,12 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.1': resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} @@ -4135,14 +4089,12 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-linux-x64-musl@1.0.1': resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} @@ -4156,7 +4108,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} @@ -4616,7 +4567,6 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} @@ -4630,7 +4580,6 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} @@ -4644,7 +4593,6 @@ packages: engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} @@ -4658,7 +4606,6 @@ packages: engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -5125,28 +5072,24 @@ packages: engines: {node: '>=20.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.2': resolution: {integrity: sha512-YGtvTHT7qP4c5pZmM4kLL78/d8hj2NS150R92cR2SVOW/l9Ilq5R5WrEiMA4k5Ea3B++IJWZT5MRFI0tW9qlcg==} engines: {node: '>=20.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.2': resolution: {integrity: sha512-FvaMI/vsy4PVM+Qd73K+KM8blfCAfaoZaGaGWNrrlMryhyPThXPnHoB1AQcrKbEAWb+z2fc4zLS4sH+8uI65fw==} engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@voidzero-dev/vite-plus-linux-x64-musl@0.2.2': resolution: {integrity: sha512-ZsMochHqXqxj2sGTJNJzz3vabppbe4BFgZAjJfsnVzkwR7jv6c5p1BM71LFWxP4qd5LL0TJT7lbeRhALlI44RQ==} engines: {node: '>=20.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.2': resolution: {integrity: sha512-noBNyJufux0cf18eDpQLOQUZ1Kybfx9zlr+yQ6gAnxMEsQXSvYqZgWymfRDesBE3G/0XB5bg+AUtWijp3TwVcw==} @@ -5194,12 +5137,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'} @@ -7046,6 +6983,9 @@ packages: resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} engines: {node: '>=6'} + ghostty-web@0.4.0: + resolution: {integrity: sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg==} + github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} @@ -7672,7 +7612,6 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} @@ -7693,7 +7632,6 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} @@ -7714,7 +7652,6 @@ packages: engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} @@ -7735,7 +7672,6 @@ packages: engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} @@ -15198,10 +15134,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 @@ -17523,6 +17455,8 @@ snapshots: getenv@2.0.0: {} + ghostty-web@0.4.0: {} + github-slugger@2.0.0: {} glob-parent@5.1.2: