WIP: Upgrade to Ghostty 1.3#162
Open
neomantra wants to merge 29 commits intocoder:mainfrom
Open
Conversation
ghostty-org/main now ships the full libghostty-vt C API that
coder/ghostty-web's patch was carving out (terminal.zig + terminal.h,
kitty_graphics, render, size_report, mouse, etc.), the WASM build
target via -Demit-lib-vt, and the PageList zero-init fix on
freestanding. The 1591-line patch is no longer needed — the WASM
build now succeeds with zero patches.
- Submodule: 5714ed07a (Dec 1, 2025) -> 659019666 (Apr 27, 2026)
- patches/ghostty-wasm-api.patch: emptied (file kept as framework for
any minimal patches we may still need)
- scripts/build-wasm.sh:
- zig build lib-vt -> zig build -Demit-lib-vt
- fix gitlink detection: [ -d ghostty/.git ] always failed because
a submodule's .git is a file, not a directory, so submodule update
was running on every build and reverting any local SHA bump
- make patch application optional (skip when patch is empty)
- use generic git restore/clean for submodule cleanup instead of
hardcoded include/ghostty/vt/terminal.h and src/terminal/c/terminal.zig
removals (those files now exist upstream)
Signed-off-by: Evan Wies <evan@neomantra.net>
Upstream replaces coder's (cols, rows[, configPtr]) -> handle constructor with: ghostty_terminal_new(allocator, *terminal, options) -> Result The handle is returned via an out-pointer; status is a Result code. The patch-only `_with_config` convenience (packed scrollback + colors) no longer exists. Adjacent renames in the same ABI cut: - ghostty_terminal_write -> ghostty_terminal_vt_write - ghostty_terminal_resize now takes cell_width_px / cell_height_px Color/palette/cursor-color config is deferred — needs follow-up using ghostty_terminal_set with GHOSTTY_TERMINAL_OPT_COLOR_*. Test failure count is unchanged (218/331) but the failure surface advances from "no constructor" to render-state API drift, confirming the construction path is correct. Signed-off-by: Evan Wies <evan@neomantra.net>
Upstream consolidated coder's per-field render-state API (cursor_x/y/
visible, bg/fg_color, is_row_dirty, mark_clean) into a single explicit
RenderState object queried via:
ghostty_render_state_get(state, key, *out) keyed by enum
ghostty_render_state_set(state, option, *val)
+ new/update/free/get_multi/colors_get
GhosttyTerminal owns the render state internally — created in the
constructor right after the terminal, freed before it. The external
surface (update/getCursor/getColors/markClean) is preserved.
- rsGetU8/U16/U32/Rgb helpers wrap _get with a typed scratch buffer
per call. Per-call allocation is intentional; easy to swap for a
reusable buffer if profiling shows it hot.
- getCursor() pulls CURSOR_{VIEWPORT_HAS_VALUE,VISIBLE,BLINKING,
VISUAL_STYLE} + VIEWPORT_X/Y when in-viewport. Upstream's four-style
enum collapses BLOCK_HOLLOW into 'block' for coder's three-style type.
- getColors() reads COLOR_BACKGROUND/_FOREGROUND, then COLOR_CURSOR
when COLOR_CURSOR_HAS_VALUE.
- markClean() sets OPTION_DIRTY=FALSE via _set.
Deferred: isRowDirty / getViewport / getGrapheme need the row iterator
+ row_cells API. Stubbed with explicit "not yet implemented" errors
and TODO breadcrumbs.
Adds RenderStateData / RenderStateOption / CursorVisualStyle enums.
Test count unchanged (113/218) but the failure mode advances past the
render-state path — first failure is now
ghostty_terminal_get_scrollback_length, the next API family to rewire.
Signed-off-by: Evan Wies <evan@neomantra.net>
Same shape as the render-state cut: upstream consolidated coder's per-property terminal getters into ghostty_terminal_get(terminal, key, *out) keyed by enum ghostty_terminal_mode_get(terminal, mode_u16, *bool) Modes are now a packed u16 (low 15 bits = value, bit 15 = ANSI flag), exposed via a packMode(value, isAnsi) helper. Wired up via _get: - getScrollbackLength -> SCROLLBACK_ROWS (size_t) - isAlternateScreen -> ACTIVE_SCREEN (GhosttyTerminalScreen enum) - hasMouseTracking -> MOUSE_TRACKING (bool) Wired up via _mode_get: - getMode(value, isAnsi) Deferred (need grid_ref + row_cells API) — explicit TODO + throw: - getScrollbackLine / getScrollbackGrapheme / getScrollbackHyperlinkUri - isRowWrapped / getHyperlinkUri Architectural change, not a rename — defer with quiet defaults: - hasResponse() -> false - readResponse() -> null The replacement is callback-based: install via ghostty_terminal_set(GHOSTTY_TERMINAL_OPT_WRITE_PTY, fn) and capture the synchronous invocation during vt_write. Returning false/null lets demos that don't generate DSR queries continue to run. Adds tGetU8 / tGetU32 helpers mirroring rsGet* and TerminalData / TerminalScreen / packMode in types.ts. Net -250 / +134: a lot of buffer-fill plumbing for now-removed symbols got deleted. Test count unchanged (113/218); failure mode advances past the terminal-property layer — every remaining failure now lands on the getViewport stub during term.open()'s initial draw. Row iterator + row_cells API is the next surface. Signed-off-by: Evan Wies <evan@neomantra.net>
The new C ABI exposes per-row + per-cell data via two pre-allocated
iterators that are repopulated each frame:
// Allocated once at construction time.
ghostty_render_state_row_iterator_new(allocator, &rowIter)
ghostty_render_state_row_cells_new(allocator, &rowCells)
// Per frame.
_get(state, ROW_ITERATOR, &rowIter) // bind iter
while (row_iterator_next(rowIter)) {
_row_get(rowIter, ROW_DATA_CELLS, &rowCells) // bind cells
while (row_cells_next(rowCells)) {
_row_cells_get(rowCells, GRAPHEMES_LEN, &len)
_row_cells_get(rowCells, GRAPHEMES_BUF, &cp) // if len > 0
_row_cells_get(rowCells, FG_COLOR/BG_COLOR, &rgb) // INVALID if unset
}
}
GhosttyTerminal owns both iterators for its lifetime. The "_get with
handle as both in and out" pattern (the function reads *out to find
the handle and re-binds its internal state) is factored into a small
populateHandle() helper.
Cell shape compromises in this pass — TODO breadcrumbs left in place:
- flags=0 (needs GhosttyStyle sized struct parsing)
- width=1 (needs RAW + cell_get(WIDE))
- hyperlink_id=0 (needs RAW + cell_get(HAS_HYPERLINK))
3-4 WASM crossings per cell — slow but correct. Optimization candidates
(_row_cells_get_multi for batch, RAW + cached struct layout for direct
read) are intentionally deferred until a profile says they matter.
isRowDirty() shares the same row iterator with a per-row dirty cache
invalidated by update(). First call walks rows once; subsequent calls
are O(1). getViewport() opportunistically populates the cache as a
side effect, so the typical render loop iterates rows just once.
Constructor cleanup: allocOpaqueOrFail() factors the new(allocator,
*outHandle) pattern; cleanupOnConstructorFailure() unwinds partially-
built state in reverse-allocation order so a midway failure doesn't
leak handles.
Adds RenderStateRowData / RowCellsData enums plus row-iterator /
row-cells exports to types.ts.
Test status: 113/218 -> 259/72. 146 tests crossed over; expect() calls
jumped from 229 to 599 — tests now run their bodies instead of dying
at term.open(). Remaining 72 cluster on the still-stubbed grid_ref
surface (isRowWrapped, getHyperlinkUri, scrollback iteration) and on
the deferred cell fields (style flags, double-width detection).
Signed-off-by: Evan Wies <evan@neomantra.net>
Three independent pieces sharing the row iterator + row_cells API established last commit. # Style flags getViewport's cell loop now reads STYLE — a 72-byte sized struct — and unpacks bold/italic/faint/blink/inverse/invisible/strikethrough from offsets 56-62 into coder's CellFlags bitmask. Underline at offset 64 is an i32 enum (NONE/SINGLE/DOUBLE/CURLY/DOTTED/DASHED); collapse any non-zero into the single UNDERLINE flag. Layout discovered via ghostty_type_json's runtime introspection — saves us from hand-computing wasm32 alignment on a struct that contains GhosttyStyleColor (size 16, align 8 from a u64-padded union). The style buffer is allocated once per getViewport call; only the size field is initialized, the populator fills the rest each cell. # isRowWrapped Reads ROW_DATA_RAW from the iterator to obtain a GhosttyRow (u64), passed as a BigInt to ghostty_row_get(row, WRAP_CONTINUATION, *bool). Test-driven semantic discovery — coder's isRowWrapped(y) means "is row y a continuation of an earlier wrap" (WRAP_CONTINUATION), not "does row y's text wrap onto the next" (WRAP). Forced by: // Second line should be wrapped (continuation) expect(isRowWrapped(1)).toBe(true) Same lazy-cache discipline as rowDirtyCache. Renamed the refresh to refreshRowMetaCache and made it call update() first — fixes a stale- state bug in tests that write then query without an explicit refresh, mirroring getCursor / getColors. # getGrapheme Walks the row iterator forward to the target row, binds cells to that row, calls _row_cells_select(col) to position, then reads GRAPHEMES_LEN and GRAPHEMES_BUF (len*4-byte u32 buffer). Copies the array out before freeing — the Uint32Array view shares the WASM buffer, and a subsequent allocation could detach it. # Grapheme len semantics Upstream's GRAPHEMES_LEN includes the base codepoint: empty cell -> 0 simple ASCII 'a' -> 1 ZWJ family emoji -> N Coder's cell.grapheme_len counts only extras beyond base (matching the old C ABI). getViewport now subtracts one (clamped at 0). Empty cells stay 0, ASCII reads 0, clusters read N-1. The full count is available through getGrapheme(). # New types RowData enum (WRAP, WRAP_CONTINUATION, GRAPHEME, STYLED, HYPERLINK) and the ghostty_row_get(row: bigint, key, *out) export. # Test status 305/26, up from 259/72 — 46 more tests crossed over. Remaining 26: - scrollback iteration (~17): getScrollbackLine / Grapheme / HyperlinkUri stubs need grid_ref for off-viewport rows - Terminal Config (2): theme/palette colors must be applied via ghostty_terminal_set(COLOR_*) after construction - Alternate Screen dirty marking and misc (~7) Signed-off-by: Evan Wies <evan@neomantra.net>
Closes the last five failures with three independent fixes plus a
dead-code sweep.
# Scrollback iteration via grid_ref
readGridLine / readHyperlinkUri / getScrollbackGrapheme share a
pattern:
1. Build a GhosttyPoint (24 bytes — tag@0:u32, value.coordinate@8:
{ x:u16@0, y:u32@4 }). Zero-init the union padding so stale
memory doesn't leak in.
2. Resolve via ghostty_terminal_grid_ref(terminal, &pt, &ref).
3. Either step along the row by mutating ref.x in place (cheap,
no re-resolution), or use the buffer-fill APIs two-pass: first
call with bufLen=0 to size, allocate exactly, second to fill.
Cell content is codepoint-only (grid_ref_cell + cell_get(CODEPOINT)).
The text-extraction tests that drove this commit only check
codepoints; fuller resolution (style + colors) can come back later.
# markClean per-row dirty
Per the upstream contract: "setting one dirty state doesn't unset
the other." After _set(OPTION_DIRTY, FALSE), walk the row iterator
and _row_set(OPTION_DIRTY, FALSE) on each row — otherwise the next
update() keeps reporting the old per-row flags as dirty even though
the terminal hasn't changed. Also nulls rowDirtyCache so the
side-effect cache doesn't hold stale values.
# Constructor: config colors + mode 2027
applyConfig() calls ghostty_terminal_set for fg / bg / cursor (0 =
"use default", skipped) and for the palette merge: read the existing
COLOR_PALETTE_DEFAULT into a 768-byte buffer, overlay the 16 ANSI
entries from config where non-zero, write back via COLOR_PALETTE.
Preserves indices ≥16 from the upstream default palette.
ghostty_terminal_mode_set(packMode(2027, false), true) replaces
coder's patch-side `terminal.modes.set(.grapheme_cluster, true)`.
Mode 2027 (grapheme clustering) was a coder default that the new
public C ABI doesn't apply automatically.
# Dead code
Pruned viewportBufferPtr/Size, CELL_SIZE, parseCellsIntoPool,
graphemeBuffer*, invalidateBuffers — all dead since getViewport and
getGrapheme moved to the iterator API a few commits ago.
# New types
TerminalOption enum (callbacks + COLOR_*), TerminalData
COLOR_PALETTE_DEFAULT (with the _DEFAULT siblings for symmetry),
PointTag, CellData, plus six grid_ref export signatures.
ghostty_cell_get fixed from number to bigint (it's a u64 like
GhosttyRow).
# Test status
331/0, up from 305/26 — closing the migration started six commits
ago at 113/218. expect() calls: 767 (was 229). Every deferred
surface (style flags on scrollback cells, double-width detection,
write_pty callback) is documented in TODOs and stubbed with
sensible defaults rather than throws, so the existing test suite
and downstream callers degrade gracefully.
Signed-off-by: Evan Wies <evan@neomantra.net>
Coder's old WASM polled responses with ghostty_terminal_has_response
/ _read_response. Upstream replaced both with a callback installed
via ghostty_terminal_set(WRITE_PTY, fn): the terminal calls back
during vt_write() with response bytes for DSR replies, in-band size
reports, XTVERSION, and friends. Without it, query sequences like
\x1b[6n hang TUI apps (vim, htop, less +F) waiting for a reply.
Two layers:
# Trampoline (lib/write_pty_trampoline.{wat,ts})
The standard JS path for installing a JS function as a WASM-callable
function pointer is `new WebAssembly.Function(...)`. That's stage-3
Type Reflection — only Chrome ships it. Bun and Node 25 both report
`typeof WebAssembly.Function === 'undefined'`.
Workaround: a separate 59-byte WASM module that imports the JS
callback as `env.cb` and re-exports a typed wrapper. The wrapper's
exported funcref is portable across modules with compatible funcref
tables, so we install it into the main libghostty-vt module's
__indirect_function_table and pass that index to terminal_set. The
.wat source is checked in next to the byte-literal .ts; rebuild
with `wat2wasm lib/write_pty_trampoline.wat` if the WAT changes.
No mandatory build step — 59 bytes doesn't justify one.
# Per-table registry (lib/ghostty.ts)
Routing happens via a WeakMap<WebAssembly.Table, registry> keyed on
the indirect function table — NOT a process-wide static. Terminal
handles are only unique within a single WASM instance, and a slot
index in module A's table is meaningless in module B's, so two
parallel Ghostty.load() instances need separate registries.
(Caught in code review; first naive impl trapped with "Out of bounds
call_indirect" on the second instance.)
Each registry holds its trampoline slot index plus a
Map<handle, GhosttyTerminal> for routing. Terminals register on
construction, deregister in free() and in cleanupOnConstructorFailure
(post-terminal_new init is now wrapped in try/catch so a failure
mid-way doesn't leak a map entry).
readResponse() concatenates pending chunks and decodes UTF-8;
hasResponse() lets callers short-circuit when nothing is queued.
# Tests
- DSR 6 round-trip: write \x1b[6n, expect "\x1b[1;1R"
- Multi-instance isolation: two Ghostty.load()s, both respond
correctly to their own DSR
Test count: 331/0 -> 333/0, expect() calls 767 -> 774.
Signed-off-by: Evan Wies <evan@neomantra.net>
The new C ABI returns INVALID_VALUE from row_cells_get(FG_COLOR) for cells with no explicit foreground, which getViewport leaves as (0,0,0). The renderer's existing isDefaultBg path treats (0,0,0) as "use theme background, let it show through"; the foreground path had no equivalent and rendered literal black. Result on a dark theme: default-fg text invisible (vim, htop, etc. unreadable in the demo). Coder's old WASM wrote resolved colors into the cell struct so the bug didn't surface there. Fix mirrors the bg path — when fg ends up at (0,0,0) after the INVERSE swap, fall back to this.theme.foreground. Same sentinel collision as bg: an explicit SGR 30 (literal black) also goes through the default path. Coder's bg side Signed-off-by: Evan Wies <evan@neomantra.net>
Coder's old WASM packed an explicit width byte into the cell struct parsed by getViewport, so wide chars (CJK ideographs, most emoji) rendered at width 2 and the spacer cell that follows rendered at width 0. The new C ABI doesn't expose width at the row_cells level — it only ships through ghostty_cell_get(cell, WIDE, *enum) on a raw GhosttyCell value. Per cell, we now also read row_cells_get(RAW) → 8-byte GhosttyCell (u64) → cell_get(cellU64, WIDE) → 4-byte enum, then map: NARROW (0) -> width 1 WIDE (1) -> width 2 SPACER_TAIL (2) -> width 0 SPACER_HEAD (3) -> width 0 Two extra WASM crossings per cell. The renderer's existing `if (cell.width === 0) continue` skips spacers correctly, and `cellWidth = metrics.width * cell.width` gives wide cells double the canvas width. Smoke test "あい🎉ok" → widths [2, 0, 2, 0, 2, 0, 1, 1, 1, 1]. Adds CellWide enum to types.ts. Signed-off-by: Evan Wies <evan@neomantra.net>
Two-half regression that has to land together.
# Cell pixel dims (lib/ghostty.ts, lib/terminal.ts)
Coder's old setPixelSize export took total screen pixels; the new
C ABI removed it and routes everything through
ghostty_terminal_resize(cols, rows, cell_width_px, cell_height_px).
cell_width_px is PER-CELL, not total.
GhosttyTerminal.setCellPixelSize(cellWidthPx, cellHeightPx) caches
the pair on the instance and re-pushes via _resize when either
axis changes; resize() reuses the cached pixel dims so both axes
stay coherent. Renamed from setPixelSize so a stale caller can't
silently mis-pass total pixels.
Terminal.updateWasmPixelSize() threads renderer.getMetrics() —
already per-cell — into the new method from setup, open(), and
resize() (same three points coder had).
# SIZE callback (lib/write_pty_trampoline.{wat,ts}, lib/ghostty.ts)
Setting cell pixel dims via _resize is NOT enough for in-band size
reports to work. XTWINOPS replies route through a separate
callback (OPT_SIZE) with its own signature:
bool(*)(GhosttyTerminal, void*, GhosttySizeReportSize*)
Without it, CSI 14/16/18 t silently drop.
Extended the trampoline file to host two forwarders side by side
— write_pty_fwd (4 args, no return) and size_fwd (3 args, i32
return). The per-table WeakMap registry now owns both slot
indices. The SIZE dispatcher fills the 12-byte
GhosttySizeReportSize struct (rows@0:u16, cols@2:u16, cell_w@4:u32,
cell_h@8:u32) from cached metrics + current cols/rows; returns
false when pixel dims are zero so unconfigured terminals drop
queries instead of reporting bogus values.
# Receipt
Smoke roundtrip on an 8x16 cell, 80x24 grid:
CSI 14 t -> \e[4;384;640t (text area in px)
CSI 16 t -> \e[6;16;8t (cell in px)
CSI 18 t -> \e[8;24;80t (rows; cols)
New regression test covers all three before/after setCellPixelSize.
Test count: 333/0 -> 334/0.
Signed-off-by: Evan Wies <evan@neomantra.net>
Lays the foundation for compositing kitty graphics images onto the
canvas. Two layers: a small ghostty patch to make the C ABI available
on wasm32-freestanding at all, and the JS-side bindings + enums to
call into it.
# patches/ghostty-wasm-api.patch — the only WASM-specific patch we carry
Upstream hardcodes `kitty_graphics = false` for wasm32-freestanding in
src/terminal/build_options.zig, citing two real blockers for that
target:
1. The eviction LRU keys on std.time.Instant.now() — there is no
clock available on freestanding.
2. graphics_image.zig:LoadingImage.init unconditionally references
std.fs.max_path_bytes + posix.realpath for non-direct mediums
(file / temp / shared_memory) — both fail to compile under
freestanding.
Patch fixes both:
- Flip the build flag to `true` (always-on).
- Add a Timestamp type that aliases std.time.Instant on native and
drops to a monotonic u64 counter on isWasm. The counter is
sufficient for LRU ordering; absolute wall time isn't needed,
and a counter keeps the patch purely Zig (no JS env import).
- Add a comptime-isWasm early return in init() that bails with
UnsupportedMedium for non-direct mediums BEFORE the path-handling
code, so std.fs.max_path_bytes and posix.realpath never get
referenced on freestanding.
Three small hunks across build_options.zig, graphics_image.zig,
graphics_storage.zig. RGBA / RGB / GRAY / GRAY_ALPHA payloads work on
WASM. PNG payloads also work if the embedder installs a JS-side
decoder via ghostty_sys_set(DECODE_PNG, fn) — wiring for that lives
in a follow-up since booba pre-decodes server-side.
# lib/types.ts
Adds:
- TerminalData.KITTY_GRAPHICS = 30 (entry point: returns the
storage handle for the active screen)
- TerminalOption.KITTY_IMAGE_STORAGE_LIMIT = 15 (uint64* bytes;
must be non-zero for the protocol to be enabled at all)
- Six kitty enums: KittyGraphicsData / PlacementData / ImageData
/ PlacementLayer / PlacementIteratorOption, KittyImageFormat,
KittyImageCompression
- 16 WASM export signatures covering the placement iterator
lifecycle, per-placement getters, image lookup, and the one-shot
placement_render_info that fills a sized struct with every field
the renderer needs in a single call
# lib/ghostty.ts
setKittyImageStorageLimit(bytes) method (uint64 LE write into a
short-lived 8-byte slot, then ghostty_terminal_set). Constructor
calls it with 64MB by default — coder's old WASM defaulted to the
same; TUI use rarely needs more.
# Smoke
Send a 2x2 RGBA image (i=1, f=32, t=d) and read it back:
terminal_get(KITTY_GRAPHICS) -> result 0, non-null storage handle
kitty_graphics_image(graphics, 1) -> non-null image handle
image_get(WIDTH/HEIGHT/DATA_LEN) -> 2 / 2 / 16
ghostty-vt.wasm: 555K -> 612K (+57K for the kitty graphics code path
that's now compiled in).
Renderer integration is the next commit — this lays the wiring that
makes it possible.
Test count unchanged: 334/0.
Signed-off-by: Evan Wies <evan@neomantra.net>
Builds on the WASM-side enable patch + C-ABI bindings — this is the
JS-side render pass that actually puts pixels on the canvas.
# lib/ghostty.ts
Three new GhosttyTerminal methods that wrap the 16 raw exports into
shapes the renderer can use without poking at WASM directly:
getKittyGraphics() -> number | null
Borrowed handle from terminal_get(KITTY_GRAPHICS); null when
no images have been transmitted.
*iterPlacements(graphics, onlyVisible = true) -> KittyPlacementInfo
Generator. Allocates a placement iterator, populates from the
storage handle, walks each placement, and for each yields a
parsed PlacementRenderInfo (pixel size + grid size + viewport
pos + source rect). Uses upstream's one-shot
placement_render_info call (12 fields, single WASM crossing)
instead of stringing together 5 separate per-placement getters.
getKittyImagePixels(graphics, imageId) -> KittyImagePixels | null
Returns a borrowed view into the WASM-side RGBA bytes plus
width/height/format. Caller must finish reading before the next
vt_write — the underlying buffer detaches on memory growth.
# lib/renderer.ts
IRenderable interface gains optional kitty methods so test fakes can
omit them. Real renderers (GhosttyTerminal) implement.
Render-loop hook lands between text rendering and cursor — MVP
z-order is "all images above text". Programs sending images
typically clear the cell area first, so there's nothing meaningful
underneath. A future commit can split into below/above-text passes
keyed off PlacementLayer if real apps need it.
The image cache is keyed by imageId with dataLen as a cheap
re-transmission discriminator. Each cache entry is an offscreen
HTMLCanvasElement filled via putImageData, so per-frame compositing
is just a drawImage call with the source/dest rects from
PlacementRenderInfo. New image IDs decode synchronously into a
fresh ArrayBuffer (not a WASM-memory view) so the bitmap survives
later vt_write calls that may detach the source.
decodeKittyImageToCanvas handles RGBA / RGB / GRAY / GRAY_ALPHA;
PNG returns null silently — the terminal would have dropped a PNG
payload at parse time anyway unless the embedder installs a
decoder via ghostty_sys_set(DECODE_PNG, fn). That hook lands in a
follow-up commit.
# lib/types.ts
KittyPlacementInfo (rendered shape) and KittyImagePixels (decoded
image bytes + metadata) interfaces, plus
KITTY_PLACEMENT_RENDER_INFO_SIZE = 48 (the C struct's wasm32 size,
documented inline with field offsets).
# Test status
334/0 still passing. End-to-end visual confirmation needs a real
canvas (test env uses happy-dom), but the code paths are exercised
by the existing test suite where they don't fire (no images stored).
Signed-off-by: Evan Wies <evan@neomantra.net>
kitten icat reads TIOCGWINSZ on its stdin and refuses to render
("Terminal does not support reporting screen sizes in pixels") if
ws_xpixel / ws_ypixel are zero — even if the terminal would have
answered CSI 14 t correctly. To make icat (and other kitty kittens)
work in the demo we have to set those fields server-side via
TIOCSWINSZ.
# Runtime swap: @lydell/node-pty -> node-pty 1.2.0-beta.12
@lydell/node-pty is forked from microsoft/node-pty's 1.1.0-beta14
(pre-pixelSize). Its resize(cols, rows) signature has no way to
pass pixel dims, and the underlying pty.cc was never updated to
take them.
microsoft/node-pty@1.2.0-beta.12 added a third pixelSize arg:
resize(columns, rows, pixelSize?: { width, height }): void
On Unix this sets ws_xpixel / ws_ypixel via TIOCSWINSZ. Microsoft
ships prebuilt binaries for darwin-{x64,arm64}, linux-{x64,arm64},
win32-{x64,arm64}, so install works without node-gyp on every
platform we care about.
# Browser -> server wire-up
The browser knows the canvas's CSS pixel size; thread it through
the existing resize message:
index.html:
function getPixelSize() {
const canvas = container?.querySelector('canvas');
return canvas
? { xpixel: canvas.clientWidth, ypixel: canvas.clientHeight }
: { xpixel: 0, ypixel: 0 };
}
onResize -> ws.send({ type: 'resize', cols, rows, xpixel, ypixel })
ws.onopen -> ws.send(... initial dims ...)
demo.js:
if (msg.xpixel > 0 && msg.ypixel > 0) {
ptyProcess.resize(msg.cols, msg.rows, {
width: msg.xpixel,
height: msg.ypixel,
});
}
getPixelSize is at module scope (not inside initTerminal) so both
onResize and connectWebSocket's ws.onopen can call it. Keeping it
local hits a "ReferenceError: Can't find variable: getPixelSize" on
WS open since connectWebSocket is a separate top-level function.
# Verification
Direct PTY round-trip:
pty.spawn(...).resize(80, 24, { width: 640, height: 384 })
→ child reads TIOCGWINSZ → (24, 80, 640, 384)
In the demo, kitten icat now negotiates past the size-reporting
gate. (Image rendering itself depends on the PNG decoder, which
lands in a separate commit.)
Signed-off-by: Evan Wies <evan@neomantra.net>
kitty kittens (icat etc.) encode payloads as PNG by default — there
is no `--format` flag to force RGBA. Without a decoder the terminal
silently drops PNG transmissions, surfacing only as
"warning(kitty_gfx): erroneous kitty graphics response: EINVAL:
unsupported format" in the log. ghostty's new C ABI deliberately
externalizes PNG decoding via a sys-level callback so embedders pick
their decoder of choice. We install one.
# Trampoline (lib/write_pty_trampoline.{wat,ts})
Third forwarder signature alongside WRITE_PTY and SIZE:
DECODE_PNG: (userdata, allocator, data, data_len, out_image)
-> i32 (bool)
Total trampoline payload: 185 bytes (was 123). The two existing
forwarders are unchanged; bytecode regeneration is mechanical from
the .wat source.
# Decoder dispatcher (lib/ghostty.ts)
Synchronous JS-side decode is the only viable path — the C callback
fires inside vt_write and can't await. Browser createImageBitmap
is async, so we use fast-png (~30KB, pure-JS, sync) which handles
all PNG variants we care about: RGB, RGBA, GRAY, GRAY+alpha, and
indexed (palette).
The dispatcher:
1. Reads PNG bytes from WASM memory (slice() to copy out — the
buffer can detach on growth).
2. Calls fast-png decode().
3. Normalizes the result to tightly-packed 8-bit RGBA via
pngToRgba8() (handles 1/2/3/4 channels at 8 or 16 bit).
4. Allocates a WASM buffer via ghostty_alloc(allocator, len) and
copies the RGBA in. The library frees later via the same
allocator.
5. Fills the 16-byte GhosttySysImage at outImagePtr (u32 width @ 0,
height @ 4, data_ptr @ 8, data_len @ 12) and returns 1.
Indexed-PNG quirk: fast-png's IndexedColors type is documented as
RGB triples (number[][]) but the runtime values are RGBA quadruples
when the source PNG has tRNS — alpha is folded into the palette
tuple instead of surfaced as a separate `transparency` field.
pngToRgba8 prefers palette[idx][3] when present, falls back to
transparency[idx], then to 255. Without this path, transparent-
background PNGs (booba.png is one) render as solid backgrounds.
# Process-global install
ghostty_sys_set is per-WASM-instance, not per-terminal. Install
once per __indirect_function_table — same lifetime as the trampoline
registry — so all GhosttyTerminals from a given Ghostty.load() share
one decoder slot.
# Bindings (lib/types.ts)
Three new exports declared on GhosttyWasmExports:
ghostty_sys_set(option, valuePtr) -> Result
ghostty_alloc(allocatorPtr, len) -> ptr
ghostty_free(allocatorPtr, ptr, len)
SysOption enum (USERDATA / DECODE_PNG / LOG = 2 / 1 / 0).
# Verification
End-to-end: encode a 2x2 RGBA image as PNG via fast-png.encode,
send via kitty graphics (a=t,f=100,t=d,i=42), read back through
getKittyImagePixels:
format = 1 (RGBA)
bytes = 16
firstPx = (255,0,0,255)
Indexed-with-tRNS verified end-to-end with kitten icat against
booba.png (512x512, 256-color palette, alpha encoded in palette
tuples) — renders with correct transparent background.
Adds fast-png ^7.0.0 to dependencies.
Signed-off-by: Evan Wies <evan@neomantra.net>
Adds support for kitty's unicode-placeholder rendering mode — programs
that use a=T,U=1 (transmit + create virtual placement) or a=t + a=p,U=1
write U+10EEEE cells into the text grid with the image_id in the cell's
foreground color and the row/col-of-image as combining diacritics.
ntcharts (the BubbleTea charting library go-booba bridges) emits this
form; without renderer support those cells fall through to font
fallback and render as a matrix of missing-glyph boxes.
# How the protocol encodes a slice
cell.codepoint = U+10EEEE
cell.fg_r/g/b = low 24 bits of image_id
combining mark [1] = row index in image's slice grid
combining mark [2] = column index in image's slice grid
combining mark [3] = optional high byte of image_id
Combining marks come from a fixed list of 297 combining characters
(kitty's gen/rowcolumn-diacritics.txt — combining-class 230, no
decompositions, won't precompose). Each diacritic represents its
0-based index in that list.
# Pieces
- lib/kitty_diacritics.ts: the 297-entry table copied from kitty's
source, plus diacriticToInt(cp) reverse lookup and the
KITTY_PLACEHOLDER = 0x10EEEE constant.
- lib/ghostty.ts: iterPlacements now also reads IS_VIRTUAL per
placement (one extra WASM crossing each; placement counts are
small) so callers can distinguish direct from virtual placements.
- lib/types.ts: KittyPlacementInfo gains an isVirtual field.
- lib/renderer.ts:
- IRenderable.getGrapheme(row, col) — already on GhosttyTerminal,
surfaced in the interface for renderer use.
- precomputeKittyState(buffer) at the top of render() walks
iterPlacements(graphics, /*onlyVisible=*/false) and indexes
virtual placements by image_id into kittyVirtualPlacements.
Direct placements continue through renderKittyImages unchanged
(the default onlyVisible=true filter there picks them up).
- getOrDecodeKittyImage factored out of renderKittyImages so the
placeholder path shares the same image cache (canvas keyed by
image id with dataLen as staleness discriminator).
- renderPlaceholderCell called from renderCellText when
cell.codepoint === KITTY_PLACEHOLDER. Decodes id + row + col,
looks up the placement's grid_cols/grid_rows, computes
srcRect = (col*W/gridCols, row*H/gridRows, W/gridCols,
H/gridRows), drawImage to the cell's pixel rect. If decode
fails (image not stored, no virtual placement record, malformed
diacritics) return false and fall through to normal text
rendering — the cell renders as a missing-glyph box, which is
correct: that's what the program asked for if we don't have
what we need.
# Z-order
Placeholder slices substitute for text in the per-cell text pass, so
they sit above the cell background and replace the glyph. They
composite at integer cell boundaries — no interpolation, no bleed.
Direct placements still render via renderKittyImages after the row
loop; the two paths don't conflict because they're keyed on
isVirtual.
# Verification
iterPlacements yields a virtual placement with isVirtual=true and
the right gridCols/gridRows after sending a=T,U=1,c=4,r=2. Diacritic
table sanity-checked: U+0305=0, U+030D=1, U+030E=2, U+0310=3, last
entry U+1D244, size 297.
End-to-end visual confirmation requires go-booba's U=1 drop
(serve/kittygfx.go:188) to be removed in lockstep — booba currently
filters U=1 transmissions defensively because the previous renderer
would OOM on them. With this commit, ghostty-web handles U=1
correctly and the booba-side filter should come out.
Test count unchanged: 334/0.
Signed-off-by: Evan Wies <evan@neomantra.net>
readGridLine (used by getScrollbackLine / getScrollbackHyperlinkUri /
getScrollbackGrapheme) was returning codepoint-only cells; everything
else (fg/bg colors, style flags, cell width) was at the renderer's
default. Visible regression: scrolling up showed the right text but
plain on dark, with no preserved colors or bold/italic from when the
output was first emitted. Closes the last styling item on the
migration backfill list.
# Reads per cell
grid_ref_cell -> cell u64
cell_get(CODEPOINT) -> base codepoint (existing)
cell_get(WIDE) -> NARROW/WIDE/SPACER (mapped to width 1/2/0)
grid_ref_style -> 72-byte GhosttyStyle
Style decode mirrors getViewport's path: bool flags at offsets 56..63
get OR'd into the CellFlags bitmask; i32 underline at offset 64
(non-zero) sets CellFlags.UNDERLINE.
# Color resolution (resolveStyleColor)
GhosttyStyleColor is a 16-byte tagged union — tag@0:u32, then 4 bytes
padding, then value@8 (palette idx u8 OR rgb u8[3] OR u64 padding).
Three tag values:
NONE(0) -> leave (0,0,0) so the renderer's isDefaultFg /
isDefaultBg path falls through to theme.foreground /
.background. Same convention getViewport uses; keeps
scrollback rendering consistent with viewport rendering
for "no explicit color" cells.
PALETTE(1) -> look up the cached palette[idx]. The palette is
fetched once per readGridLine call (terminal_get
(COLOR_PALETTE) → 768 bytes) so per-cell color
resolution is a Uint8Array index, not a WASM crossing.
RGB(2) -> read the 3 bytes directly.
# Per-cell cost
4 WASM crossings (grid_ref_cell, cp, wide, style) plus the one-time
palette fetch. Heavier than getViewport's 3-4 per cell but scrollback
isn't on the hot path — only fires when the user scrolls up or a
selection extracts text.
# Verification
\x1b[1;31m hello bold red \x1b[0m
-> scrollback cell 0:
cp = 'h'
fg = (204, 102, 102) — palette red
bg = (0, 0, 0) — default sentinel (theme fallback)
flags = 1 — BOLD
\x1b[32m\x1b[44m green on blue \x1b[0m
-> scrollback cell 0:
fg = (181, 189, 104) — palette green
bg = (129, 162, 190) — palette blue
flags = 0
Signed-off-by: Evan Wies <evan@neomantra.net>
Coder's old C ABI exposed a u16 hyperlink_id from the packed cell struct, but lib/link-detector.ts:101-103 has a comment from that era documenting that "the WASM returns hyperlink_id as a boolean (0 or 1), not a unique identifier. The actual unique identifier is the URI." The renderer's `=== hoveredHyperlinkId` comparison therefore really means "this cell is part of *some* hyperlink, same as the one I'm hovering" rather than "the *same* hyperlink instance." Identifying the actual link is link-detector's job, keyed by URI + position range. The new C ABI doesn't surface a per-cell hyperlink id at all; only GHOSTTY_CELL_DATA_HAS_HYPERLINK (bool). That's exactly enough for the contract above. # Reads per cell Both getViewport and readGridLine now call: ghostty_cell_get(cell, HAS_HYPERLINK, *bool) cell.hyperlink_id = bool ? 1 : 0 Buffer-reuse: getViewport reuses widePtr (already 4-byte allocated for the WIDE enum); HAS_HYPERLINK writes a u8 at byte 0 of that slot. Saves an alloc/free pair per cell. Same trick in readGridLine. # Verification OSC 8 round-trip: see <8;;https://github.com/ghostty-org/ghostty>ghostty</8> rocks → viewport row 0 cells: "see " → hyperlink_id = 0 "ghostty" → hyperlink_id = 1 " rocks" → hyperlink_id = 0 Same row pushed into scrollback: same per-cell pattern. The renderer's `cell.hyperlink_id > 0` test now correctly fires on "ghostty" cells and only those, restoring the OSC 8 hover underline. Signed-off-by: Evan Wies <evan@neomantra.net>
Signed-off-by: Evan Wies <evan@neomantra.net>
Renderer: kitty image compositing was unconditional, so translucent images accumulated alpha frame-over-frame and stale pixels persisted when placements moved or were deleted (unless the underlying text rows happened to repaint). Track placement signatures across frames; on any add/remove/move/redecode, mark the affected rows damaged so the text pass clears the area before we composite the current frame's placements. Skip the composite entirely on quiescent frames. Demo: the embedded HTML in @ghostty-web/demo's bin still sent resize without xpixel/ypixel, so the server's node-pty pixel-size code path never fired and TIOCGWINSZ pixel fields stayed zero. Mirror the Vite demo: getPixelSize() helper, push initial dims on ws.onopen, and include them on subsequent resize events. Signed-off-by: Evan Wies <evan@neomantra.net>
Cache invalidation keyed only on (id, dataLen) aliased same-id re-transmissions when bytes happened to be the same length: width/height swaps with the same total byte count (100x50 RGBA == 50x100 RGBA) and re-allocations of identical-shape images both went undetected. Extend the cache and the per-frame placement signature to include width/height/format/dataPtr/dataLen via a shared cachedMatchesPixels helper. dataPtr is the WASM byteOffset, which changes whenever ghostty frees + re-allocates the bytes. Terminal.reset() spins up a fresh WASM terminal but did not push the renderer's per-cell pixel dims into it, so CSI 14/16/18 t and kitty graphics sizing reported zeros until the next font/resize event. Reapply via updateWasmPixelSize() right after createTerminal. Signed-off-by: Evan Wies <evan@neomantra.net>
Signed-off-by: Evan Wies <evan@neomantra.net>
Signed-off-by: Evan Wies <evan@neomantra.net>
Signed-off-by: Evan Wies <evan@neomantra.net>
The 512 KB tripwire predates kitty graphics: it was set when the project first started building from the upstream submodule, sized for a build with kitty_graphics disabled on wasm32-freestanding. This branch flips that flag, which pulls in the kitty storage layer (LRU, image table, placement iterator) plus the new C ABI surface — taking the binary from ~480 KB to ~611 KB. Raise the cap to 768 KB to track the new baseline while still catching unintended bloat. Signed-off-by: Evan Wies <evan@neomantra.net>
Programs emit literal black as a valid explicit color (e.g. letterboxed image cells with a true-black background). The renderer was conflating that with "no color set" — both arrive as RGB(0,0,0) once style resolution runs — and falling back to theme.background, so explicit-black cells rendered as the theme bg instead. Thread the upstream GhosttyStyleColor tag through as fgIsDefault / bgIsDefault booleans on GhosttyCell, populated from the C ABI: tag === 0 (NONE) means default; PALETTE / RGB are explicit and the flag stays false. The renderer consults those flags instead of inspecting the RGB triple, so explicit (0,0,0) now paints as black and only true default cells fall through to theme.foreground / theme.background. INVERSE flips which flag governs the theme fallback in each path: inverted bg uses fgIsDefault, inverted fg uses bgIsDefault. Signed-off-by: Evan Wies <evan@neomantra.net>
renderPlaceholderCell draws each U+10EEEE cell as an independent drawImage from the decoded image canvas, with srcW = pixels.width / gridCols and srcX = col * srcW. Whenever pixels.width doesn't divide evenly by gridCols (the common case), every srcX/srcW is fractional. With the default imageSmoothingEnabled = true, bilinear interpolation is clamped to each slice's own source rect, so adjacent cells sample the boundary differently and leave a 1px discontinuity at every cell edge — the classic tile-edge artifact, showing up as a grid overlaid on the rendered image. Save / disable / restore imageSmoothingEnabled around the slice draw. Other drawImage callers in the renderer (renderKittyImages on direct placements) don't have this problem: their source/dest rects come straight from PlacementRenderInfo and aren't sliced per-cell. Trade-off: per-cell scaling is now nearest-neighbor rather than bilinear, which can look more pixelated when the source image is much larger than its cell rect. The seam removal is the bigger win in practice; if smooth scaling matters later, the fix is to pre-render the image to a single canvas at gridCols*cellW × gridRows*cellH and draw integer-aligned slices from that. Signed-off-by: Evan Wies <evan@neomantra.net>
…eduler startRenderLoop kept a CPU core hot at ~60 Hz forever. Even on a static screen each frame paid for a render() entry/exit (which calls into WASM via update() and clearDirty()) and a getCursor() round-trip into WASM. Browser tabs hosting an idle terminal pinned a core for as long as the tab was open. Replace the unconditional loop with requestRender(): an idempotent single-rAF scheduler that's a no-op if a frame is already pending. Wake points are placed on every event source that mutates renderable state — writes from the PTY (writeInternal), each smooth-scroll animateScroll tick, scroll API mutations (scrollLines, scrollToTop, scrollToBottom, scrollToLine, smoothScrollTo immediate-jump), selection changes, post-resize, and the cursor-blink interval (via a new onRequestRender callback the renderer holds and the Terminal sets). The renderer's setHoveredHyperlinkId / setHoveredLinkRange also wake on actual state change, with identity dedupe. After open()'s forced render, run one synchronous renderTick to mirror the prior loop's first iteration: refreshRowMetaCache (used by isRowWrapped) walks the WASM row iterator immediately after open() and depends on the second update / clearDirty pair to settle WASM state. Without this, the existing isRowWrapped test fails. End state: idle terminal does zero JS work and zero WASM calls until the next event. An equivalent design — fully event-driven with no loop at all, where every state mutation calls requestRender directly — would land in the same place; we kept the requestRender shape for surgical scope. Signed-off-by: Evan Wies <evan@neomantra.net>
The Block Elements range U+2580..U+259F (half blocks, eighths blocks, shading, quadrants) was being drawn through the browser's font, like any other codepoint. The font's rasterization of these glyphs doesn't quite fill the cell box — typically a sub-pixel gap on one or two edges. In half-block image renderings (pixterm/ansimage, ntcharts-picture's glyph mode, chafa), where adjacent cells carry different colors, those gaps line up into a 1-device-px grid overlaid on the image. Visible at integer dpr (e.g. dpr=1); at dpr=2 the gap gets antialiased away, which is why the artifact has the surprising "sticky" property of appearing only on windows opened on a non-retina display. Native terminals (Ghostty, kitty, alacritty) draw block elements programmatically as filled rectangles for exactly this reason. Do the same: a renderBlockElement helper handles the whole U+2580..U+259F range using the cell's existing fillStyle, returning true if the codepoint is a block element so renderCellText skips the fillText call. Eighths blocks are computed from codepoint arithmetic (n/8 of the cell box). Shading uses globalAlpha modulation. Quadrants use a codepoint-to-bitmap map. Restricted to simple cells (grapheme_len === 0); cells with combining marks fall back to fillText so block-element-as-base-with-combiners still renders correctly. Signed-off-by: Evan Wies <evan@neomantra.net>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This is a WIP and I'm not expecting review, but rather just exposing what's upstream in Ghostty for maintainers and all of us following along.
I'm working on a project called
go-boobawhich is usingghostty-webto embed Golang BubbleTea programs in web browsers. That is still heavily in development.I wanted to push Kitty Graphics which wasn't supported yet. Once I cracked it open, I saw this repo was based on Ghostty 1.2 ... version 1.3 significantly expanded the C API for
libghostty-vt.Either upstream Ghostty adopted this repo's patches or worked on it themselves, but the API surface greatly expanded, which was wonderful.
So I removed the patches and started rounding out support for other feature that upstream added, for example Kitty! To support that fully, I had to also add some interesting things like a PNG transcoder.
The nature of this is that it was heavily LLM-assisted; but I'm trying not to show you garbage. I've actually did the base implementation twice and the same concerns surfaced. So at minimum it's a guide for what's needed and what's possible.
Check out these demos that used to be just GIFs... they are now BubbleTea TUIs alive in the browser:
https://nimblemarkets.github.io/ntcharts/demos/heatmap-perlin/
Thanks for
ghostty-web-- I wanted to make it the day I heard aboutlibghostty-vt, but felt I wouldn't be able to. But I certainly felt comfortable working atop this, so I appreciate your foundation and providing inspiration for upstream to keep pushing WASM. 😻