diff --git a/.gitignore b/.gitignore index 9eb20c0..98f8fd5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ zig-out/ .zig-cache/ *.wasm !packages/@wterm/core/wasm/wterm.wasm +!packages/@wterm/ghostty/wasm/ghostty-vt.wasm .DS_Store node_modules/ dist/ diff --git a/README.md b/README.md index 81d4b26..16d0447 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,17 @@ wterm ("dub-term") renders to the DOM — native text selection, copy/paste, fin | Package | Description | |---|---| -| [`@wterm/core`](packages/@wterm/core) | Headless WASM bridge + WebSocket transport | +| [`@wterm/core`](packages/@wterm/core) | Headless WASM bridge, `TerminalCore` interface, WebSocket transport | | [`@wterm/dom`](packages/@wterm/dom) | DOM renderer, input handler — vanilla JS terminal | | [`@wterm/react`](packages/@wterm/react) | React component + `useTerminal` hook (TypeScript) | | [`@wterm/vue`](packages/@wterm/vue) | Vue 3 component + template ref API | +| [`@wterm/ghostty`](packages/@wterm/ghostty) | Full-featured VT emulation core powered by libghostty | | [`@wterm/just-bash`](packages/@wterm/just-bash) | In-browser Bash shell powered by just-bash | | [`@wterm/markdown`](packages/@wterm/markdown) | Render Markdown in the terminal | ## Features +- **Pluggable cores** — built-in lightweight Zig core (~12 KB) or opt-in [libghostty](packages/@wterm/ghostty) backend (~400 KB) for full VT compliance - **Zig + WASM core** — VT100/VT220/xterm escape sequence parser compiled to a ~12 KB `.wasm` binary (release build) - **DOM rendering** — native text selection, clipboard, browser find, and screen reader support - **Dirty-row tracking** — only touched rows are re-rendered each frame via `requestAnimationFrame` diff --git a/apps/docs/src/app/api-reference/page.mdx b/apps/docs/src/app/api-reference/page.mdx index 1241923..9c03113 100644 --- a/apps/docs/src/app/api-reference/page.mdx +++ b/apps/docs/src/app/api-reference/page.mdx @@ -28,11 +28,17 @@ The React and Vue `` components and the vanilla `WTerm` constructor al 24 Initial row count + + core + TerminalCore + — + A pre-constructed terminal core instance. When provided, wasmUrl is ignored and this core is used instead of loading the built-in Zig WASM binary. See Ghostty Core for an example. + wasmUrl string — - URL to serve the WASM binary separately. When omitted, the ~12 KB binary is decoded from an inlined base64 string. + URL to serve the WASM binary separately. When omitted, the ~12 KB binary is decoded from an inlined base64 string. Ignored when core is provided. autoResize diff --git a/apps/docs/src/app/configuration/page.mdx b/apps/docs/src/app/configuration/page.mdx index 75364ae..ded05f5 100644 --- a/apps/docs/src/app/configuration/page.mdx +++ b/apps/docs/src/app/configuration/page.mdx @@ -2,10 +2,14 @@ ## Options -The React and Vue `Terminal` components and the vanilla `WTerm` constructor accept the same core options — `cols`, `rows`, `wasmUrl`, `autoResize`, `cursorBlink`, and `debug` — plus event callbacks `onData`, `onTitle`, and `onResize` (exposed as `@data`, `@title`, `@resize` events in Vue). +The React and Vue `Terminal` components and the vanilla `WTerm` constructor accept the same core options — `cols`, `rows`, `core`, `wasmUrl`, `autoResize`, `cursorBlink`, and `debug` — plus event callbacks `onData`, `onTitle`, and `onResize` (exposed as `@data`, `@title`, `@resize` events in Vue). See the full [API Reference](/api-reference#terminal-options) for types, defaults, and descriptions. +### Pluggable cores + +By default, wterm uses its built-in lightweight Zig WASM core (~12 KB). To use a different terminal emulation backend, pass a `TerminalCore` instance via the `core` option. See the [Ghostty Core](/ghostty) page for an example using libghostty. + ### React-only The React `Terminal` component adds `theme`, `onReady`, and `onError` on top of the shared options. It also spreads standard HTML attributes onto the root `div`, so you can pass `className`, `style`, ARIA attributes, and other DOM props directly. diff --git a/apps/docs/src/app/ghostty/page.mdx b/apps/docs/src/app/ghostty/page.mdx new file mode 100644 index 0000000..a84823d --- /dev/null +++ b/apps/docs/src/app/ghostty/page.mdx @@ -0,0 +1,149 @@ +# Ghostty Core + +The `@wterm/ghostty` package provides a full-featured terminal emulation core powered by [libghostty](https://ghostty.org) built directly from upstream source. It implements the same `TerminalCore` interface as wterm's built-in Zig core, so it's a drop-in replacement. + +## Why use it? + +wterm ships with a lightweight built-in core (~12 KB WASM) that covers basic VT100/VT220/xterm escape sequences. For apps that need comprehensive terminal emulation — full Unicode grapheme clusters, all SGR attributes, terminal modes, and more — `@wterm/ghostty` provides all of that via Ghostty's battle-tested VT parser (~400 KB WASM). + +## Install + +```bash +npm install @wterm/ghostty +``` + +## Usage + +Load the Ghostty core and pass it to `WTerm` via the `core` option. Everything else stays the same. + +### Vanilla JS + +```ts +import { WTerm } from "@wterm/dom"; +import { GhosttyCore } from "@wterm/ghostty"; +import "@wterm/dom/css"; + +const core = await GhosttyCore.load(); +const term = new WTerm(document.getElementById("terminal"), { core }); +await term.init(); +``` + +### React + +```tsx +import { Terminal } from "@wterm/react"; +import { GhosttyCore } from "@wterm/ghostty"; +import "@wterm/dom/css"; + +const core = await GhosttyCore.load(); + +function App() { + return ; +} +``` + +### Vue + +```vue + + + +``` + +## Options + +`GhosttyCore.load()` accepts an optional options object: + + + + + + + + + + + + + + + + + + + + + +
OptionTypeDescription
wasmPathstringCustom path to the ghostty-vt WASM binary. By default it resolves to the committed binary inside the package.
scrollbackLimitnumberMaximum scrollback lines (default: 10000).
+ +## How it works + +`@wterm/ghostty` builds libghostty directly from [ghostty-org/ghostty](https://github.com/ghostty-org/ghostty) source — no third-party npm packages or pre-built binaries from other projects. The architecture: + +1. **Zig package dependency**: ghostty v1.3.1 is declared as a URL dependency in `zig/build.zig.zon`. Zig's package manager fetches it automatically. +2. **WASM compatibility patches**: ghostty's `Terminal` uses `posix.mmap` and Mach VM allocators internally, which don't exist on `wasm32-freestanding`. The build script applies small, targeted patches to replace these with `std.heap.wasm_allocator` behind comptime `isWasm()` checks. The patches only touch `page.zig` and `PageList.zig`. +3. **Thin WASM export layer**: `zig/src/wasm_api.zig` (~300 lines) imports ghostty's `Terminal` and `RenderState` APIs and exports ~20 functions to JavaScript. +4. **Committed WASM binary**: The built `wasm/ghostty-vt.wasm` is checked into the repo so consumers never need Zig installed. +5. **TypeScript bindings**: `wasm-bindings.ts` loads the WASM module and provides typed accessors for the exported functions. +6. **TerminalCore adapter**: `ghostty-core.ts` implements the `TerminalCore` interface by calling the WASM bindings, converting ghostty's pre-resolved 24-bit RGB colors to wterm's `CellData` format via the `fgRgb`/`bgRgb` fields. + +The `TerminalCore` interface means the DOM renderer, input handler, and framework bindings don't need to know which core they're talking to. + +## Rebuilding the WASM + +Only needed by maintainers. Requires [Zig 0.15.x](https://ziglang.org/download/) (ghostty's required Zig version, separate from wterm's Zig 0.16.x): + +```bash +pnpm --filter @wterm/ghostty rebuild-wasm +``` + +## Comparison + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Built-in (default)@wterm/ghostty
Bundle size~12 KB WASM (inlined)~400 KB WASM (fetched)
VT complianceBasic VT100/VT220/xtermComprehensive
UnicodeSingle codepointsFull grapheme clusters
Color model256-color palette indicesPre-resolved 24-bit RGB
DependenciesNoneNone (WASM built from source)
SetupZero-configRequires @wterm/ghostty install
diff --git a/apps/docs/src/app/react/page.mdx b/apps/docs/src/app/react/page.mdx index b724066..7fd56c6 100644 --- a/apps/docs/src/app/react/page.mdx +++ b/apps/docs/src/app/react/page.mdx @@ -43,7 +43,7 @@ function App() { ## Props -The `` component accepts all [shared terminal options](/api-reference#terminal-options) (`cols`, `rows`, `wasmUrl`, `autoResize`, `cursorBlink`, `onData`, `onTitle`, `onResize`) plus [React-only props](/api-reference#react-only-props) (`theme`, `onReady`, `onError`). +The `` component accepts all [shared terminal options](/api-reference#terminal-options) (`cols`, `rows`, `core`, `wasmUrl`, `autoResize`, `cursorBlink`, `onData`, `onTitle`, `onResize`) plus [React-only props](/api-reference#react-only-props) (`theme`, `onReady`, `onError`). Pass a [`TerminalCore`](/ghostty) instance to the `core` prop to use an alternative emulation backend. Standard `div` props (`className`, `style`, `id`, etc.) are forwarded to the container element. diff --git a/apps/docs/src/app/vanilla/page.mdx b/apps/docs/src/app/vanilla/page.mdx index f5dd152..1e214f9 100644 --- a/apps/docs/src/app/vanilla/page.mdx +++ b/apps/docs/src/app/vanilla/page.mdx @@ -43,7 +43,7 @@ await term.init(); ## Options and Methods -The `WTerm` constructor accepts all [shared terminal options](/api-reference#terminal-options) (`cols`, `rows`, `wasmUrl`, `autoResize`, `cursorBlink`, `onData`, `onTitle`, `onResize`). +The `WTerm` constructor accepts all [shared terminal options](/api-reference#terminal-options) (`cols`, `rows`, `core`, `wasmUrl`, `autoResize`, `cursorBlink`, `onData`, `onTitle`, `onResize`). Pass a [`TerminalCore`](/ghostty) instance to the `core` option to use an alternative emulation backend. See [WTerm Methods](/api-reference#wterm-methods) for the full list of instance methods (`init`, `write`, `resize`, `focus`, `destroy`). diff --git a/apps/docs/src/app/vue/page.mdx b/apps/docs/src/app/vue/page.mdx index 26f0408..6124915 100644 --- a/apps/docs/src/app/vue/page.mdx +++ b/apps/docs/src/app/vue/page.mdx @@ -45,7 +45,7 @@ function onData(chunk: string) { ## Props -The `` component accepts all [shared terminal options](/api-reference#terminal-options) (`cols`, `rows`, `wasmUrl`, `autoResize`, `cursorBlink`) plus [Vue-only props](/api-reference#vue-only-props) (`theme`). +The `` component accepts all [shared terminal options](/api-reference#terminal-options) (`cols`, `rows`, `core`, `wasmUrl`, `autoResize`, `cursorBlink`) plus [Vue-only props](/api-reference#vue-only-props) (`theme`). Pass a [`TerminalCore`](/ghostty) instance to the `core` prop to use an alternative emulation backend. Because `inheritAttrs` is enabled, standard DOM attributes (`class`, `style`, `id`, ARIA props, etc.) are forwarded to the root `
`. diff --git a/apps/docs/src/lib/docs-navigation.ts b/apps/docs/src/lib/docs-navigation.ts index bee32cb..39ea8f7 100644 --- a/apps/docs/src/lib/docs-navigation.ts +++ b/apps/docs/src/lib/docs-navigation.ts @@ -33,6 +33,7 @@ export const navGroups: NavGroup[] = [ { label: "Packages", items: [ + { name: "Ghostty Core", href: "/ghostty" }, { name: "Just Bash", href: "/just-bash" }, { name: "Markdown", href: "/markdown" }, { name: "Core / Advanced", href: "/core" }, @@ -66,6 +67,11 @@ export const navGroups: NavGroup[] = [ href: `${GITHUB}/tree/main/examples/markdown-streaming`, external: true, }, + { + name: "Ghostty Core", + href: `${GITHUB}/tree/main/examples/ghostty`, + external: true, + }, ], }, { @@ -91,6 +97,11 @@ export const navGroups: NavGroup[] = [ href: `${GITHUB}/tree/main/packages/@wterm/vue`, external: true, }, + { + name: "@wterm/ghostty", + href: `${GITHUB}/tree/main/packages/@wterm/ghostty`, + external: true, + }, { name: "@wterm/just-bash", href: `${GITHUB}/tree/main/packages/@wterm/just-bash`, diff --git a/apps/docs/src/lib/page-titles.ts b/apps/docs/src/lib/page-titles.ts index 9f81950..f0ed772 100644 --- a/apps/docs/src/lib/page-titles.ts +++ b/apps/docs/src/lib/page-titles.ts @@ -7,6 +7,7 @@ export const PAGE_TITLES: Record = { react: "React", vue: "Vue", vanilla: "Vanilla JS", + ghostty: "Ghostty Core", "just-bash": "Just Bash", markdown: "Markdown", core: "Core / Advanced", diff --git a/examples/ghostty/README.md b/examples/ghostty/README.md new file mode 100644 index 0000000..423875a --- /dev/null +++ b/examples/ghostty/README.md @@ -0,0 +1,27 @@ +# Ghostty Core Example + +Minimal Vite + vanilla TypeScript terminal using the [libghostty](https://ghostty.org) backend (built from source) via `@wterm/ghostty` instead of wterm's built-in Zig core. + +## Setup + +From the monorepo root: + +```bash +pnpm install +pnpm --filter ghostty-example dev +``` + +Opens at `ghostty-example.wterm.localhost` via [portless](https://github.com/vercel-labs/portless). + +## How It Works + +- `@wterm/ghostty` loads the ghostty-vt WASM binary (~400 KB, built from upstream ghostty source) and creates a `GhosttyCore` instance +- The core is passed to `WTerm` via the `core` option — from that point on, everything works identically to the built-in core +- `@wterm/dom` renders the terminal grid into the DOM as usual, consuming `TerminalCore` methods + +## Key Files + +| File | Description | +|---|---| +| `src/main.ts` | Loads the Ghostty core and creates the terminal | +| `index.html` | Minimal HTML with `
` | diff --git a/examples/ghostty/index.html b/examples/ghostty/index.html new file mode 100644 index 0000000..133520e --- /dev/null +++ b/examples/ghostty/index.html @@ -0,0 +1,27 @@ + + + + + + wterm — Ghostty Core Example + + + +
+ + + diff --git a/examples/ghostty/package.json b/examples/ghostty/package.json new file mode 100644 index 0000000..8ca1b48 --- /dev/null +++ b/examples/ghostty/package.json @@ -0,0 +1,21 @@ +{ + "name": "ghostty-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)", + "dev": "portless ghostty-example.wterm vite", + "build": "vite build", + "preview": "vite preview", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@wterm/dom": "workspace:*", + "@wterm/ghostty": "workspace:*" + }, + "devDependencies": { + "typescript": "^6.0.2", + "vite": "^6.3.5" + } +} diff --git a/examples/ghostty/src/main.ts b/examples/ghostty/src/main.ts new file mode 100644 index 0000000..216c188 --- /dev/null +++ b/examples/ghostty/src/main.ts @@ -0,0 +1,16 @@ +import { WTerm } from "@wterm/dom"; +import { GhosttyCore } from "@wterm/ghostty"; +import "@wterm/dom/css"; + +const el = document.getElementById("terminal")!; + +const core = await GhosttyCore.load(); +const term = new WTerm(el, { core }); + +await term.init(); + +term.write( + "\x1b[1;36mwterm\x1b[0m powered by \x1b[1;35mlibghostty\x1b[0m 🚀\r\n\r\n" + + "Full VT emulation • Kitty protocols • Unicode grapheme clusters\r\n\r\n" + + "Type anything to echo it back:\r\n", +); diff --git a/examples/ghostty/tsconfig.json b/examples/ghostty/tsconfig.json new file mode 100644 index 0000000..11e5462 --- /dev/null +++ b/examples/ghostty/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"] + }, + "include": ["src", "*.d.ts"] +} diff --git a/examples/ghostty/vite.config.ts b/examples/ghostty/vite.config.ts new file mode 100644 index 0000000..87d46cd --- /dev/null +++ b/examples/ghostty/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + target: "esnext", + }, +}); diff --git a/examples/ghostty/wterm-dom.d.ts b/examples/ghostty/wterm-dom.d.ts new file mode 100644 index 0000000..aefbc0a --- /dev/null +++ b/examples/ghostty/wterm-dom.d.ts @@ -0,0 +1 @@ +declare module "@wterm/dom/css"; diff --git a/examples/local/.gitignore b/examples/local/.gitignore index 71e5011..7b7894a 100644 --- a/examples/local/.gitignore +++ b/examples/local/.gitignore @@ -33,6 +33,7 @@ yarn-error.log* # wasm build artifact /public/wterm.wasm +/public/ghostty-vt.wasm # vercel .vercel diff --git a/examples/local/app/core-toggle.tsx b/examples/local/app/core-toggle.tsx new file mode 100644 index 0000000..026d368 --- /dev/null +++ b/examples/local/app/core-toggle.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { usePathname, useRouter } from "next/navigation"; + +export function CoreToggle() { + const pathname = usePathname(); + const router = useRouter(); + const active = pathname === "/ghostty" ? "ghostty" : "builtin"; + + return ( +
+ Core +
+ + +
+
+ ); +} diff --git a/examples/local/app/ghostty/page.tsx b/examples/local/app/ghostty/page.tsx new file mode 100644 index 0000000..e9f55e0 --- /dev/null +++ b/examples/local/app/ghostty/page.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useCallback, useRef, useState, useEffect } from "react"; +import { Terminal, useTerminal } from "@wterm/react"; +import type { WTerm } from "@wterm/dom"; +import type { TerminalCore } from "@wterm/core"; +import { GhosttyCore } from "@wterm/ghostty"; +import { CoreToggle } from "../core-toggle"; +import "@wterm/react/css"; + +export default function GhosttyTerminal() { + const [debugEnabled] = useState( + () => + typeof window !== "undefined" && + new URLSearchParams(window.location.search).has("debug"), + ); + const [core, setCore] = useState(null); + const { ref, write } = useTerminal(); + const wsRef = useRef(null); + + useEffect(() => { + GhosttyCore.load({ wasmPath: "/ghostty-vt.wasm" }).then(setCore); + }, []); + + const handleReady = useCallback( + (wt: WTerm) => { + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${proto}//${window.location.host}/api/terminal`; + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + ws.send(`\x1b[RESIZE:${wt.cols};${wt.rows}]`); + }; + + ws.onmessage = (event: MessageEvent) => { + write(event.data as string); + }; + + ws.onclose = () => { + write("\r\n\x1b[90m[session ended]\x1b[0m\r\n"); + wsRef.current = null; + }; + }, + [write], + ); + + const handleData = useCallback((data: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(data); + } + }, []); + + const handleResize = useCallback((cols: number, rows: number) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(`\x1b[RESIZE:${cols};${rows}]`); + } + }, []); + + if (!core) return null; + + return ( +
+ + +
+ ); +} diff --git a/examples/local/app/page.tsx b/examples/local/app/page.tsx index 38399c1..3289e3d 100644 --- a/examples/local/app/page.tsx +++ b/examples/local/app/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useRef, useState } from "react"; import { Terminal, useTerminal } from "@wterm/react"; import type { WTerm } from "@wterm/dom"; +import { CoreToggle } from "./core-toggle"; import "@wterm/react/css"; export default function LocalTerminal() { @@ -51,6 +52,7 @@ export default function LocalTerminal() { return (
+ /dev/null; (command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1))", + "predev": "mkdir -p public && cp node_modules/@wterm/core/wasm/wterm.wasm public/wterm.wasm && cp node_modules/@wterm/ghostty/wasm/ghostty-vt.wasm public/ghostty-vt.wasm && chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper 2>/dev/null; (command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1))", "dev": "portless local-example.wterm tsx server.ts", - "prebuild": "mkdir -p public && cp node_modules/@wterm/core/wasm/wterm.wasm public/wterm.wasm && chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper 2>/dev/null", + "prebuild": "mkdir -p public && cp node_modules/@wterm/core/wasm/wterm.wasm public/wterm.wasm && cp node_modules/@wterm/ghostty/wasm/ghostty-vt.wasm public/ghostty-vt.wasm && chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper 2>/dev/null", "build": "next build", "start": "next start", "lint": "eslint", @@ -15,6 +15,7 @@ "@tailwindcss/postcss": "^4.2.2", "@wterm/core": "workspace:*", "@wterm/dom": "workspace:*", + "@wterm/ghostty": "workspace:*", "@wterm/react": "workspace:*", "lucide-react": "^1.8.0", "next": "16.2.3", @@ -22,17 +23,17 @@ "postcss": "^8.5.9", "react": "19.2.5", "react-dom": "19.2.5", - "ws": "^8.18.2", - "tailwindcss": "^4.2.2" + "tailwindcss": "^4.2.2", + "ws": "^8.18.2" }, "devDependencies": { "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/ws": "^8.18.1", - "tsx": "^4.19.4", "eslint": "^9.39.4", "eslint-config-next": "16.2.3", + "tsx": "^4.19.4", "typescript": "^6.0.2" } } diff --git a/examples/local/server.ts b/examples/local/server.ts index 4f4c098..cee1679 100644 --- a/examples/local/server.ts +++ b/examples/local/server.ts @@ -21,35 +21,37 @@ function cleanEnv(): Record { function handlePTYConnection(ws: WebSocket) { const shell = process.env.SHELL || "/bin/zsh"; - - let ptyProcess: pty.IPty; - try { - ptyProcess = pty.spawn(shell, ["-l"], { - name: "xterm-256color", - cols: 80, - rows: 24, - cwd: process.env.HOME || "/", - env: cleanEnv(), - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`Failed to spawn PTY: ${msg}`); - if (ws.readyState === WebSocket.OPEN) { - ws.send(`\r\n\x1b[31mFailed to spawn shell: ${msg}\x1b[0m\r\n`); - ws.close(); + let ptyProcess: pty.IPty | null = null; + + function spawnPTY(cols: number, rows: number) { + try { + ptyProcess = pty.spawn(shell, ["-l"], { + name: "xterm-256color", + cols, + rows, + cwd: process.env.HOME || "/", + env: cleanEnv(), + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(`Failed to spawn PTY: ${msg}`); + if (ws.readyState === WebSocket.OPEN) { + ws.send(`\r\n\x1b[31mFailed to spawn shell: ${msg}\x1b[0m\r\n`); + ws.close(); + } + return; } - return; - } - ptyProcess.onData((data) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data); - } - }); + ptyProcess.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(data); + } + }); - ptyProcess.onExit(() => { - if (ws.readyState === WebSocket.OPEN) ws.close(); - }); + ptyProcess.onExit(() => { + if (ws.readyState === WebSocket.OPEN) ws.close(); + }); + } ws.on("message", (msg: Buffer | string) => { const input = typeof msg === "string" ? msg : msg.toString("utf-8"); @@ -57,16 +59,22 @@ function handlePTYConnection(ws: WebSocket) { if (input.startsWith("\x1b[RESIZE:")) { const match = input.match(/\x1b\[RESIZE:(\d+);(\d+)\]/); if (match) { - ptyProcess.resize(parseInt(match[1], 10), parseInt(match[2], 10)); + const cols = parseInt(match[1], 10); + const rows = parseInt(match[2], 10); + if (!ptyProcess) { + spawnPTY(cols, rows); + } else { + ptyProcess.resize(cols, rows); + } return; } } - ptyProcess.write(input); + if (ptyProcess) ptyProcess.write(input); }); ws.on("close", () => { - ptyProcess.kill(); + if (ptyProcess) ptyProcess.kill(); }); } diff --git a/packages/@wterm/core/README.md b/packages/@wterm/core/README.md index 0f21212..b29c42d 100644 --- a/packages/@wterm/core/README.md +++ b/packages/@wterm/core/README.md @@ -9,6 +9,7 @@ Headless terminal emulator core for [wterm](https://github.com/vercel-labs/wterm | [`@wterm/dom`](https://www.npmjs.com/package/@wterm/dom) | DOM renderer, input handler — vanilla JS terminal | | [`@wterm/react`](https://www.npmjs.com/package/@wterm/react) | React component + `useTerminal` hook | | [`@wterm/vue`](https://www.npmjs.com/package/@wterm/vue) | Vue 3 component + template ref API | +| [`@wterm/ghostty`](https://www.npmjs.com/package/@wterm/ghostty) | Full-featured VT emulation core powered by libghostty | | [`@wterm/just-bash`](https://www.npmjs.com/package/@wterm/just-bash) | In-browser Bash shell powered by just-bash | | [`@wterm/markdown`](https://www.npmjs.com/package/@wterm/markdown) | Streaming Markdown-to-ANSI renderer for terminals | @@ -18,11 +19,27 @@ Headless terminal emulator core for [wterm](https://github.com/vercel-labs/wterm npm install @wterm/core ``` +## Pluggable Cores + +`@wterm/core` defines a `TerminalCore` interface that any terminal emulation backend can implement. The built-in `WasmBridge` implements it using wterm's lightweight Zig WASM binary (~12 KB). For full-featured emulation (Kitty protocols, proper grapheme handling, mouse tracking, etc.), use [`@wterm/ghostty`](https://www.npmjs.com/package/@wterm/ghostty) which implements the same interface using libghostty (~400 KB). + +```ts +import { WTerm } from "@wterm/dom"; +import { GhosttyCore } from "@wterm/ghostty"; + +// Default — uses built-in lightweight core +const term = new WTerm(el); + +// Opt-in — uses libghostty for full VT emulation +const core = await GhosttyCore.load(); +const term = new WTerm(el, { core }); +``` + ## API ### `WasmBridge` -Low-level interface to the Zig/WASM terminal state machine. +Low-level interface to the Zig/WASM terminal state machine. Implements the `TerminalCore` interface. ```ts import { WasmBridge } from "@wterm/core"; diff --git a/packages/@wterm/core/src/index.ts b/packages/@wterm/core/src/index.ts index c757cc5..bf32657 100644 --- a/packages/@wterm/core/src/index.ts +++ b/packages/@wterm/core/src/index.ts @@ -1,8 +1,9 @@ -export { WasmBridge } from "./wasm-bridge.js"; export type { CellData, CursorState, UnhandledSequence, -} from "./wasm-bridge.js"; + TerminalCore, +} from "./terminal-core.js"; +export { WasmBridge } from "./wasm-bridge.js"; export { WebSocketTransport } from "./transport.js"; export type { WebSocketTransportOptions } from "./transport.js"; diff --git a/packages/@wterm/core/src/terminal-core.ts b/packages/@wterm/core/src/terminal-core.ts new file mode 100644 index 0000000..f8c77d5 --- /dev/null +++ b/packages/@wterm/core/src/terminal-core.ts @@ -0,0 +1,65 @@ +export interface CellData { + char: number; + fg: number; + bg: number; + flags: number; + /** Resolved 24-bit foreground color (0xRRGGBB). Present when the core provides true color. */ + fgRgb?: number; + /** Resolved 24-bit background color (0xRRGGBB). Present when the core provides true color. */ + bgRgb?: number; +} + +export interface CursorState { + row: number; + col: number; + visible: boolean; +} + +export interface UnhandledSequence { + final: string; + private: string; + paramCount: number; + params: number[]; +} + +/** + * Abstract terminal emulation core. Both the built-in Zig WASM core + * (`WasmBridge`) and alternative backends (e.g. `@wterm/ghostty`) implement + * this interface so that `@wterm/dom` can render any core interchangeably. + */ +export interface TerminalCore { + // -- Lifecycle -- + init(cols: number, rows: number): void; + resize(cols: number, rows: number): void; + + // -- I/O -- + writeString(str: string): void; + writeRaw(data: Uint8Array): void; + + // -- Grid -- + getCell(row: number, col: number): CellData; + isDirtyRow(row: number): boolean; + clearDirty(): void; + getCols(): number; + getRows(): number; + + // -- Cursor -- + getCursor(): CursorState; + + // -- Modes -- + cursorKeysApp(): boolean; + bracketedPaste(): boolean; + usingAltScreen(): boolean; + + // -- Side outputs -- + getTitle(): string | null; + getResponse(): string | null; + + // -- Scrollback -- + getScrollbackCount(): number; + getScrollbackCell(offset: number, col: number): CellData; + getScrollbackLineLen(offset: number): number; + + // -- Debug -- + getUnhandledSequences(): UnhandledSequence[]; +} diff --git a/packages/@wterm/core/src/wasm-bridge.ts b/packages/@wterm/core/src/wasm-bridge.ts index 73db73e..981c300 100644 --- a/packages/@wterm/core/src/wasm-bridge.ts +++ b/packages/@wterm/core/src/wasm-bridge.ts @@ -1,15 +1,9 @@ -export interface CellData { - char: number; - fg: number; - bg: number; - flags: number; -} - -export interface CursorState { - row: number; - col: number; - visible: boolean; -} +import type { + CellData, + CursorState, + UnhandledSequence, + TerminalCore, +} from "./terminal-core.js"; interface WasmExports { memory: WebAssembly.Memory; @@ -45,13 +39,6 @@ interface WasmExports { getDebugLogMax(): number; } -export interface UnhandledSequence { - final: string; - private: string; - paramCount: number; - params: number[]; -} - import { WASM_BASE64 } from "./wasm-inline.js"; function decodeBase64(base64: string): ArrayBuffer { @@ -61,7 +48,7 @@ function decodeBase64(base64: string): ArrayBuffer { return bytes.buffer; } -export class WasmBridge { +export class WasmBridge implements TerminalCore { private exports: WasmExports; private memory: WebAssembly.Memory; private gridPtr = 0; diff --git a/packages/@wterm/dom/src/debug.ts b/packages/@wterm/dom/src/debug.ts index b8fe0ab..cfce891 100644 --- a/packages/@wterm/dom/src/debug.ts +++ b/packages/@wterm/dom/src/debug.ts @@ -1,4 +1,4 @@ -import type { WasmBridge, CellData } from "@wterm/core"; +import type { TerminalCore, CellData } from "@wterm/core"; const FLAG_NAMES: Record = { 0x01: "bold", @@ -193,7 +193,7 @@ const MAX_TRACES = 500; export class DebugAdapter { private _traces: TraceEntry[] = []; - private _bridge: WasmBridge | null = null; + private _bridge: TerminalCore | null = null; private _perf: PerfStats = { frameCount: 0, totalRenderMs: 0, @@ -210,7 +210,7 @@ export class DebugAdapter { return this._perf; } - setBridge(bridge: WasmBridge): void { + setBridge(bridge: TerminalCore): void { this._bridge = bridge; } diff --git a/packages/@wterm/dom/src/input.ts b/packages/@wterm/dom/src/input.ts index e75feab..5e2bfce 100644 --- a/packages/@wterm/dom/src/input.ts +++ b/packages/@wterm/dom/src/input.ts @@ -1,4 +1,4 @@ -import type { WasmBridge } from "@wterm/core"; +import type { TerminalCore } from "@wterm/core"; const NORMAL_KEYS: Record = { ArrowUp: "\x1b[A", @@ -45,7 +45,7 @@ export class InputHandler { private element: HTMLElement; private textarea: HTMLTextAreaElement; private onData: (data: string) => void; - private getBridge: () => WasmBridge | null; + private getBridge: () => TerminalCore | null; private composing = false; private _onKeyDown: (e: KeyboardEvent) => void; @@ -59,7 +59,7 @@ export class InputHandler { constructor( element: HTMLElement, onData: (data: string) => void, - getBridge: () => WasmBridge | null, + getBridge: () => TerminalCore | null, ) { this.element = element; this.onData = onData; diff --git a/packages/@wterm/dom/src/renderer.ts b/packages/@wterm/dom/src/renderer.ts index 5f6ce51..a5ed237 100644 --- a/packages/@wterm/dom/src/renderer.ts +++ b/packages/@wterm/dom/src/renderer.ts @@ -1,4 +1,4 @@ -import type { WasmBridge } from "@wterm/core"; +import type { TerminalCore } from "@wterm/core"; const DEFAULT_COLOR = 256; const FLAG_BOLD = 0x01; @@ -9,6 +9,13 @@ const FLAG_REVERSE = 0x20; const FLAG_INVISIBLE = 0x40; const FLAG_STRIKETHROUGH = 0x80; +function rgbToCSS(packed: number): string { + const r = (packed >> 16) & 0xff; + const g = (packed >> 8) & 0xff; + const b = packed & 0xff; + return `rgb(${r},${g},${b})`; +} + function colorToCSS(index: number): string | null { if (index === DEFAULT_COLOR) return null; if (index < 16) return `var(--term-color-${index})`; @@ -23,19 +30,41 @@ function colorToCSS(index: number): string | null { return `rgb(${level},${level},${level})`; } -function buildCellStyle(fg: number, bg: number, flags: number): string { - let fgC = fg, - bgC = bg; +function cellFgCSS(fg: number, fgRgb: number | undefined): string | null { + if (fgRgb !== undefined) return rgbToCSS(fgRgb); + return colorToCSS(fg); +} + +function cellBgCSS(bg: number, bgRgb: number | undefined): string | null { + if (bgRgb !== undefined) return rgbToCSS(bgRgb); + return colorToCSS(bg); +} + +function buildCellStyle( + fg: number, + bg: number, + flags: number, + fgRgb?: number, + bgRgb?: number, +): string { + let fgIdx = fg, + bgIdx = bg, + fgR = fgRgb, + bgR = bgRgb; + if (flags & FLAG_REVERSE) { - const tmp = fgC; - fgC = bgC; - bgC = tmp; - if (fgC === DEFAULT_COLOR) fgC = 0; - if (bgC === DEFAULT_COLOR) bgC = 7; + const tmpIdx = fgIdx; + fgIdx = bgIdx; + bgIdx = tmpIdx; + const tmpR = fgR; + fgR = bgR; + bgR = tmpR; + if (fgR === undefined && fgIdx === DEFAULT_COLOR) fgIdx = 0; + if (bgR === undefined && bgIdx === DEFAULT_COLOR) bgIdx = 7; } - const fgCSS = colorToCSS(fgC); - const bgCSS = colorToCSS(bgC); + const fgCSS = cellFgCSS(fgIdx, fgR); + const bgCSS = cellBgCSS(bgIdx, bgR); let style = ""; if (fgCSS) style += `color:${fgCSS};`; @@ -60,21 +89,34 @@ function appendRun(parent: HTMLElement, text: string, style: string): void { parent.appendChild(span); } +function escapeHTML(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + function resolveColors( fg: number, bg: number, flags: number, + fgRgb?: number, + bgRgb?: number, ): { fg: string; bg: string } { - let fgC = fg, - bgC = bg; + let fgIdx = fg, + bgIdx = bg, + fgR = fgRgb, + bgR = bgRgb; + if (flags & FLAG_REVERSE) { - [fgC, bgC] = [bgC, fgC]; - if (fgC === DEFAULT_COLOR) fgC = 0; - if (bgC === DEFAULT_COLOR) bgC = 7; + [fgIdx, bgIdx] = [bgIdx, fgIdx]; + [fgR, bgR] = [bgR, fgR]; + if (fgR === undefined && fgIdx === DEFAULT_COLOR) fgIdx = 0; + if (bgR === undefined && bgIdx === DEFAULT_COLOR) bgIdx = 7; } return { - fg: colorToCSS(fgC) || "var(--term-fg)", - bg: colorToCSS(bgC) || "var(--term-bg)", + fg: cellFgCSS(fgIdx, fgR) || "var(--term-fg)", + bg: cellBgCSS(bgIdx, bgR) || "var(--term-bg)", }; } @@ -201,37 +243,46 @@ export class Renderer { fg: number; bg: number; flags: number; + fgRgb?: number; + bgRgb?: number; }, lineLen: number, cursorCol: number, rowIndex: number, ): void { - rowEl.textContent = ""; - + let html = ""; let runStyle = ""; let runText = ""; let runStart = 0; const flushRun = (endCol: number) => { if (!runText) return; + const escaped = escapeHTML(runText); if (cursorCol >= runStart && cursorCol < endCol) { const offset = cursorCol - runStart; - const before = runText.slice(0, offset); - const cursorChar = runText[offset]; - const after = runText.slice(offset + 1); - - if (before) appendRun(rowEl, before, runStyle); - - const cursorSpan = document.createElement("span"); - cursorSpan.className = "term-cursor"; - if (runStyle) cursorSpan.style.cssText = runStyle; - cursorSpan.textContent = cursorChar; - rowEl.appendChild(cursorSpan); - - if (after) appendRun(rowEl, after, runStyle); + const chars = [...runText]; + const before = chars.slice(0, offset).join(""); + const cursorChar = chars[offset] || " "; + const after = chars.slice(offset + 1).join(""); + + if (before) { + html += runStyle + ? `${escapeHTML(before)}` + : `${escapeHTML(before)}`; + } + html += runStyle + ? `${escapeHTML(cursorChar)}` + : `${escapeHTML(cursorChar)}`; + if (after) { + html += runStyle + ? `${escapeHTML(after)}` + : `${escapeHTML(after)}`; + } } else { - appendRun(rowEl, runText, runStyle); + html += runStyle + ? `${escaped}` + : `${escaped}`; } }; @@ -243,13 +294,17 @@ export class Renderer { if (inBounds && cp >= 0x2580 && cp <= 0x259f) { flushRun(col); - const colors = resolveColors(cell.fg, cell.bg, cell.flags); - const span = document.createElement("span"); - span.className = - col === cursorCol ? "term-block term-cursor" : "term-block"; - span.style.background = getBlockBackground(cp, colors.fg, colors.bg); - if (cell.flags & FLAG_DIM) span.style.opacity = "0.5"; - rowEl.appendChild(span); + const colors = resolveColors( + cell.fg, + cell.bg, + cell.flags, + cell.fgRgb, + cell.bgRgb, + ); + const cls = col === cursorCol ? "term-block term-cursor" : "term-block"; + const bg = getBlockBackground(cp, colors.fg, colors.bg); + const dim = cell.flags & FLAG_DIM ? "opacity:0.5;" : ""; + html += ``; runStyle = ""; runText = ""; @@ -257,7 +312,7 @@ export class Renderer { } else { const ch = inBounds && cp >= 32 ? String.fromCodePoint(cp) : " "; const style = inBounds - ? buildCellStyle(cell.fg, cell.bg, cell.flags) + ? buildCellStyle(cell.fg, cell.bg, cell.flags, cell.fgRgb, cell.bgRgb) : ""; if (style !== runStyle) { @@ -272,18 +327,19 @@ export class Renderer { } flushRun(this.cols); - // Extend the row background when the line fills the full width. - // When lineLen < cols, bgCss stays "" which clears any stale bg - // via the prevRowBg comparison below. + rowEl.innerHTML = html; + let bgCss = ""; if (lineLen >= this.cols && this.cols > 0) { const lastCell = getCell(this.cols - 1); - let bgC = lastCell.bg; + let bgIdx = lastCell.bg; + let bgR = lastCell.bgRgb; if (lastCell.flags & FLAG_REVERSE) { - bgC = lastCell.fg; - if (bgC === DEFAULT_COLOR) bgC = 7; + bgIdx = lastCell.fg; + bgR = lastCell.fgRgb; + if (bgR === undefined && bgIdx === DEFAULT_COLOR) bgIdx = 7; } - bgCss = colorToCSS(bgC) || ""; + bgCss = cellBgCSS(bgIdx, bgR) || ""; } const boxShadow = bgCss ? `0 1px 0 ${bgCss}` : ""; if (rowIndex >= 0) { @@ -299,16 +355,16 @@ export class Renderer { } private _buildScrollbackRowEl( - bridge: WasmBridge, + core: TerminalCore, sbOffset: number, ): HTMLDivElement { const rowEl = document.createElement("div"); rowEl.className = "term-row term-scrollback-row"; - const lineLen = bridge.getScrollbackLineLen(sbOffset); + const lineLen = core.getScrollbackLineLen(sbOffset); this._buildRowContent( rowEl, - (col) => bridge.getScrollbackCell(sbOffset, col), + (col) => core.getScrollbackCell(sbOffset, col), lineLen, -1, -1, @@ -316,8 +372,8 @@ export class Renderer { return rowEl; } - private syncScrollback(bridge: WasmBridge): void { - const scrollbackCount = bridge.getScrollbackCount(); + private syncScrollback(core: TerminalCore): void { + const scrollbackCount = core.getScrollbackCount(); if (scrollbackCount === this._renderedScrollbackCount) return; @@ -327,7 +383,7 @@ export class Renderer { const fragment = document.createDocumentFragment(); for (let i = newCount - 1; i >= 0; i--) { - const rowEl = this._buildScrollbackRowEl(bridge, i); + const rowEl = this._buildScrollbackRowEl(core, i); fragment.appendChild(rowEl); this._scrollbackRowEls.push(rowEl); } @@ -344,9 +400,9 @@ export class Renderer { this._renderedScrollbackCount = scrollbackCount; } - render(bridge: WasmBridge): void { - const rows = bridge.getRows(); - const cols = bridge.getCols(); + render(core: TerminalCore): void { + const rows = core.getRows(); + const cols = core.getCols(); let resized = false; if (rows !== this.rows || cols !== this.cols) { @@ -354,16 +410,16 @@ export class Renderer { resized = true; } - this.syncScrollback(bridge); + this.syncScrollback(core); - const cursor = bridge.getCursor(); + const cursor = core.getCursor(); const cursorVisible = cursor.visible; const needsCursorUpdate = cursor.row !== this.prevCursorRow || cursor.col !== this.prevCursorCol; for (let r = 0; r < this.rows; r++) { - const isDirty = resized || bridge.isDirtyRow(r); + const isDirty = resized || core.isDirtyRow(r); const hadCursor = r === this.prevCursorRow && needsCursorUpdate; const hasCursor = r === cursor.row; @@ -371,7 +427,7 @@ export class Renderer { const cCol = hasCursor && cursorVisible ? cursor.col : -1; this._buildRowContent( this.rowEls[r], - (col) => bridge.getCell(r, col), + (col) => core.getCell(r, col), this.cols, cCol, r, @@ -382,23 +438,24 @@ export class Renderer { this.prevCursorRow = cursor.row; this.prevCursorCol = cursor.col; - // Update the container background only when the last row was actually - // repainted, avoiding stale reads during partial mid-redraw frames. - const lastRowDirty = resized || bridge.isDirtyRow(this.rows - 1); + const lastRowDirty = resized || core.isDirtyRow(this.rows - 1); if (lastRowDirty) { - const bottomRight = bridge.getCell(this.rows - 1, this.cols - 1); - let gridBg = bottomRight.bg; + const bottomRight = core.getCell(this.rows - 1, this.cols - 1); + let gridBgIdx = bottomRight.bg; + let gridBgRgb = bottomRight.bgRgb; if (bottomRight.flags & FLAG_REVERSE) { - gridBg = bottomRight.fg; - if (gridBg === DEFAULT_COLOR) gridBg = 7; + gridBgIdx = bottomRight.fg; + gridBgRgb = bottomRight.fgRgb; + if (gridBgRgb === undefined && gridBgIdx === DEFAULT_COLOR) + gridBgIdx = 7; } - const containerBg = colorToCSS(gridBg) || ""; + const containerBg = cellBgCSS(gridBgIdx, gridBgRgb) || ""; if (containerBg !== this.prevContainerBg) { this.container.style.background = containerBg; this.prevContainerBg = containerBg; } } - bridge.clearDirty(); + core.clearDirty(); } } diff --git a/packages/@wterm/dom/src/wterm.ts b/packages/@wterm/dom/src/wterm.ts index 5552297..0885ef9 100644 --- a/packages/@wterm/dom/src/wterm.ts +++ b/packages/@wterm/dom/src/wterm.ts @@ -1,4 +1,4 @@ -import { WasmBridge } from "@wterm/core"; +import { WasmBridge, type TerminalCore } from "@wterm/core"; import { Renderer } from "./renderer.js"; import { InputHandler } from "./input.js"; import { DebugAdapter } from "./debug.js"; @@ -6,6 +6,11 @@ import { DebugAdapter } from "./debug.js"; export interface WTermOptions { cols?: number; rows?: number; + /** + * A pre-constructed terminal core. When provided, `wasmUrl` is ignored and + * this core is used directly instead of loading the built-in Zig WASM binary. + */ + core?: TerminalCore; wasmUrl?: string; autoResize?: boolean; cursorBlink?: boolean; @@ -19,15 +24,17 @@ export class WTerm { element: HTMLElement; cols: number; rows: number; - bridge: WasmBridge | null = null; + bridge: TerminalCore | null = null; autoResize: boolean; debug: DebugAdapter | null = null; + private _coreOption: TerminalCore | undefined; private wasmUrl: string | undefined; private _debugEnabled: boolean; private renderer: Renderer | null = null; private input: InputHandler | null = null; private rafId: number | null = null; + private _renderTimer: ReturnType | null = null; private resizeObserver: ResizeObserver | null = null; private _destroyed = false; private _shouldScrollToBottom = false; @@ -42,6 +49,7 @@ export class WTerm { constructor(element: HTMLElement, options: WTermOptions = {}) { this.element = element; + this._coreOption = options.core; this.wasmUrl = options.wasmUrl; this.cols = options.cols || 80; this.rows = options.rows || 24; @@ -67,7 +75,11 @@ export class WTerm { async init(): Promise { try { - this.bridge = await WasmBridge.load(this.wasmUrl); + if (this._coreOption) { + this.bridge = this._coreOption; + } else { + this.bridge = await WasmBridge.load(this.wasmUrl); + } if (this._destroyed) return this; this.bridge.init(this.cols, this.rows); @@ -161,12 +173,16 @@ export class WTerm { } private _scheduleRender(): void { - if (this.rafId == null) { - this.rafId = requestAnimationFrame(() => { - this.rafId = null; - this._doRender(); - }); - } + if (this._renderTimer != null) return; + this._renderTimer = setTimeout(() => { + this._renderTimer = null; + if (this.rafId == null) { + this.rafId = requestAnimationFrame(() => { + this.rafId = null; + this._doRender(); + }); + } + }, 0); } private _initialRender(): void { @@ -290,6 +306,7 @@ export class WTerm { destroy(): void { this._destroyed = true; + if (this._renderTimer != null) clearTimeout(this._renderTimer); if (this.rafId != null) cancelAnimationFrame(this.rafId); if (this.resizeObserver) this.resizeObserver.disconnect(); if (this.input) this.input.destroy(); diff --git a/packages/@wterm/ghostty/README.md b/packages/@wterm/ghostty/README.md new file mode 100644 index 0000000..b0660c7 --- /dev/null +++ b/packages/@wterm/ghostty/README.md @@ -0,0 +1,107 @@ +# @wterm/ghostty + +Full-featured terminal emulation core for [wterm](https://github.com/vercel-labs/wterm), powered by [libghostty](https://ghostty.org) built from source. + +Drop-in replacement for wterm's built-in Zig core. Implements the same `TerminalCore` interface with comprehensive VT emulation: proper Unicode grapheme handling, all SGR attributes, terminal modes, and more. + +## Install + +```bash +npm install @wterm/ghostty +``` + +## Usage + +### Vanilla JS + +```ts +import { WTerm } from "@wterm/dom"; +import { GhosttyCore } from "@wterm/ghostty"; +import "@wterm/dom/css"; + +const core = await GhosttyCore.load(); +const term = new WTerm(document.getElementById("terminal"), { core }); +await term.init(); +``` + +### React + +```tsx +import { Terminal } from "@wterm/react"; +import { GhosttyCore } from "@wterm/ghostty"; +import "@wterm/dom/css"; + +const core = await GhosttyCore.load(); + +function App() { + return ; +} +``` + +### Vue + +```vue + + + +``` + +## Options + +`GhosttyCore.load()` accepts an options object: + +| Option | Type | Description | +|---|---|---| +| `wasmPath` | `string` | Custom path to the ghostty-vt WASM binary | +| `scrollbackLimit` | `number` | Maximum scrollback lines (default: 10000) | + +## Architecture + +The WASM binary is built from upstream [ghostty-org/ghostty](https://github.com/ghostty-org/ghostty) (v1.3.1) using it as a Zig package dependency — no third-party npm packages or pre-built binaries from other projects. + +``` +ghostty (Zig dep) → WASM patches → wasm_api.zig (~300 LOC) → ghostty-vt.wasm → TypeScript bindings +``` + +ghostty's `Terminal` and `Page` types use `posix.mmap` and Mach VM allocators internally, which don't exist on `wasm32-freestanding`. The build script applies small, targeted patches to replace these with `std.heap.wasm_allocator` behind comptime `isWasm()` checks (see `scripts/patch-ghostty-wasm.sh`). The patches are pinned to ghostty v1.3.1 and only touch two files: `page.zig` and `PageList.zig`. + +The committed `wasm/ghostty-vt.wasm` binary means consumers never need Zig installed. Only maintainers rebuilding the WASM need Zig 0.15.x. + +### Rebuilding the WASM + +Requires [Zig 0.15.x](https://ziglang.org/download/) (ghostty's required version): + +```bash +pnpm --filter @wterm/ghostty rebuild-wasm +``` + +This fetches the ghostty source via Zig's package manager, applies WASM compatibility patches, compiles our export layer to `wasm32-freestanding`, and copies the binary to `wasm/`. + +### Upgrading ghostty + +1. Edit the URL tag in `zig/build.zig.zon` to the new ghostty version +2. Run `zig fetch ` from the `zig/` directory to get the new hash +3. Update the hash in `build.zig.zon` +4. Verify the patches in `scripts/patch-ghostty-wasm.sh` still apply cleanly +5. Run `pnpm --filter @wterm/ghostty rebuild-wasm` + +## Tradeoffs vs built-in core + +| | Built-in (default) | `@wterm/ghostty` | +|---|---|---| +| Bundle size | ~12 KB WASM | ~400 KB WASM | +| VT compliance | Basic VT100/VT220/xterm | Comprehensive | +| Unicode | Single codepoints | Full grapheme clusters | +| Dependencies | None | None (WASM built from source) | +| Setup | Zero-config | Requires `@wterm/ghostty` install | + +## License + +Apache-2.0 diff --git a/packages/@wterm/ghostty/package.json b/packages/@wterm/ghostty/package.json new file mode 100644 index 0000000..3902cab --- /dev/null +++ b/packages/@wterm/ghostty/package.json @@ -0,0 +1,47 @@ +{ + "name": "@wterm/ghostty", + "version": "0.2.1", + "description": "libghostty-powered terminal core for wterm — full-featured VT emulation via Ghostty's WASM build", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "wasm" + ], + "scripts": { + "build": "tsc", + "prepublishOnly": "pnpm build", + "type-check": "tsc --noEmit", + "rebuild-wasm": "bash scripts/build-wasm.sh" + }, + "dependencies": { + "@wterm/core": "workspace:*" + }, + "devDependencies": { + "@internal/ts": "workspace:*", + "typescript": "^6.0.2" + }, + "keywords": [ + "terminal", + "emulator", + "ghostty", + "libghostty", + "wasm", + "xterm" + ], + "license": "Apache-2.0", + "homepage": "https://wterm.dev", + "repository": { + "type": "git", + "url": "https://github.com/vercel-labs/wterm", + "directory": "packages/@wterm/ghostty" + } +} diff --git a/packages/@wterm/ghostty/scripts/build-wasm.sh b/packages/@wterm/ghostty/scripts/build-wasm.sh new file mode 100755 index 0000000..54925ab --- /dev/null +++ b/packages/@wterm/ghostty/scripts/build-wasm.sh @@ -0,0 +1,69 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ZIG_DIR="$SCRIPT_DIR/../zig" +OUT_DIR="$SCRIPT_DIR/../wasm" + +GHOSTTY_VERSION="1.3.1" +GHOSTTY_URL="https://github.com/ghostty-org/ghostty/archive/v${GHOSTTY_VERSION}.tar.gz" +GHOSTTY_HASH="ghostty-${GHOSTTY_VERSION}-5UdBCwYm-gQeBa4bu1-sMooCQS4KVriv5wWSIJ_sI-Cb" + +# --------------------------------------------------------------------------- +# 1. Locate Zig 0.15.x +# --------------------------------------------------------------------------- +ZIG="" +ZIGUP_PATH="$HOME/.local/share/zigup/0.15.2/files/zig" +if [[ -x "$ZIGUP_PATH" ]]; then + ZIG="$ZIGUP_PATH" +elif command -v zig &>/dev/null && [[ "$(zig version 2>/dev/null)" =~ ^0\.15\. ]]; then + ZIG="zig" +fi + +if [[ -z "$ZIG" ]]; then + echo "Error: Zig 0.15.x is required but not found." + echo "" + echo "ghostty requires Zig 0.15.x which differs from wterm's Zig 0.16.x." + echo "Install it with: zigup 0.15.2" + echo "or download from https://ziglang.org/download/" + exit 1 +fi + +echo "Using Zig: $ZIG ($($ZIG version))" + +# --------------------------------------------------------------------------- +# 2. Ensure ghostty source is fetched (populate Zig global cache) +# --------------------------------------------------------------------------- +GHOSTTY_SRC="$HOME/.cache/zig/p/$GHOSTTY_HASH" + +if [[ ! -d "$GHOSTTY_SRC" ]]; then + echo "Fetching ghostty v${GHOSTTY_VERSION}..." + cd "$ZIG_DIR" + "$ZIG" build 2>/dev/null || true + if [[ ! -d "$GHOSTTY_SRC" ]]; then + echo "Error: ghostty source not found at $GHOSTTY_SRC after fetch" + exit 1 + fi +fi + +echo "ghostty source: $GHOSTTY_SRC" + +# --------------------------------------------------------------------------- +# 3. Patch page.zig for WASM (mmap → wasm_allocator) +# --------------------------------------------------------------------------- +echo "Applying WASM patches..." +bash "$SCRIPT_DIR/patch-ghostty-wasm.sh" "$GHOSTTY_SRC" + +# --------------------------------------------------------------------------- +# 4. Build +# --------------------------------------------------------------------------- +cd "$ZIG_DIR" +echo "Building ghostty-vt WASM module..." +"$ZIG" build -Doptimize=ReleaseSmall + +mkdir -p "$OUT_DIR" +cp zig-out/bin/ghostty-vt.wasm "$OUT_DIR/" + +echo "" +echo "Built: $OUT_DIR/ghostty-vt.wasm" +ls -lh "$OUT_DIR/ghostty-vt.wasm" diff --git a/packages/@wterm/ghostty/scripts/patch-ghostty-wasm.sh b/packages/@wterm/ghostty/scripts/patch-ghostty-wasm.sh new file mode 100755 index 0000000..03fbe0f --- /dev/null +++ b/packages/@wterm/ghostty/scripts/patch-ghostty-wasm.sh @@ -0,0 +1,202 @@ +#!/bin/bash +# Patches ghostty source for wasm32-freestanding compatibility. +# +# Two files need patching: +# 1. page.zig — uses posix.mmap/munmap for page memory +# 2. PageList.zig — pageAllocator() returns Mach VM allocator on macOS +# +# Both are replaced with wasm_allocator on WASM targets using +# comptime isWasm() checks, matching ghostty's own conditional style. +# +# Pinned to ghostty v1.3.1 — verify after version bumps. +set -euo pipefail + +GHOSTTY_SRC="$1" +PAGE_ZIG="$GHOSTTY_SRC/src/terminal/page.zig" +PAGELIST_ZIG="$GHOSTTY_SRC/src/terminal/PageList.zig" + +if [[ ! -f "$PAGE_ZIG" ]]; then + echo "Error: $PAGE_ZIG not found" + exit 1 +fi + +# Skip if already patched +if grep -q 'wasm_page_alloc' "$PAGE_ZIG" 2>/dev/null; then + echo "Already patched, skipping" + exit 0 +fi + +cp "$PAGE_ZIG" "$PAGE_ZIG.orig" +cp "$PAGELIST_ZIG" "$PAGELIST_ZIG.orig" + +# --------------------------------------------------------------- +# Patch PageList.zig — pageAllocator() +# --------------------------------------------------------------- +python3 -c " +with open('$PAGELIST_ZIG', 'r') as f: + src = f.read() + +old_pa = '''inline fn pageAllocator() Allocator { + // In tests we use our testing allocator so we can detect leaks. + if (builtin.is_test) return std.testing.allocator; + + // On non-macOS we use our standard Zig page allocator. + if (!builtin.target.os.tag.isDarwin()) return std.heap.page_allocator; + + // On macOS we want to tag our memory so we can assign it to our + // core terminal usage. + const mach = @import(\"../os/mach.zig\"); + return mach.taggedPageAllocator(.application_specific_1); +}''' + +new_pa = '''inline fn pageAllocator() Allocator { + if (builtin.is_test) return std.testing.allocator; + if (comptime builtin.target.cpu.arch.isWasm()) { + return std.heap.wasm_allocator; + } else if (comptime builtin.target.os.tag.isDarwin()) { + const mach = @import(\"../os/mach.zig\"); + return mach.taggedPageAllocator(.application_specific_1); + } else { + return std.heap.page_allocator; + } +}''' + +src = src.replace(old_pa, new_pa, 1) + +with open('$PAGELIST_ZIG', 'w') as f: + f.write(src) + +print('PageList.zig patched for WASM') +" + +# --------------------------------------------------------------- +# Patch page.zig — mmap/munmap +# --------------------------------------------------------------- +python3 -c " +import sys + +with open('$PAGE_ZIG', 'r') as f: + src = f.read() + +# 1. Make posix conditional — void on WASM so no symbols are resolved +src = src.replace( + 'const posix = std.posix;', + 'const posix = if (builtin.target.cpu.arch.isWasm()) void else std.posix;', + 1 +) + +# 2. Patch init() to branch on WASM +old_init = ''' pub inline fn init(cap: Capacity) !Page { + const l = layout(cap); + + // We use mmap directly to avoid Zig allocator overhead + // (small but meaningful for this path) and because a private + // anonymous mmap is guaranteed on Linux and macOS to be zeroed, + // which is a critical property for us. + assert(l.total_size % std.heap.page_size_min == 0); + const backing = try posix.mmap( + null, + l.total_size, + posix.PROT.READ | posix.PROT.WRITE, + .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, + -1, + 0, + ); + errdefer posix.munmap(backing); + + const buf = OffsetBuf.init(backing); + return initBuf(buf, l); + }''' + +new_init = ''' // wasm_page_alloc: patched by @wterm/ghostty for WASM compatibility + pub inline fn init(cap: Capacity) !Page { + const l = layout(cap); + + if (comptime builtin.target.cpu.arch.isWasm()) { + const backing = std.heap.wasm_allocator.alignedAlloc( + u8, + std.heap.page_size_min, + l.total_size, + ) catch return error.OutOfMemory; + @memset(backing, 0); + const buf = OffsetBuf.init(backing); + return initBuf(buf, l); + } + + assert(l.total_size % std.heap.page_size_min == 0); + const backing = try posix.mmap( + null, + l.total_size, + posix.PROT.READ | posix.PROT.WRITE, + .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, + -1, + 0, + ); + errdefer posix.munmap(backing); + + const buf = OffsetBuf.init(backing); + return initBuf(buf, l); + }''' + +src = src.replace(old_init, new_init, 1) + +# 3. Patch deinit() +old_deinit = ''' pub inline fn deinit(self: *Page) void { + posix.munmap(self.memory); + self.* = undefined; + }''' + +new_deinit = ''' pub inline fn deinit(self: *Page) void { + if (comptime builtin.target.cpu.arch.isWasm()) { + std.heap.wasm_allocator.free(self.memory); + } else { + posix.munmap(self.memory); + } + self.* = undefined; + }''' + +src = src.replace(old_deinit, new_deinit, 1) + +# 4. Patch clone() +old_clone = ''' pub inline fn clone(self: *const Page) !Page { + const backing = try posix.mmap( + null, + self.memory.len, + posix.PROT.READ | posix.PROT.WRITE, + .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, + -1, + 0, + ); + errdefer posix.munmap(backing); + return self.cloneBuf(backing); + }''' + +new_clone = ''' pub inline fn clone(self: *const Page) !Page { + if (comptime builtin.target.cpu.arch.isWasm()) { + const backing = std.heap.wasm_allocator.alignedAlloc( + u8, + std.heap.page_size_min, + self.memory.len, + ) catch return error.OutOfMemory; + errdefer std.heap.wasm_allocator.free(backing); + return self.cloneBuf(backing); + } + const backing = try posix.mmap( + null, + self.memory.len, + posix.PROT.READ | posix.PROT.WRITE, + .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, + -1, + 0, + ); + errdefer posix.munmap(backing); + return self.cloneBuf(backing); + }''' + +src = src.replace(old_clone, new_clone, 1) + +with open('$PAGE_ZIG', 'w') as f: + f.write(src) + +print('page.zig patched for WASM') +" diff --git a/packages/@wterm/ghostty/src/ghostty-core.ts b/packages/@wterm/ghostty/src/ghostty-core.ts new file mode 100644 index 0000000..0a67716 --- /dev/null +++ b/packages/@wterm/ghostty/src/ghostty-core.ts @@ -0,0 +1,319 @@ +import type { + CellData, + CursorState, + UnhandledSequence, + TerminalCore, +} from "@wterm/core"; +import { + type GhosttyWasm, + loadGhosttyWasm, + parseCell, + writeString as wasmWriteString, + writeBytes as wasmWriteBytes, + allocBuffer, + freeBuffer, + CELL_BYTES, +} from "./wasm-bindings.js"; + +const DEFAULT_COLOR = 256; + +const WTERM_FLAG_BOLD = 0x01; +const WTERM_FLAG_DIM = 0x02; +const WTERM_FLAG_ITALIC = 0x04; +const WTERM_FLAG_UNDERLINE = 0x08; +const WTERM_FLAG_BLINK = 0x10; +const WTERM_FLAG_REVERSE = 0x20; +const WTERM_FLAG_INVISIBLE = 0x40; +const WTERM_FLAG_STRIKETHROUGH = 0x80; + +// Our WASM layer packs flags in the same order as wterm (see wasm_api.zig): +// bold=1, faint=2, italic=4, underline=8, blink=16, inverse=32, +// invisible=64, strikethrough=128 +// This matches wterm's layout exactly, so no remapping is needed. +const _FLAG_SANITY_CHECK = [ + WTERM_FLAG_BOLD, + WTERM_FLAG_DIM, + WTERM_FLAG_ITALIC, + WTERM_FLAG_UNDERLINE, + WTERM_FLAG_BLINK, + WTERM_FLAG_REVERSE, + WTERM_FLAG_INVISIBLE, + WTERM_FLAG_STRIKETHROUGH, +]; +void _FLAG_SANITY_CHECK; + +function packRgb(r: number, g: number, b: number): number { + return (r << 16) | (g << 8) | b; +} + +const BLANK_CELL: CellData = { + char: 32, + fg: DEFAULT_COLOR, + bg: DEFAULT_COLOR, + flags: 0, +}; + +export interface GhosttyOptions { + wasmPath?: string; + scrollbackLimit?: number; +} + +/** + * Terminal core powered by libghostty built from source. Implements the + * same `TerminalCore` interface as wterm's built-in Zig core, providing + * full-featured VT emulation including proper Unicode grapheme handling, + * all SGR attributes, terminal modes, and more. + * + * @example + * ```ts + * import { WTerm } from '@wterm/dom'; + * import { GhosttyCore } from '@wterm/ghostty'; + * + * const core = await GhosttyCore.load(); + * const term = new WTerm(el, { core }); + * await term.init(); + * ``` + */ +export class GhosttyCore implements TerminalCore { + private wasm: GhosttyWasm; + private termPtr = 0; + private _options: GhosttyOptions; + + private _viewportBufPtr = 0; + private _viewportBufSize = 0; + private _viewportView: DataView | null = null; + private _viewportStale = true; + private _cols = 0; + private _rows = 0; + + private constructor(wasm: GhosttyWasm, options: GhosttyOptions) { + this.wasm = wasm; + this._options = options; + } + + /** + * Load the ghostty-vt WASM binary and create a new `GhosttyCore`. + * The returned core is ready to be passed as the `core` option to `WTerm`. + */ + static async load(options: GhosttyOptions = {}): Promise { + const wasm = await loadGhosttyWasm(options.wasmPath); + return new GhosttyCore(wasm, options); + } + + // -- Lifecycle -- + + init(cols: number, rows: number): void { + this._cols = cols; + this._rows = rows; + const scrollback = this._options.scrollbackLimit ?? 10000; + this.termPtr = this.wasm.exports.init(cols, rows, scrollback); + this._allocViewportBuffer(); + this._invalidate(); + } + + resize(cols: number, rows: number): void { + this._cols = cols; + this._rows = rows; + this.wasm.exports.resize(this.termPtr, cols, rows); + this._allocViewportBuffer(); + this._invalidate(); + } + + // -- I/O -- + + writeString(str: string): void { + wasmWriteString(this.wasm, this.termPtr, str); + this._invalidate(); + } + + writeRaw(data: Uint8Array): void { + wasmWriteBytes(this.wasm, this.termPtr, data); + this._invalidate(); + } + + // -- Grid -- + + getCell(row: number, col: number): CellData { + this._ensureViewport(); + const view = this._viewportView; + if (!view) return BLANK_CELL; + + const idx = row * this._cols + col; + const byteOffset = idx * CELL_BYTES; + if (byteOffset + CELL_BYTES > this._viewportBufSize) return BLANK_CELL; + + const cell = parseCell(view, byteOffset); + if (cell.codepoint === 0 && cell.flags === 0 && cell.colorFlags === 0) + return BLANK_CELL; + + const result: CellData = { + char: cell.codepoint || 32, + fg: DEFAULT_COLOR, + bg: DEFAULT_COLOR, + flags: cell.flags, + }; + if (cell.colorFlags & 1) + result.fgRgb = packRgb(cell.fgR, cell.fgG, cell.fgB); + if (cell.colorFlags & 2) + result.bgRgb = packRgb(cell.bgR, cell.bgG, cell.bgB); + return result; + } + + isDirtyRow(row: number): boolean { + this._ensureViewport(); + return this.wasm.exports.is_dirty_row(this.termPtr, row) !== 0; + } + + clearDirty(): void { + this.wasm.exports.clear_dirty(this.termPtr); + this._viewportStale = true; + } + + getCols(): number { + return this._cols; + } + + getRows(): number { + return this._rows; + } + + // -- Cursor -- + + getCursor(): CursorState { + this._ensureViewport(); + return { + row: this.wasm.exports.get_cursor_row(this.termPtr), + col: this.wasm.exports.get_cursor_col(this.termPtr), + visible: this.wasm.exports.get_cursor_visible(this.termPtr) !== 0, + }; + } + + // -- Modes -- + + cursorKeysApp(): boolean { + return this.wasm.exports.cursor_keys_app(this.termPtr) !== 0; + } + + bracketedPaste(): boolean { + return this.wasm.exports.bracketed_paste(this.termPtr) !== 0; + } + + usingAltScreen(): boolean { + return this.wasm.exports.using_alt_screen(this.termPtr) !== 0; + } + + // -- Side outputs -- + + getTitle(): string | null { + // Title changes are delivered through OSC sequences which the + // ReadonlyStream handler doesn't capture. A full stream handler + // would be needed for title support. + return null; + } + + getResponse(): string | null { + const bufSize = 4096; + const bufPtr = allocBuffer(this.wasm, bufSize); + if (bufPtr === 0) return null; + const len = this.wasm.exports.read_response(this.termPtr, bufPtr, bufSize); + if (len === 0) { + freeBuffer(this.wasm, bufPtr, bufSize); + return null; + } + const bytes = new Uint8Array(this.wasm.exports.memory.buffer, bufPtr, len); + const text = new TextDecoder().decode(bytes); + freeBuffer(this.wasm, bufPtr, bufSize); + return text; + } + + // -- Scrollback -- + + getScrollbackCount(): number { + return this.wasm.exports.get_scrollback_count(this.termPtr); + } + + getScrollbackCell(offset: number, col: number): CellData { + const maxCols = this._cols; + const lineSize = maxCols * CELL_BYTES; + const bufPtr = allocBuffer(this.wasm, lineSize); + if (bufPtr === 0) return BLANK_CELL; + + const len = this.wasm.exports.get_scrollback_line( + this.termPtr, + offset, + bufPtr, + maxCols, + ); + if (len === 0 || col >= len) { + freeBuffer(this.wasm, bufPtr, lineSize); + return BLANK_CELL; + } + + const view = new DataView( + this.wasm.exports.memory.buffer, + bufPtr, + lineSize, + ); + const cell = parseCell(view, col * CELL_BYTES); + freeBuffer(this.wasm, bufPtr, lineSize); + + return { + char: cell.codepoint || 32, + fg: DEFAULT_COLOR, + bg: DEFAULT_COLOR, + flags: cell.flags, + fgRgb: packRgb(cell.fgR, cell.fgG, cell.fgB), + bgRgb: packRgb(cell.bgR, cell.bgG, cell.bgB), + }; + } + + getScrollbackLineLen(offset: number): number { + const maxCols = this._cols; + const lineSize = maxCols * CELL_BYTES; + const bufPtr = allocBuffer(this.wasm, lineSize); + if (bufPtr === 0) return 0; + + const len = this.wasm.exports.get_scrollback_line( + this.termPtr, + offset, + bufPtr, + maxCols, + ); + freeBuffer(this.wasm, bufPtr, lineSize); + return len; + } + + // -- Debug -- + + getUnhandledSequences(): UnhandledSequence[] { + return []; + } + + // -- Internal helpers -- + + private _invalidate(): void { + this._viewportStale = true; + } + + private _allocViewportBuffer(): void { + if (this._viewportBufPtr !== 0) { + freeBuffer(this.wasm, this._viewportBufPtr, this._viewportBufSize); + } + this._viewportBufSize = this._cols * this._rows * CELL_BYTES; + this._viewportBufPtr = allocBuffer(this.wasm, this._viewportBufSize); + this._viewportView = null; + this._viewportStale = true; + } + + private _ensureViewport(): void { + if (!this._viewportStale) return; + this.wasm.exports.update(this.termPtr); + this.wasm.exports.get_viewport(this.termPtr, this._viewportBufPtr); + this._viewportView = new DataView( + this.wasm.exports.memory.buffer, + this._viewportBufPtr, + this._viewportBufSize, + ); + this._viewportStale = false; + } +} diff --git a/packages/@wterm/ghostty/src/index.ts b/packages/@wterm/ghostty/src/index.ts new file mode 100644 index 0000000..67d6f1d --- /dev/null +++ b/packages/@wterm/ghostty/src/index.ts @@ -0,0 +1,2 @@ +export { GhosttyCore } from "./ghostty-core.js"; +export type { GhosttyOptions } from "./ghostty-core.js"; diff --git a/packages/@wterm/ghostty/src/wasm-bindings.ts b/packages/@wterm/ghostty/src/wasm-bindings.ts new file mode 100644 index 0000000..ba67e02 --- /dev/null +++ b/packages/@wterm/ghostty/src/wasm-bindings.ts @@ -0,0 +1,177 @@ +/** + * Low-level typed bindings to the ghostty-vt WASM module built from + * our Zig export layer (zig/src/wasm_api.zig). + * + * Each exported Zig function maps 1:1 to a property on GhosttyExports. + * This module handles WASM loading, memory management, and cell parsing. + */ + +export interface GhosttyExports { + memory: WebAssembly.Memory; + + // Lifecycle + init(cols: number, rows: number, max_scrollback: number): number; + deinit(ptr: number): void; + resize(ptr: number, cols: number, rows: number): void; + + // Data input + write(ptr: number, data_ptr: number, data_len: number): void; + + // Render state + update(ptr: number): void; + get_viewport(ptr: number, buf_ptr: number): number; + + // Dirty tracking + is_dirty(ptr: number): number; + is_dirty_row(ptr: number, row: number): number; + clear_dirty(ptr: number): void; + + // Cursor + get_cursor_row(ptr: number): number; + get_cursor_col(ptr: number): number; + get_cursor_visible(ptr: number): number; + + // Modes + cursor_keys_app(ptr: number): number; + bracketed_paste(ptr: number): number; + using_alt_screen(ptr: number): number; + + // Grid + get_cols(ptr: number): number; + get_rows(ptr: number): number; + + // Scrollback + get_scrollback_count(ptr: number): number; + get_scrollback_line( + ptr: number, + offset: number, + buf_ptr: number, + max_cols: number, + ): number; + + // Responses + read_response(ptr: number, buf_ptr: number, buf_len: number): number; + + // Memory + alloc_buffer(len: number): number; + free_buffer(ptr: number, len: number): void; +} + +export interface GhosttyWasm { + exports: GhosttyExports; + instance: WebAssembly.Instance; +} + +const CELL_BYTES = 16; + +const DEFAULT_WASM_PATH = new URL("../wasm/ghostty-vt.wasm", import.meta.url) + .href; + +/** + * Load the ghostty-vt WASM module. + * + * @param wasmUrl - URL or path to the .wasm file. Defaults to the + * committed binary at `../wasm/ghostty-vt.wasm`. + */ +export async function loadGhosttyWasm(wasmUrl?: string): Promise { + const url = wasmUrl ?? DEFAULT_WASM_PATH; + const response = await fetch(url); + const bytes = await response.arrayBuffer(); + + let wasmMemory: WebAssembly.Memory; + + const { instance } = await WebAssembly.instantiate(bytes, { + env: { + log(ptr: number, len: number) { + const text = new TextDecoder().decode( + new Uint8Array(wasmMemory.buffer, ptr, len), + ); + console.log("[ghostty-vt]", text); + }, + }, + }); + + wasmMemory = instance.exports.memory as WebAssembly.Memory; + const exports = instance.exports as unknown as GhosttyExports; + return { exports, instance }; +} + +/** Parsed cell data from the viewport buffer. */ +export interface WasmCellData { + codepoint: number; + fgR: number; + fgG: number; + fgB: number; + bgR: number; + bgG: number; + bgB: number; + flags: number; + width: number; + /** Bit 0: has explicit fg color, Bit 1: has explicit bg color */ + colorFlags: number; +} + +/** + * Parse a single cell from the viewport buffer at the given byte offset. + * The buffer layout matches the 16-byte struct from wasm_api.zig. + */ +export function parseCell(view: DataView, byteOffset: number): WasmCellData { + return { + codepoint: view.getUint32(byteOffset, true), + fgR: view.getUint8(byteOffset + 4), + fgG: view.getUint8(byteOffset + 5), + fgB: view.getUint8(byteOffset + 6), + bgR: view.getUint8(byteOffset + 7), + bgG: view.getUint8(byteOffset + 8), + bgB: view.getUint8(byteOffset + 9), + flags: view.getUint8(byteOffset + 10), + width: view.getUint8(byteOffset + 11), + colorFlags: view.getUint8(byteOffset + 12), + }; +} + +/** Byte size of one cell in the viewport buffer. */ +export { CELL_BYTES }; + +/** + * Allocate a buffer in WASM memory and return its pointer. + * The caller must free it with freeBuffer when done. + */ +export function allocBuffer(wasm: GhosttyWasm, size: number): number { + return wasm.exports.alloc_buffer(size); +} + +/** Free a buffer previously allocated with allocBuffer. */ +export function freeBuffer(wasm: GhosttyWasm, ptr: number, size: number): void { + wasm.exports.free_buffer(ptr, size); +} + +/** + * Write a UTF-8 string into WASM memory and call the terminal's write + * function. Handles allocation/deallocation of the transfer buffer. + */ +export function writeString( + wasm: GhosttyWasm, + termPtr: number, + str: string, +): void { + const encoded = new TextEncoder().encode(str); + writeBytes(wasm, termPtr, encoded); +} + +/** + * Write raw bytes into the terminal. Handles allocation/deallocation + * of the transfer buffer. + */ +export function writeBytes( + wasm: GhosttyWasm, + termPtr: number, + data: Uint8Array, +): void { + if (data.length === 0) return; + const bufPtr = allocBuffer(wasm, data.length); + if (bufPtr === 0) return; + new Uint8Array(wasm.exports.memory.buffer, bufPtr, data.length).set(data); + wasm.exports.write(termPtr, bufPtr, data.length); + freeBuffer(wasm, bufPtr, data.length); +} diff --git a/packages/@wterm/ghostty/tsconfig.json b/packages/@wterm/ghostty/tsconfig.json new file mode 100644 index 0000000..f02d9c6 --- /dev/null +++ b/packages/@wterm/ghostty/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@internal/ts/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/__tests__"] +} diff --git a/packages/@wterm/ghostty/wasm/.gitkeep b/packages/@wterm/ghostty/wasm/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/@wterm/ghostty/wasm/ghostty-vt.wasm b/packages/@wterm/ghostty/wasm/ghostty-vt.wasm new file mode 100755 index 0000000..bac589b Binary files /dev/null and b/packages/@wterm/ghostty/wasm/ghostty-vt.wasm differ diff --git a/packages/@wterm/ghostty/zig/build.zig b/packages/@wterm/ghostty/zig/build.zig new file mode 100644 index 0000000..f5d0328 --- /dev/null +++ b/packages/@wterm/ghostty/zig/build.zig @@ -0,0 +1,33 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const optimize = b.standardOptimizeOption(.{}); + + const wasm_target = b.resolveTargetQuery(.{ + .cpu_arch = .wasm32, + .os_tag = .freestanding, + }); + + const exe_mod = b.createModule(.{ + .root_source_file = b.path("src/wasm_api.zig"), + .target = wasm_target, + .optimize = optimize, + }); + + if (b.lazyDependency("ghostty", .{ + .target = wasm_target, + .simd = false, + })) |dep| { + exe_mod.addImport("ghostty-vt", dep.module("ghostty-vt")); + } + + const exe = b.addExecutable(.{ + .name = "ghostty-vt", + .root_module = exe_mod, + }); + + exe.rdynamic = true; + exe.entry = .disabled; + + b.installArtifact(exe); +} diff --git a/packages/@wterm/ghostty/zig/build.zig.zon b/packages/@wterm/ghostty/zig/build.zig.zon new file mode 100644 index 0000000..ac0c582 --- /dev/null +++ b/packages/@wterm/ghostty/zig/build.zig.zon @@ -0,0 +1,17 @@ +.{ + .name = .wterm_ghostty, + .version = "0.0.0", + .fingerprint = 0xe486cbc71ccad0ae, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + .ghostty = .{ + .url = "https://github.com/ghostty-org/ghostty/archive/v1.3.1.tar.gz", + .hash = "ghostty-1.3.1-5UdBCwYm-gQeBa4bu1-sMooCQS4KVriv5wWSIJ_sI-Cb", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/packages/@wterm/ghostty/zig/src/wasm_api.zig b/packages/@wterm/ghostty/zig/src/wasm_api.zig new file mode 100644 index 0000000..0bb88ab --- /dev/null +++ b/packages/@wterm/ghostty/zig/src/wasm_api.zig @@ -0,0 +1,336 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const vt = @import("ghostty-vt"); +const Terminal = vt.Terminal; +const Screen = vt.Screen; +const RenderState = vt.RenderState; +const Style = vt.Style; +const color = vt.color; +const modes = vt.modes; + +const Allocator = std.mem.Allocator; +const allocator = std.heap.wasm_allocator; + +pub const std_options: std.Options = .{ + .logFn = wasmLog, +}; + +fn wasmLog( + comptime level: std.log.Level, + comptime scope: @TypeOf(.EnumLiteral), + comptime format: []const u8, + args: anytype, +) void { + _ = level; + _ = scope; + var buf: [2048]u8 = undefined; + const str = std.fmt.bufPrint(&buf, format, args) catch return; + JS.log(str.ptr, str.len); +} + +const JS = struct { + extern "env" fn log(ptr: [*]const u8, len: usize) void; +}; + +// --------------------------------------------------------------- +// Cell layout written into the JS-owned viewport buffer. +// 16 bytes per cell, little-endian. +// +// offset size field +// ------ ---- ----- +// 0 4 codepoint (u32) +// 4 1 fg_r +// 5 1 fg_g +// 6 1 fg_b +// 7 1 bg_r +// 8 1 bg_g +// 9 1 bg_b +// 10 1 flags (bold=1, faint=2, italic=4, underline=8, +// blink=16, inverse=32, invisible=64, +// strikethrough=128) +// 11 1 width (0 = spacer, 1 = normal, 2 = wide) +// 12 1 color_flags (bit 0 = has explicit fg, +// bit 1 = has explicit bg) +// 13 3 reserved +// --------------------------------------------------------------- +const CELL_BYTES = 16; + +const State = struct { + terminal: Terminal, + stream: vt.ReadonlyStream, + render: RenderState, +}; + +fn stateFromPtr(ptr: usize) *State { + return @ptrFromInt(ptr); +} + +// -- Lifecycle -------------------------------------------------- + +export fn init(cols: u16, rows: u16, max_scrollback: u32) usize { + const state = allocator.create(State) catch return 0; + state.terminal = Terminal.init(allocator, .{ + .cols = cols, + .rows = rows, + .max_scrollback = max_scrollback, + }) catch { + allocator.destroy(state); + return 0; + }; + state.stream = state.terminal.vtStream(); + state.render = RenderState.empty; + return @intFromPtr(state); +} + +export fn deinit(ptr: usize) void { + const state = stateFromPtr(ptr); + state.render.deinit(allocator); + state.stream.deinit(); + state.terminal.deinit(allocator); + allocator.destroy(state); +} + +export fn resize(ptr: usize, cols: u16, rows: u16) void { + const state = stateFromPtr(ptr); + state.terminal.resize(allocator, cols, rows) catch {}; +} + +// -- Data input ------------------------------------------------- + +export fn write(ptr: usize, data_ptr: [*]const u8, data_len: u32) void { + const state = stateFromPtr(ptr); + state.stream.nextSlice(data_ptr[0..data_len]) catch {}; +} + +// -- Render state ----------------------------------------------- + +export fn update(ptr: usize) void { + const state = stateFromPtr(ptr); + state.render.update(allocator, &state.terminal) catch {}; +} + +fn packFlags(style: Style) u8 { + var f: u8 = 0; + if (style.flags.bold) f |= 0x01; + if (style.flags.faint) f |= 0x02; + if (style.flags.italic) f |= 0x04; + if (style.flags.underline != .none) f |= 0x08; + if (style.flags.blink) f |= 0x10; + if (style.flags.inverse) f |= 0x20; + if (style.flags.invisible) f |= 0x40; + if (style.flags.strikethrough) f |= 0x80; + return f; +} + +fn resolveRgb(c: Style.Color, palette: *const color.Palette) color.RGB { + return switch (c) { + .none => .{}, + .palette => |idx| palette[idx], + .rgb => |rgb| rgb, + }; +} + +fn cellWidth(cell: vt.Cell) u8 { + return switch (cell.wide) { + .narrow => 1, + .wide => 2, + .spacer_tail, .spacer_head => 0, + }; +} + +/// Write the entire viewport into a JS-provided flat buffer. +/// Returns the number of cells written (rows * cols). +export fn get_viewport(ptr: usize, buf_ptr: [*]u8) u32 { + const state = stateFromPtr(ptr); + const rs = &state.render; + const rows = rs.rows; + const cols = rs.cols; + const palette = &rs.colors.palette; + + const row_cells_slice = rs.row_data.items(.cells); + + var offset: usize = 0; + for (0..rows) |y| { + if (y >= row_cells_slice.len) { + // Pad remaining rows with blank cells + const remaining = (@as(usize, rows) - y) * @as(usize, cols) * CELL_BYTES; + @memset(buf_ptr[offset .. offset + remaining], 0); + break; + } + const cells_mal = row_cells_slice[y]; + const raw_cells = cells_mal.items(.raw); + const style_cells = cells_mal.items(.style); + + for (0..cols) |x| { + if (x >= raw_cells.len) { + @memset(buf_ptr[offset .. offset + CELL_BYTES], 0); + offset += CELL_BYTES; + continue; + } + const raw = raw_cells[x]; + const style = style_cells[x]; + + const cp: u32 = switch (raw.content_tag) { + .codepoint, .codepoint_grapheme => raw.content.codepoint, + else => 0, + }; + + const has_fg = style.fg_color != .none; + const has_bg_style = style.bg_color != .none; + const has_bg_cell = raw.content_tag == .bg_color_palette or raw.content_tag == .bg_color_rgb; + const has_bg = has_bg_style or has_bg_cell; + + const fg = if (has_fg) resolveRgb(style.fg_color, palette) else color.RGB{}; + const bg = if (has_bg_cell) switch (raw.content_tag) { + .bg_color_palette => palette[raw.content.color_palette], + .bg_color_rgb => blk: { + const c = raw.content.color_rgb; + break :blk color.RGB{ .r = c.r, .g = c.g, .b = c.b }; + }, + else => unreachable, + } else if (has_bg_style) resolveRgb(style.bg_color, palette) else color.RGB{}; + + const flags = packFlags(style); + const width = cellWidth(raw); + const color_flags: u8 = (if (has_fg) @as(u8, 1) else 0) | (if (has_bg) @as(u8, 2) else 0); + + std.mem.writeInt(u32, buf_ptr[offset..][0..4], cp, .little); + buf_ptr[offset + 4] = fg.r; + buf_ptr[offset + 5] = fg.g; + buf_ptr[offset + 6] = fg.b; + buf_ptr[offset + 7] = bg.r; + buf_ptr[offset + 8] = bg.g; + buf_ptr[offset + 9] = bg.b; + buf_ptr[offset + 10] = flags; + buf_ptr[offset + 11] = width; + buf_ptr[offset + 12] = color_flags; + buf_ptr[offset + 13] = 0; + buf_ptr[offset + 14] = 0; + buf_ptr[offset + 15] = 0; + offset += CELL_BYTES; + } + } + + return @as(u32, rows) * @as(u32, cols); +} + +// -- Dirty tracking --------------------------------------------- + +export fn is_dirty(ptr: usize) u32 { + const state = stateFromPtr(ptr); + return switch (state.render.dirty) { + .false => 0, + .partial => 1, + .full => 2, + }; +} + +export fn is_dirty_row(ptr: usize, row: u16) u32 { + const state = stateFromPtr(ptr); + const row_dirty = state.render.row_data.items(.dirty); + if (row >= row_dirty.len) return 0; + return if (row_dirty[row]) 1 else 0; +} + +export fn clear_dirty(ptr: usize) void { + const state = stateFromPtr(ptr); + state.render.dirty = .false; + const row_dirty = state.render.row_data.items(.dirty); + for (row_dirty) |*d| d.* = false; +} + +// -- Cursor ----------------------------------------------------- + +export fn get_cursor_row(ptr: usize) u32 { + const state = stateFromPtr(ptr); + return state.render.cursor.active.y; +} + +export fn get_cursor_col(ptr: usize) u32 { + const state = stateFromPtr(ptr); + return state.render.cursor.active.x; +} + +export fn get_cursor_visible(ptr: usize) u32 { + const state = stateFromPtr(ptr); + return if (state.render.cursor.visible) 1 else 0; +} + +// -- Modes ------------------------------------------------------ + +export fn cursor_keys_app(ptr: usize) u32 { + const state = stateFromPtr(ptr); + return if (state.terminal.modes.get(.cursor_keys)) 1 else 0; +} + +export fn bracketed_paste(ptr: usize) u32 { + const state = stateFromPtr(ptr); + return if (state.terminal.modes.get(.bracketed_paste)) 1 else 0; +} + +export fn using_alt_screen(ptr: usize) u32 { + const state = stateFromPtr(ptr); + return if (state.terminal.screens.active_key != .primary) 1 else 0; +} + +// -- Grid dimensions -------------------------------------------- + +export fn get_cols(ptr: usize) u32 { + const state = stateFromPtr(ptr); + return state.render.cols; +} + +export fn get_rows(ptr: usize) u32 { + const state = stateFromPtr(ptr); + return state.render.rows; +} + +// -- Scrollback ------------------------------------------------- + +export fn get_scrollback_count(ptr: usize) u32 { + const state = stateFromPtr(ptr); + const screen: *Screen = state.terminal.screens.active; + var total: usize = 0; + var node_ = screen.pages.pages.first; + while (node_) |node| : (node_ = node.next) { + total += node.data.size.rows; + } + if (total <= state.terminal.rows) return 0; + return @intCast(total - state.terminal.rows); +} + +export fn get_scrollback_line(ptr: usize, offset: u32, buf_ptr: [*]u8, max_cols: u32) u32 { + _ = ptr; + _ = offset; + _ = buf_ptr; + _ = max_cols; + // TODO: scrollback line reading requires navigating the page list + // backwards. This is a complex operation that will be implemented + // when scrollback support is prioritized. + return 0; +} + +// -- Responses -------------------------------------------------- + +export fn read_response(ptr: usize, buf_ptr: [*]u8, buf_len: u32) u32 { + _ = ptr; + _ = buf_ptr; + _ = buf_len; + // The ReadonlyStream ignores queries that produce responses. + // A full-featured stream handler would be needed to support + // device status reports and other response-generating sequences. + return 0; +} + +// -- Memory management ------------------------------------------ + +export fn alloc_buffer(len: u32) usize { + const buf = allocator.alloc(u8, len) catch return 0; + return @intFromPtr(buf.ptr); +} + +export fn free_buffer(buf_ptr: usize, len: u32) void { + const slice: [*]u8 = @ptrFromInt(buf_ptr); + allocator.free(slice[0..len]); +} + diff --git a/packages/@wterm/react/src/Terminal.tsx b/packages/@wterm/react/src/Terminal.tsx index ff5dcb3..f37062b 100644 --- a/packages/@wterm/react/src/Terminal.tsx +++ b/packages/@wterm/react/src/Terminal.tsx @@ -5,7 +5,7 @@ import { forwardRef, type HTMLAttributes, } from "react"; -import { WTerm } from "@wterm/dom"; +import { WTerm, type TerminalCore } from "@wterm/dom"; // onResize and onError are omitted from HTMLAttributes because we redefine // them with different signatures (terminal dimensions / WASM init errors). @@ -15,6 +15,11 @@ export interface TerminalProps extends Omit< > { cols?: number; rows?: number; + /** + * A pre-constructed terminal core. When provided, `wasmUrl` is ignored and + * this core is used instead of loading the built-in Zig WASM binary. + */ + core?: TerminalCore; wasmUrl?: string; theme?: string; autoResize?: boolean; @@ -39,6 +44,7 @@ const Terminal = forwardRef(function Terminal( { cols = 80, rows = 24, + core, wasmUrl, theme, autoResize = false, @@ -92,6 +98,7 @@ const Terminal = forwardRef(function Terminal( const wt = new WTerm(el, { cols, rows, + core, wasmUrl, autoResize: autoResizeRef.current, cursorBlink, @@ -125,7 +132,7 @@ const Terminal = forwardRef(function Terminal( }, // Re-run only when the WASM source changes // eslint-disable-next-line react-hooks/exhaustive-deps - [wasmUrl], + [core, wasmUrl], ); // Sync props to the existing instance (render-time checks) diff --git a/packages/@wterm/vue/src/Terminal.ts b/packages/@wterm/vue/src/Terminal.ts index 198ac79..cc6e4df 100644 --- a/packages/@wterm/vue/src/Terminal.ts +++ b/packages/@wterm/vue/src/Terminal.ts @@ -7,8 +7,9 @@ import { onMounted, onBeforeUnmount, watch, + type PropType, } from "vue"; -import { WTerm } from "@wterm/dom"; +import { WTerm, type TerminalCore } from "@wterm/dom"; /** * Vue wrapper around {@link WTerm} from `@wterm/dom`. Creates a `WTerm` in @@ -59,6 +60,11 @@ const Terminal = defineComponent({ * @defaultValue 24 */ rows: { type: Number, default: 24 }, + /** + * A pre-constructed terminal core. When provided, `wasmUrl` is ignored and + * this core is used instead of loading the built-in Zig WASM binary. + */ + core: { type: Object as PropType, default: undefined }, /** * Optional override for the WASM binary URL used by the terminal core. */ @@ -129,6 +135,7 @@ const Terminal = defineComponent({ const wt = new WTerm(el, { cols: props.cols, rows: props.rows, + core: props.core, wasmUrl: props.wasmUrl, autoResize: props.autoResize, cursorBlink: props.cursorBlink, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c2d832..432b5e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,7 +127,7 @@ importers: version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: 16.2.3 - version: 16.2.3(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + version: 16.2.3(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) tailwindcss: specifier: ^4 version: 4.2.2 @@ -135,6 +135,22 @@ importers: specifier: ^6.0.2 version: 6.0.2 + examples/ghostty: + dependencies: + '@wterm/dom': + specifier: workspace:* + version: link:../../packages/@wterm/dom + '@wterm/ghostty': + specifier: workspace:* + version: link:../../packages/@wterm/ghostty + devDependencies: + typescript: + specifier: ^6.0.2 + version: 6.0.2 + vite: + specifier: ^6.3.5 + version: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + examples/local: dependencies: '@tailwindcss/postcss': @@ -146,6 +162,9 @@ importers: '@wterm/dom': specifier: workspace:* version: link:../../packages/@wterm/dom + '@wterm/ghostty': + specifier: workspace:* + version: link:../../packages/@wterm/ghostty '@wterm/react': specifier: workspace:* version: link:../../packages/@wterm/react @@ -191,7 +210,7 @@ importers: version: 9.39.4(jiti@2.6.1) eslint-config-next: specifier: 16.2.3 - version: 16.2.3(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) + version: 16.2.3(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) tsx: specifier: ^4.19.4 version: 4.21.0 @@ -507,6 +526,19 @@ importers: specifier: ^6.0.2 version: 6.0.2 + packages/@wterm/ghostty: + dependencies: + '@wterm/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@internal/ts': + specifier: workspace:* + version: link:../../@internal/ts + typescript: + specifier: ^6.0.2 + version: 6.0.2 + packages/@wterm/just-bash: devDependencies: '@internal/ts': @@ -11245,8 +11277,8 @@ snapshots: '@next/eslint-plugin-next': 16.2.3 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -11288,21 +11320,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) - get-tsconfig: 4.13.7 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.16 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -11314,32 +11331,22 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2) - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -11350,7 +11357,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -11379,7 +11386,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3