# @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.
The core exposes mouse tracking (modes 1000, 1002, and 1003), its active wire encoding through `mouseEncoding()`, focus reporting (mode 1004), synchronized-output state (mode 2026), Kitty keyboard negotiation, and terminal responses including foreground/background color queries (OSC 10 and OSC 11) to `@wterm/dom`. With the DOM layer, X10, UTF-8 (1005), SGR (1006), urxvt (1015), and SGR pixel (1016) reports are supported. Mode 1003 reports unpressed pointer movement once per cell for cell formats and once per CSS pixel for 1016.
Combining marks and ZWJ emoji are exposed through `CellData.chars` as complete strings, including after their rows move into scrollback.
`CellData.spacerHead` marks the empty right-edge filler before a wrapped wide glyph. WTerm's full-history Find uses this flag, grapheme strings, and native row-wrap metadata to match text across soft wraps without inserting artificial spaces.
The bindings decode extended cell attributes without temporary style objects to
reduce allocation overhead when searching large histories. The
[history-search measurements](../../../e2e/harness/README.md#history-search-measurements)
cover sparse and absent matches in 100,000 retained lines across three browsers.
WTerm also uses this metadata when copying native text selections: soft wraps join without newlines, spacer heads are omitted, and partial graphemes copy as whole cells. Explicit newlines remain intact. See [selection and copy](../dom/README.md#selecting-and-copying-text).
`trackPosition({ row, col })` follows a retained cell through scrolling and reflow. The returned handle has `resolve()` and `dispose()` methods; row zero is the oldest retained row. It resolves to `null` after pruning, reset, screen switching, reinitialization, or disposal. Up to 64 simultaneous handles are supported; invalid coordinates, exhausted capacity, and older binaries return `null`. Release handles when finished. WTerm uses this API to preserve native selections and separately checks for overwritten text. See the [core contract](../core/README.md#tracked-cell-positions).
`getColorOverrides()` exposes application-requested default foreground,
background, and cursor colors from OSC 10/11/12. The DOM renderer applies them
to live cells, retained history, the terminal background, and cursor shapes.
OSC 110/111/112 restores the current CSS theme without rewriting host theme
variables. Explicit SGR colors remain unchanged. Serve the current WASM binary;
older binaries return an empty snapshot and retain their CSS defaults.
Ghostty preserves single, double, curly, dotted, and dashed underlines through
`CellData.underlineStyle`, with resolved colors in `underlineRgb`. The DOM
renderer displays them in the viewport and scrollback, including after reflow.
SGR `4:1` through `4:5` select the styles; `4` selects single and `21` selects
double. SGR `58` sets an indexed or RGB underline color, `59` restores the
foreground color, and `24` or `4:0` removes the underline. SGR `0` resets both.
Strikethrough stays solid and follows the text color. Apps serving an older
WASM file retain single underlines; serve the binary shipped with the package
to enable the additional styles and colors.
`getCursor()` exposes Ghostty's block, bar, or underline `shape` and `blinking`
state. The DOM renderer follows application requests (DECSCUSR and mode 12);
an explicit `cursorBlink: true` or `false` on the terminal wrapper overrides
blinking. Omitting the option follows the application, initially steady.
OSC 0 and OSC 2 window-title changes reach `getTitle()` and the terminal
wrapper's `onTitle` callback, including an empty title that clears the current
name. The callback runs as output is written, including while painting is
paused. Several changes within a parsed chunk may coalesce to the latest
complete title. Titles longer than Ghostty's 255-byte limit are ignored.
`getBellCount()` reads and clears Ghostty's pending BEL count. `WTerm` forwards
it through `onBell(count)` as output is written, even when synchronized output
holds painting. BEL used to terminate an OSC sequence is excluded.
OSC 52 clipboard writes reach `getClipboardWrite()` and `onClipboardWrite(text)`.
The default selector and `c` are supported, with valid base64-encoded UTF-8 up
to 65,536 decoded bytes; empty text requests a clear. Encoded accumulation is
also bounded. Queries, other selectors, invalid text, and oversized requests
are ignored. Only the latest valid pending write is retained. The host chooses
whether to accept it; no browser clipboard access is automatic. Serve the
updated bundled WASM to enable this optional effect.
Native OSC 8 hyperlinks are resolved from Ghostty's page-owned metadata and exposed through `CellData.linkUri`, `CellData.linkId`, and `CellData.linkKey` in both the viewport and scrollback.
The Ghostty core also provides the optional terminal graphics API. The DOM
renderer displays direct Kitty Graphics Protocol PNG/RGB/RGBA images as
transient, bounded canvas overlays. Pinned placements follow scrollback,
scrolling, resize, and primary/alternate screen changes. Sixel, iTerm2/OSC
1337, animation, virtual Unicode placements, file/shared-memory/URL media, and
image persistence are not supported. Auto-sized (implicit) placements align
with the terminal content origin and reserve their rendered height in the
visual DOM flow so prompts emitted after an image remain visible below it.
Ghostty also exposes the cumulative number of rows discarded from the oldest end of scrollback. `@wterm/dom` uses that signal to keep retained history anchored when the page budget rolls over.
`getRowMetadata(row)` and `getScrollbackRowMetadata(offset)` return
`{ wrapsToNext, continuesPrevious }` from Ghostty's native row flags. Live rows
start at zero; history offset zero is the newest retained row. They distinguish
soft wraps from explicit newlines, including across the live/history boundary
and after resize reflow. Results are snapshots: re-read after output or resize,
and treat row indexes as temporary. A retained row can continue an older row
that has already been discarded. Invalid positions, disposed cores, and older
WASM binaries without these exports return `null` rather than a hard-line
assumption. The methods read terminal state immediately, without requiring a
render or consuming dirty flags.
Kitty keyboard flags stay authoritative in Ghostty's active screen. Queries return the native value, primary and alternate screens negotiate independently, DECSTR preserves the flags, and RIS clears them. `@wterm/dom` encodes browser keyboard events from those flags with the browser limitations documented in its README.
## 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
```
### Svelte
```svelte
{#if core}
{/if}
```
## Options
`GhosttyCore.load()` accepts an options object:
| Option | Type | Description |
|---|---|---|
| `wasmPath` | `string` | Custom path to the ghostty-vt WASM binary |
| `scrollbackLimit` | `number` | Scrollback budget in bytes, not lines (default: 10000). ghostty allocates history in pages, so the retained row count depends on the terminal width |
| `foregroundColor` | `string` | Foreground reported by OSC 10 in `#RRGGBB` format (default: `#d4d4d4`) |
| `backgroundColor` | `string` | Background reported by OSC 11 in `#RRGGBB` format (default: `#1e1e1e`) |
| `imageStorageLimit` | `number` | Maximum decoded Kitty image bytes per screen (default: 32 MiB; `0` disables graphics) |
When using a custom CSS theme, pass matching foreground and background colors so terminal applications receive the colors they are actually rendered with:
```ts
const core = await GhosttyCore.load({
foregroundColor: "#ededed",
backgroundColor: "#0a0a0a",
imageStorageLimit: 32 * 1024 * 1024,
});
```
The image limit applies to decoded image storage, not browser canvas count.
Each direct image is also capped at `MAX_IMAGE_BYTES` (32 MiB), even when a
larger `imageStorageLimit` is configured; the larger budget can hold multiple
smaller images. Each screen also retains at most 4,096 image descriptors and
4,096 placements, so unique tiny-image churn cannot grow WASM metadata without
bound; additional records fail closed until existing records are removed.
The DOM overlay independently caps visible canvas backing stores at 32 MiB and
bounds each destination canvas to the terminal pixel area; placements that do
not fit those browser limits are skipped.
Ghostty rejects oversized, malformed, and non-direct media before any file or
shared-memory access. `getResourceState()` reports image count, placement
count, bytes used/capacity, rejections, evictions, and saturation.
The core returns copied metadata and RGBA buffers through the optional
`TerminalCore` graphics methods. Call `core.dispose()` when the application
owns the core lifecycle; `WTerm.destroy()` cleans up its DOM layer but never
disposes a caller-supplied core automatically.
## Updating a running theme
`core.setThemeColors(colors)` updates configured foreground/background/cursor and
16 ANSI defaults using `TerminalThemeColors` from `@wterm/core`. Values are integer
RGB colors from `0` to `0xffffff`; the palette must have exactly 16 entries.
Invalid values throw `RangeError` without changing defaults. The core copies the
values and retains them through `init()` calls. Updates preserve partial parser
input, modes, screens, history, and application overrides; OSC resets return to
the latest host defaults. Higher palette indices and explicit RGB cells are
unchanged. No terminal response is generated by the update itself.
For a browser terminal, call `wt.setThemeColors(colors)` instead so CSS and
rendered rows update together. This requires the updated bundled WASM asset;
older assets without the theme export keep their current engine defaults.
## Bundlers
The WASM binary is fetched at runtime, not inlined, so the default has to resolve to a URL your app actually serves. `GhosttyCore.load()` resolves it with `new URL("../wasm/ghostty-vt.wasm", import.meta.url)`. Bundlers that implement that asset pattern emit the binary and rewrite the URL; ones that do not leave `import.meta.url` pointing at the machine that built the bundle.
| Bundler | Default `GhosttyCore.load()` | Verified |
|---|---|---|
| Vite (dev and build) | works, emits a hashed asset | yes |
| Bun dev server | fails, pass `wasmPath` | yes |
| Others | untested, use `wasmPath` if the default throws | no |
When the default cannot work, serve the binary yourself and point at it:
```bash
cp node_modules/@wterm/ghostty/wasm/ghostty-vt.wasm public/ghostty-vt.wasm
```
```ts
const core = await GhosttyCore.load({ wasmPath: "/ghostty-vt.wasm" });
```
The binary is also addressable as `@wterm/ghostty/ghostty-vt.wasm`, so a bundler with a URL import can take it directly:
```ts
import wasmPath from "@wterm/ghostty/ghostty-vt.wasm?url";
const core = await GhosttyCore.load({ wasmPath });
```
## 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 → 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`, expose the discarded-row count from `PageList`, forward the per-screen image limit, bound and compact Kitty image/placement metadata, and make direct PNG decoding work without POSIX time. It also adds the Wuffs freestanding compatibility include/source configuration in `zig/build.zig`. The patches are pinned to ghostty v1.3.1 and touch these upstream files:
- `src/terminal/Terminal.zig`
- `src/terminal/kitty/graphics_image.zig`
- `src/terminal/kitty/graphics_storage.zig`
- `src/terminal/kitty/graphics_exec.zig`
- `src/terminal/kitty/graphics_unicode.zig`
- `src/terminal/page.zig`
- `src/terminal/PageList.zig`
Page allocation also includes upstream's [WASM initialization fix](https://github.com/ghostty-org/ghostty/commit/420de124f04aa322bf250098cc62d7195db94bfd): initial and replacement terminal pages are cleared before use in release builds. Unlike native OS page allocation, the WASM allocator can return previously used memory. This keeps new page state independent of earlier terminal contents.
The Wuffs compatibility headers used by the build are `zig/src/wuffs-compat/{stdbool.h,stddef.h,stdint.h,stdlib.h,string.h}`; Wuffs itself is fetched from the pinned dependency in `zig/build.zig.zon`.
The committed `wasm/ghostty-vt.wasm` binary means consumers never need Zig installed. Only maintainers rebuilding the WASM need Zig 0.15.2.
### Rebuilding the WASM
Requires [Zig 0.15.2](https://ziglang.org/download/), Bash, and Python 3:
```bash
pnpm --filter @wterm/ghostty rebuild-wasm
```
The script checks the exact compiler version, fetches the upstream URL and
content hash from `zig/build.zig.zon`, and applies the WASM patches inside a
fresh temporary dependency cache. It verifies that applying the patches twice
produces identical files, builds `wasm32-freestanding` with `ReleaseSmall`,
and copies the completed binary to `wasm/ghostty-vt.wasm`. Temporary sources,
caches, and build output live under `/tmp` and are removed when the build
exits. Your shared Zig cache is never read or patched.
The script finds Zig 0.15.2 on `PATH` or in the usual zigup installation.
To select an executable explicitly, set `WTERM_GHOSTTY_ZIG=/path/to/zig`.
Zig 0.15.2 cannot link its native build runner on macOS 26; use Docker there:
```bash
pnpm --filter @wterm/ghostty rebuild-wasm:docker
```
The Linux installer used by Docker and CI verifies the compiler archive's
SHA-256 before extracting it. Version and archive checksums are recorded in
`scripts/zig-toolchain.sh`; consumers still only need the committed WASM.
To verify the committed binary without replacing it:
```bash
pnpm --filter @wterm/ghostty check-wasm
# Or use a Linux container with the checkout mounted read-only:
pnpm --filter @wterm/ghostty check-wasm:docker
```
CI runs the Ghostty drift check on every PR and push to `main`, separately from
the built-in core's Zig 0.16 build. A missing or differing artifact fails the
check with a rebuild command. Commit the regenerated WASM with changes to the
adapter, upstream dependency, patches, or toolchain that affect its output.
### Public API experiment
The repository also contains an isolated
[public libghostty WASM probe](../../../experiments/libghostty/README.md), using
an unpatched upstream revision and Zig 0.16.0. Run `pnpm test:libghostty` from
the repository root to build it and exercise rendering, terminal effects,
history, and snapshots in Node and browser engines. It does not replace this
package's v1.3.1 binary: the probed freestanding build disables Kitty graphics,
and the public API still has gaps against the adapter's history, hyperlink
identity, and resource-reporting contracts. The experiment documents the
verified behavior and remaining compatibility gaps.
### 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` |
|---|---|---|
| WASM binary size | ~26 KB | ~580 KB |
| 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 |
## Shell command state
`getShellIntegrationState()` consumes the latest OSC 133 state change, forwarded
by WTerm as `onShellIntegration({ phase, exitCode })`. A/N/P mark `prompt`, B/I
mark `input`, C marks `running` and clears the previous exit code, and D marks
`complete` with a signed 32-bit exit code or `null` when missing/invalid. L only
controls line layout. Unknown commands leave the state unchanged. Markers on
the alternate screen do not replace the primary shell state.
Prompt redraws retain the previous completion code; RIS reports `unknown` and
clears it. Init/dispose discard pending state. Multiple unread changes coalesce
to one caller-owned snapshot, using constant storage and retaining no command
text. This is shell-reported state, not proof that a process exited or a complete
command log. Older WASM assets return `null`. The shell must emit OSC 133;
loading the core does not install shell hooks. See
[Shell integration](https://wterm.dev/configuration#shell-integration).
`findPrompt(row, -1 | 1)` looks up the previous or next prompt start using native
retained metadata. Row zero is the oldest retained row. Continuation and
reflowed rows belong to one prompt; pruned history is unavailable. The origin
must be an integer within the retained buffer. Invalid inputs, alternate-screen
queries, older WASM assets, and missing prompts return `null`. Lookup does not
move the terminal cursor or consume shell-state changes. WTerm exposes viewport
navigation as `scrollToPrompt(direction)`.
## License
Apache-2.0