From 3ddfd42028f109798a75f2c718065ba672db6ff1 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Tue, 28 Apr 2026 12:47:11 -0400 Subject: [PATCH 01/29] feat: Bump ghostty to upstream main; drop now-upstreamed WASM patches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ghostty | 2 +- patches/ghostty-wasm-api.patch | 1591 -------------------------------- scripts/build-wasm.sh | 60 +- 3 files changed, 40 insertions(+), 1613 deletions(-) diff --git a/ghostty b/ghostty index 5714ed0..6590196 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 5714ed07a1012573261b7b7e3ed2add9c1504496 +Subproject commit 6590196661f769dd8f2b3e85d6c98262c4ec5b3b diff --git a/patches/ghostty-wasm-api.patch b/patches/ghostty-wasm-api.patch index bd3feb9..e69de29 100644 --- a/patches/ghostty-wasm-api.patch +++ b/patches/ghostty-wasm-api.patch @@ -1,1591 +0,0 @@ -diff --git a/.gitignore b/.gitignore -index e451b171a..89c623d8b 100644 ---- a/.gitignore -+++ b/.gitignore -@@ -23,3 +23,4 @@ glad.zip - /ghostty.qcow2 - - vgcore.* -+node_modules/ -diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h -index 4f8fef88e..ca9fb1d4d 100644 ---- a/include/ghostty/vt.h -+++ b/include/ghostty/vt.h -@@ -28,6 +28,7 @@ - * @section groups_sec API Reference - * - * The API is organized into the following groups: -+ * - @ref terminal "Terminal Emulator" - Complete terminal emulator with VT parsing - * - @ref key "Key Encoding" - Encode key events into terminal sequences - * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences - * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences -@@ -74,6 +75,7 @@ extern "C" { - - #include - #include -+#include - #include - #include - #include -diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h -new file mode 100644 -index 000000000..c467102c3 ---- /dev/null -+++ b/include/ghostty/vt/terminal.h -@@ -0,0 +1,285 @@ -+/** -+ * @file terminal.h -+ * -+ * Minimal, high-performance terminal emulator API for WASM. -+ * -+ * The key optimization is the RenderState API which provides a pre-computed -+ * snapshot of all render data in a single update call, avoiding multiple -+ * WASM boundary crossings. -+ * -+ * Basic usage: -+ * 1. Create terminal: ghostty_terminal_new(80, 24) -+ * 2. Write data: ghostty_terminal_write(term, data, len) -+ * 3. Each frame: -+ * - ghostty_render_state_update(term) -+ * - ghostty_render_state_get_viewport(term, buffer, size) -+ * - Render the buffer -+ * - ghostty_render_state_mark_clean(term) -+ * 4. Free: ghostty_terminal_free(term) -+ */ -+ -+#ifndef GHOSTTY_VT_TERMINAL_H -+#define GHOSTTY_VT_TERMINAL_H -+ -+#include -+#include -+#include -+ -+#ifdef __cplusplus -+extern "C" { -+#endif -+ -+/** Opaque terminal handle */ -+typedef void* GhosttyTerminal; -+ -+/** -+ * Terminal configuration. -+ * All color values use 0xRRGGBB format. A value of 0 means "use default". -+ */ -+typedef struct { -+ /** Maximum scrollback lines (0 = unlimited) */ -+ uint32_t scrollback_limit; -+ /** Default foreground color (0xRRGGBB, 0 = default) */ -+ uint32_t fg_color; -+ /** Default background color (0xRRGGBB, 0 = default) */ -+ uint32_t bg_color; -+ /** Cursor color (0xRRGGBB, 0 = default) */ -+ uint32_t cursor_color; -+ /** ANSI color palette (16 colors, 0xRRGGBB format, 0 = default) */ -+ uint32_t palette[16]; -+} GhosttyTerminalConfig; -+ -+/** Cell structure - 16 bytes, pre-resolved colors */ -+typedef struct { -+ uint32_t codepoint; -+ uint8_t fg_r, fg_g, fg_b; -+ uint8_t bg_r, bg_g, bg_b; -+ uint8_t flags; -+ uint8_t width; -+ uint16_t hyperlink_id; -+ uint8_t grapheme_len; /* Number of extra codepoints beyond first (0 = no grapheme) */ -+ uint8_t _pad; -+} GhosttyCell; -+ -+/** Cell flags */ -+#define GHOSTTY_CELL_BOLD (1 << 0) -+#define GHOSTTY_CELL_ITALIC (1 << 1) -+#define GHOSTTY_CELL_UNDERLINE (1 << 2) -+#define GHOSTTY_CELL_STRIKETHROUGH (1 << 3) -+#define GHOSTTY_CELL_INVERSE (1 << 4) -+#define GHOSTTY_CELL_INVISIBLE (1 << 5) -+#define GHOSTTY_CELL_BLINK (1 << 6) -+#define GHOSTTY_CELL_FAINT (1 << 7) -+ -+/** Dirty state */ -+typedef enum { -+ GHOSTTY_DIRTY_NONE = 0, -+ GHOSTTY_DIRTY_PARTIAL = 1, -+ GHOSTTY_DIRTY_FULL = 2 -+} GhosttyDirty; -+ -+/* ============================================================================ -+ * Lifecycle -+ * ========================================================================= */ -+ -+/** Create a new terminal with default settings */ -+GhosttyTerminal ghostty_terminal_new(int cols, int rows); -+ -+/** -+ * Create a new terminal with custom configuration. -+ * @param cols Number of columns -+ * @param rows Number of rows -+ * @param config Configuration options (NULL = use defaults) -+ * @return Terminal handle, or NULL on failure -+ */ -+GhosttyTerminal ghostty_terminal_new_with_config( -+ int cols, -+ int rows, -+ const GhosttyTerminalConfig* config -+); -+ -+/** Free a terminal */ -+void ghostty_terminal_free(GhosttyTerminal term); -+ -+/** Resize terminal */ -+void ghostty_terminal_resize(GhosttyTerminal term, int cols, int rows); -+ -+/** Write data to terminal (parses VT sequences) */ -+void ghostty_terminal_write(GhosttyTerminal term, const uint8_t* data, size_t len); -+ -+/* ============================================================================ -+ * RenderState API - High-performance rendering -+ * ========================================================================= */ -+ -+/** Update render state from terminal. Call once per frame. */ -+GhosttyDirty ghostty_render_state_update(GhosttyTerminal term); -+ -+/** Get dimensions */ -+int ghostty_render_state_get_cols(GhosttyTerminal term); -+int ghostty_render_state_get_rows(GhosttyTerminal term); -+ -+/** Get cursor state (individual getters for WASM efficiency) */ -+int ghostty_render_state_get_cursor_x(GhosttyTerminal term); -+int ghostty_render_state_get_cursor_y(GhosttyTerminal term); -+bool ghostty_render_state_get_cursor_visible(GhosttyTerminal term); -+ -+/** Get default colors as 0xRRGGBB */ -+uint32_t ghostty_render_state_get_bg_color(GhosttyTerminal term); -+uint32_t ghostty_render_state_get_fg_color(GhosttyTerminal term); -+ -+/** Check if a row is dirty */ -+bool ghostty_render_state_is_row_dirty(GhosttyTerminal term, int y); -+ -+/** Mark render state as clean (call after rendering) */ -+void ghostty_render_state_mark_clean(GhosttyTerminal term); -+ -+/** -+ * Get ALL viewport cells in one call - the key performance optimization! -+ * Buffer must be at least (rows * cols) cells. -+ * Returns total cells written, or -1 on error. -+ */ -+int ghostty_render_state_get_viewport( -+ GhosttyTerminal term, -+ GhosttyCell* out_buffer, -+ size_t buffer_size -+); -+ -+/** -+ * Get grapheme codepoints for a cell at (row, col). -+ * For cells with grapheme_len > 0, this returns all codepoints that make up -+ * the grapheme cluster. The buffer receives u32 codepoints. -+ * @param row Row index (0-based) -+ * @param col Column index (0-based) -+ * @param out_buffer Buffer to receive codepoints -+ * @param buffer_size Size of buffer in u32 elements -+ * @return Number of codepoints written (including the first), or -1 on error -+ */ -+int ghostty_render_state_get_grapheme( -+ GhosttyTerminal term, -+ int row, -+ int col, -+ uint32_t* out_buffer, -+ size_t buffer_size -+); -+ -+/* ============================================================================ -+ * Terminal Modes -+ * ========================================================================= */ -+ -+/** Check if alternate screen is active */ -+bool ghostty_terminal_is_alternate_screen(GhosttyTerminal term); -+ -+/** Check if any mouse tracking mode is enabled */ -+bool ghostty_terminal_has_mouse_tracking(GhosttyTerminal term); -+ -+/** -+ * Query arbitrary terminal mode by number. -+ * @param mode Mode number (e.g., 25 for cursor visibility, 2004 for bracketed paste) -+ * @param is_ansi true for ANSI modes, false for DEC modes -+ * @return true if mode is enabled -+ */ -+bool ghostty_terminal_get_mode(GhosttyTerminal term, int mode, bool is_ansi); -+ -+/* ============================================================================ -+ * Scrollback API -+ * ========================================================================= */ -+ -+/** Get number of scrollback lines (history, not including active screen) */ -+int ghostty_terminal_get_scrollback_length(GhosttyTerminal term); -+ -+/** -+ * Get a line from the scrollback buffer. -+ * @param offset 0 = oldest line, (length-1) = most recent scrollback line -+ * @param out_buffer Buffer to write cells to -+ * @param buffer_size Size of buffer in cells (must be >= cols) -+ * @return Number of cells written, or -1 on error -+ */ -+int ghostty_terminal_get_scrollback_line( -+ GhosttyTerminal term, -+ int offset, -+ GhosttyCell* out_buffer, -+ size_t buffer_size -+); -+ -+/** -+ * Get grapheme codepoints for a cell in the scrollback buffer. -+ * @param offset Scrollback line offset (0 = oldest) -+ * @param col Column index (0-based) -+ * @param out_buffer Buffer to receive codepoints -+ * @param buffer_size Size of buffer in u32 elements -+ * @return Number of codepoints written, or -1 on error -+ */ -+int ghostty_terminal_get_scrollback_grapheme( -+ GhosttyTerminal term, -+ int offset, -+ int col, -+ uint32_t* out_buffer, -+ size_t buffer_size -+); -+ -+/** Check if a row is a continuation from previous row (soft-wrapped) */ -+bool ghostty_terminal_is_row_wrapped(GhosttyTerminal term, int y); -+ -+/* ============================================================================ -+ * Hyperlink API -+ * ========================================================================= */ -+ -+/** -+ * Get the hyperlink URI for a cell in the active viewport. -+ * @param row Row index (0-based) -+ * @param col Column index (0-based) -+ * @param out_buffer Buffer to receive URI bytes (UTF-8) -+ * @param buffer_size Size of buffer in bytes -+ * @return Number of bytes written, 0 if no hyperlink, -1 on error -+ */ -+int ghostty_terminal_get_hyperlink_uri( -+ GhosttyTerminal term, -+ int row, -+ int col, -+ uint8_t* out_buffer, -+ size_t buffer_size -+); -+ -+/** -+ * Get the hyperlink URI for a cell in the scrollback buffer. -+ * @param offset Scrollback line offset (0 = oldest, scrollback_len-1 = newest) -+ * @param col Column index (0-based) -+ * @param out_buffer Buffer to receive URI bytes (UTF-8) -+ * @param buffer_size Size of buffer in bytes -+ * @return Number of bytes written, 0 if no hyperlink, -1 on error -+ */ -+int ghostty_terminal_get_scrollback_hyperlink_uri( -+ GhosttyTerminal term, -+ int offset, -+ int col, -+ uint8_t* out_buffer, -+ size_t buffer_size -+); -+ -+/* ============================================================================ -+ * Response API - for DSR and other terminal queries -+ * ========================================================================= */ -+ -+/** -+ * Check if there are pending responses from the terminal. -+ * Responses are generated by escape sequences like DSR (Device Status Report). -+ */ -+bool ghostty_terminal_has_response(GhosttyTerminal term); -+ -+/** -+ * Read pending responses from the terminal. -+ * @param out_buffer Buffer to write response bytes to -+ * @param buffer_size Size of buffer in bytes -+ * @return Number of bytes written, 0 if no responses pending, -1 on error -+ */ -+int ghostty_terminal_read_response( -+ GhosttyTerminal term, -+ uint8_t* out_buffer, -+ size_t buffer_size -+); -+ -+#ifdef __cplusplus -+} -+#endif -+ -+#endif /* GHOSTTY_VT_TERMINAL_H */ -diff --git a/src/lib_vt.zig b/src/lib_vt.zig -index 03a883e20..1336676d7 100644 ---- a/src/lib_vt.zig -+++ b/src/lib_vt.zig -@@ -140,6 +140,45 @@ comptime { - @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); - @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); - @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); -+ // Terminal lifecycle -+ @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); -+ @export(&c.terminal_new_with_config, .{ .name = "ghostty_terminal_new_with_config" }); -+ @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); -+ @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); -+ @export(&c.terminal_write, .{ .name = "ghostty_terminal_write" }); -+ -+ // RenderState API - high-performance rendering -+ @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); -+ @export(&c.render_state_get_cols, .{ .name = "ghostty_render_state_get_cols" }); -+ @export(&c.render_state_get_rows, .{ .name = "ghostty_render_state_get_rows" }); -+ @export(&c.render_state_get_cursor_x, .{ .name = "ghostty_render_state_get_cursor_x" }); -+ @export(&c.render_state_get_cursor_y, .{ .name = "ghostty_render_state_get_cursor_y" }); -+ @export(&c.render_state_get_cursor_visible, .{ .name = "ghostty_render_state_get_cursor_visible" }); -+ @export(&c.render_state_get_bg_color, .{ .name = "ghostty_render_state_get_bg_color" }); -+ @export(&c.render_state_get_fg_color, .{ .name = "ghostty_render_state_get_fg_color" }); -+ @export(&c.render_state_is_row_dirty, .{ .name = "ghostty_render_state_is_row_dirty" }); -+ @export(&c.render_state_mark_clean, .{ .name = "ghostty_render_state_mark_clean" }); -+ @export(&c.render_state_get_viewport, .{ .name = "ghostty_render_state_get_viewport" }); -+ @export(&c.render_state_get_grapheme, .{ .name = "ghostty_render_state_get_grapheme" }); -+ -+ // Terminal modes -+ @export(&c.terminal_is_alternate_screen, .{ .name = "ghostty_terminal_is_alternate_screen" }); -+ @export(&c.terminal_has_mouse_tracking, .{ .name = "ghostty_terminal_has_mouse_tracking" }); -+ @export(&c.terminal_get_mode, .{ .name = "ghostty_terminal_get_mode" }); -+ -+ // Scrollback API -+ @export(&c.terminal_get_scrollback_length, .{ .name = "ghostty_terminal_get_scrollback_length" }); -+ @export(&c.terminal_get_scrollback_line, .{ .name = "ghostty_terminal_get_scrollback_line" }); -+ @export(&c.terminal_get_scrollback_grapheme, .{ .name = "ghostty_terminal_get_scrollback_grapheme" }); -+ @export(&c.terminal_is_row_wrapped, .{ .name = "ghostty_terminal_is_row_wrapped" }); -+ -+ // Hyperlink API -+ @export(&c.terminal_get_hyperlink_uri, .{ .name = "ghostty_terminal_get_hyperlink_uri" }); -+ @export(&c.terminal_get_scrollback_hyperlink_uri, .{ .name = "ghostty_terminal_get_scrollback_hyperlink_uri" }); -+ -+ // Response API (for DSR and other queries) -+ @export(&c.terminal_has_response, .{ .name = "ghostty_terminal_has_response" }); -+ @export(&c.terminal_read_response, .{ .name = "ghostty_terminal_read_response" }); - - // On Wasm we need to export our allocator convenience functions. - if (builtin.target.cpu.arch.isWasm()) { -diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig -index bc92597f5..d0ee49c1b 100644 ---- a/src/terminal/c/main.zig -+++ b/src/terminal/c/main.zig -@@ -4,6 +4,7 @@ pub const key_event = @import("key_event.zig"); - pub const key_encode = @import("key_encode.zig"); - pub const paste = @import("paste.zig"); - pub const sgr = @import("sgr.zig"); -+pub const terminal = @import("terminal.zig"); - - // The full C API, unexported. - pub const osc_new = osc.new; -@@ -52,6 +53,46 @@ pub const key_encoder_encode = key_encode.encode; - - pub const paste_is_safe = paste.is_safe; - -+// Terminal lifecycle -+pub const terminal_new = terminal.new; -+pub const terminal_new_with_config = terminal.newWithConfig; -+pub const terminal_free = terminal.free; -+pub const terminal_resize = terminal.resize; -+pub const terminal_write = terminal.write; -+ -+// RenderState API - high-performance rendering -+pub const render_state_update = terminal.renderStateUpdate; -+pub const render_state_get_cols = terminal.renderStateGetCols; -+pub const render_state_get_rows = terminal.renderStateGetRows; -+pub const render_state_get_cursor_x = terminal.renderStateGetCursorX; -+pub const render_state_get_cursor_y = terminal.renderStateGetCursorY; -+pub const render_state_get_cursor_visible = terminal.renderStateGetCursorVisible; -+pub const render_state_get_bg_color = terminal.renderStateGetBgColor; -+pub const render_state_get_fg_color = terminal.renderStateGetFgColor; -+pub const render_state_is_row_dirty = terminal.renderStateIsRowDirty; -+pub const render_state_mark_clean = terminal.renderStateMarkClean; -+pub const render_state_get_viewport = terminal.renderStateGetViewport; -+pub const render_state_get_grapheme = terminal.renderStateGetGrapheme; -+ -+// Terminal modes -+pub const terminal_is_alternate_screen = terminal.isAlternateScreen; -+pub const terminal_has_mouse_tracking = terminal.hasMouseTracking; -+pub const terminal_get_mode = terminal.getMode; -+ -+// Scrollback API -+pub const terminal_get_scrollback_length = terminal.getScrollbackLength; -+pub const terminal_get_scrollback_line = terminal.getScrollbackLine; -+pub const terminal_get_scrollback_grapheme = terminal.getScrollbackGrapheme; -+pub const terminal_is_row_wrapped = terminal.isRowWrapped; -+ -+// Hyperlink API -+pub const terminal_get_hyperlink_uri = terminal.getHyperlinkUri; -+pub const terminal_get_scrollback_hyperlink_uri = terminal.getScrollbackHyperlinkUri; -+ -+// Response API (for DSR and other queries) -+pub const terminal_has_response = terminal.hasResponse; -+pub const terminal_read_response = terminal.readResponse; -+ - test { - _ = color; - _ = osc; -@@ -59,6 +100,7 @@ test { - _ = key_encode; - _ = paste; - _ = sgr; -+ _ = terminal; - - // We want to make sure we run the tests for the C allocator interface. - _ = @import("../../lib/allocator.zig"); -diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig -new file mode 100644 -index 000000000..73ae2e6fa ---- /dev/null -+++ b/src/terminal/c/terminal.zig -@@ -0,0 +1,1123 @@ -+//! C API wrapper for Terminal -+//! -+//! This provides a minimal, high-performance interface to Ghostty's Terminal -+//! for WASM export. The key optimization is using RenderState which provides -+//! a pre-computed snapshot of all render data in a single update call. -+//! -+//! API Design: -+//! - Lifecycle: new, free, resize, write -+//! - Rendering: render_state_update, render_state_get_viewport, etc. -+//! -+//! The RenderState approach means: -+//! - ONE call to update all state (render_state_update) -+//! - ONE call to get all cells (render_state_get_viewport) -+//! - No per-row or per-cell WASM boundary crossings! -+ -+const std = @import("std"); -+const Allocator = std.mem.Allocator; -+const builtin = @import("builtin"); -+ -+const Terminal = @import("../Terminal.zig"); -+const stream = @import("../stream.zig"); -+const Action = stream.Action; -+const ansi = @import("../ansi.zig"); -+const render = @import("../render.zig"); -+const RenderState = render.RenderState; -+const color = @import("../color.zig"); -+const modespkg = @import("../modes.zig"); -+const point = @import("../point.zig"); -+const Style = @import("../style.zig").Style; -+const device_status = @import("../device_status.zig"); -+ -+const log = std.log.scoped(.terminal_c); -+ -+/// Response handler that processes VT sequences and queues responses. -+/// This extends the readonly stream handler to also handle queries. -+const ResponseHandler = struct { -+ alloc: Allocator, -+ terminal: *Terminal, -+ response_buffer: *std.ArrayList(u8), -+ -+ pub fn init(alloc: Allocator, terminal: *Terminal, response_buffer: *std.ArrayList(u8)) ResponseHandler { -+ return .{ -+ .alloc = alloc, -+ .terminal = terminal, -+ .response_buffer = response_buffer, -+ }; -+ } -+ -+ pub fn deinit(self: *ResponseHandler) void { -+ _ = self; -+ } -+ -+ pub fn vt( -+ self: *ResponseHandler, -+ comptime action: Action.Tag, -+ value: Action.Value(action), -+ ) !void { -+ switch (action) { -+ // Device status reports - these need responses -+ .device_status => try self.handleDeviceStatus(value.request), -+ .device_attributes => try self.handleDeviceAttributes(value), -+ -+ // All the terminal state modifications (same as stream_readonly.zig) -+ .print => try self.terminal.print(value.cp), -+ .print_repeat => try self.terminal.printRepeat(value), -+ .backspace => self.terminal.backspace(), -+ .carriage_return => self.terminal.carriageReturn(), -+ .linefeed => try self.terminal.linefeed(), -+ .index => try self.terminal.index(), -+ .next_line => { -+ try self.terminal.index(); -+ self.terminal.carriageReturn(); -+ }, -+ .reverse_index => self.terminal.reverseIndex(), -+ .cursor_up => self.terminal.cursorUp(value.value), -+ .cursor_down => self.terminal.cursorDown(value.value), -+ .cursor_left => self.terminal.cursorLeft(value.value), -+ .cursor_right => self.terminal.cursorRight(value.value), -+ .cursor_pos => self.terminal.setCursorPos(value.row, value.col), -+ .cursor_col => self.terminal.setCursorPos(self.terminal.screens.active.cursor.y + 1, value.value), -+ .cursor_row => self.terminal.setCursorPos(value.value, self.terminal.screens.active.cursor.x + 1), -+ .cursor_col_relative => self.terminal.setCursorPos( -+ self.terminal.screens.active.cursor.y + 1, -+ self.terminal.screens.active.cursor.x + 1 +| value.value, -+ ), -+ .cursor_row_relative => self.terminal.setCursorPos( -+ self.terminal.screens.active.cursor.y + 1 +| value.value, -+ self.terminal.screens.active.cursor.x + 1, -+ ), -+ .cursor_style => { -+ const blink = switch (value) { -+ .default, .steady_block, .steady_bar, .steady_underline => false, -+ .blinking_block, .blinking_bar, .blinking_underline => true, -+ }; -+ const style: @import("../Screen.zig").CursorStyle = switch (value) { -+ .default, .blinking_block, .steady_block => .block, -+ .blinking_bar, .steady_bar => .bar, -+ .blinking_underline, .steady_underline => .underline, -+ }; -+ self.terminal.modes.set(.cursor_blinking, blink); -+ self.terminal.screens.active.cursor.cursor_style = style; -+ }, -+ .erase_display_below => self.terminal.eraseDisplay(.below, value), -+ .erase_display_above => self.terminal.eraseDisplay(.above, value), -+ .erase_display_complete => self.terminal.eraseDisplay(.complete, value), -+ .erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value), -+ .erase_display_scroll_complete => self.terminal.eraseDisplay(.scroll_complete, value), -+ .erase_line_right => self.terminal.eraseLine(.right, value), -+ .erase_line_left => self.terminal.eraseLine(.left, value), -+ .erase_line_complete => self.terminal.eraseLine(.complete, value), -+ .erase_line_right_unless_pending_wrap => self.terminal.eraseLine(.right_unless_pending_wrap, value), -+ .delete_chars => self.terminal.deleteChars(value), -+ .erase_chars => self.terminal.eraseChars(value), -+ .insert_lines => self.terminal.insertLines(value), -+ .insert_blanks => self.terminal.insertBlanks(value), -+ .delete_lines => self.terminal.deleteLines(value), -+ .scroll_up => self.terminal.scrollUp(value), -+ .scroll_down => self.terminal.scrollDown(value), -+ .horizontal_tab => try self.horizontalTab(value), -+ .horizontal_tab_back => try self.horizontalTabBack(value), -+ .tab_clear_current => self.terminal.tabClear(.current), -+ .tab_clear_all => self.terminal.tabClear(.all), -+ .tab_set => self.terminal.tabSet(), -+ .tab_reset => self.terminal.tabReset(), -+ .set_mode => try self.setMode(value.mode, true), -+ .reset_mode => try self.setMode(value.mode, false), -+ .save_mode => self.terminal.modes.save(value.mode), -+ .restore_mode => { -+ const v = self.terminal.modes.restore(value.mode); -+ try self.setMode(value.mode, v); -+ }, -+ .top_and_bottom_margin => self.terminal.setTopAndBottomMargin(value.top_left, value.bottom_right), -+ .left_and_right_margin => self.terminal.setLeftAndRightMargin(value.top_left, value.bottom_right), -+ .left_and_right_margin_ambiguous => { -+ if (self.terminal.modes.get(.enable_left_and_right_margin)) { -+ self.terminal.setLeftAndRightMargin(0, 0); -+ } else { -+ self.terminal.saveCursor(); -+ } -+ }, -+ .save_cursor => self.terminal.saveCursor(), -+ .restore_cursor => try self.terminal.restoreCursor(), -+ .invoke_charset => self.terminal.invokeCharset(value.bank, value.charset, value.locking), -+ .configure_charset => self.terminal.configureCharset(value.slot, value.charset), -+ .set_attribute => switch (value) { -+ .unknown => {}, -+ else => self.terminal.setAttribute(value) catch {}, -+ }, -+ .protected_mode_off => self.terminal.setProtectedMode(.off), -+ .protected_mode_iso => self.terminal.setProtectedMode(.iso), -+ .protected_mode_dec => self.terminal.setProtectedMode(.dec), -+ .mouse_shift_capture => self.terminal.flags.mouse_shift_capture = if (value) .true else .false, -+ .kitty_keyboard_push => self.terminal.screens.active.kitty_keyboard.push(value.flags), -+ .kitty_keyboard_pop => self.terminal.screens.active.kitty_keyboard.pop(@intCast(value)), -+ .kitty_keyboard_set => self.terminal.screens.active.kitty_keyboard.set(.set, value.flags), -+ .kitty_keyboard_set_or => self.terminal.screens.active.kitty_keyboard.set(.@"or", value.flags), -+ .kitty_keyboard_set_not => self.terminal.screens.active.kitty_keyboard.set(.not, value.flags), -+ .modify_key_format => { -+ self.terminal.flags.modify_other_keys_2 = false; -+ switch (value) { -+ .other_keys_numeric => self.terminal.flags.modify_other_keys_2 = true, -+ else => {}, -+ } -+ }, -+ .active_status_display => self.terminal.status_display = value, -+ .decaln => try self.terminal.decaln(), -+ .full_reset => self.terminal.fullReset(), -+ .start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id), -+ .end_hyperlink => self.terminal.screens.active.endHyperlink(), -+ .prompt_start => { -+ self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt; -+ self.terminal.flags.shell_redraws_prompt = value.redraw; -+ }, -+ .prompt_continuation => self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt_continuation, -+ .prompt_end => self.terminal.markSemanticPrompt(.input), -+ .end_of_input => self.terminal.markSemanticPrompt(.command), -+ .end_of_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input, -+ .mouse_shape => self.terminal.mouse_shape = value, -+ .color_operation => try self.colorOperation(value.op, &value.requests), -+ .kitty_color_report => try self.kittyColorOperation(value), -+ -+ // Actions that require no response and have no terminal effect -+ .dcs_hook, -+ .dcs_put, -+ .dcs_unhook, -+ .apc_start, -+ .apc_end, -+ .apc_put, -+ .bell, -+ .enquiry, -+ .request_mode, -+ .request_mode_unknown, -+ .size_report, -+ .xtversion, -+ .kitty_keyboard_query, -+ .window_title, -+ .report_pwd, -+ .show_desktop_notification, -+ .progress_report, -+ .clipboard_contents, -+ .title_push, -+ .title_pop, -+ => {}, -+ } -+ } -+ -+ fn handleDeviceStatus(self: *ResponseHandler, req: device_status.Request) !void { -+ switch (req) { -+ .operating_status => { -+ // DSR 5 - Operating status report: always report "OK" -+ try self.response_buffer.appendSlice(self.alloc, "\x1B[0n"); -+ }, -+ .cursor_position => { -+ // DSR 6 - Cursor position report (CPR) -+ const cursor = self.terminal.screens.active.cursor; -+ const x = if (self.terminal.modes.get(.origin)) -+ cursor.x -| self.terminal.scrolling_region.left -+ else -+ cursor.x; -+ const y = if (self.terminal.modes.get(.origin)) -+ cursor.y -| self.terminal.scrolling_region.top -+ else -+ cursor.y; -+ var buf: [32]u8 = undefined; -+ const resp = std.fmt.bufPrint(&buf, "\x1B[{};{}R", .{ -+ y + 1, -+ x + 1, -+ }) catch return; -+ try self.response_buffer.appendSlice(self.alloc, resp); -+ }, -+ .color_scheme => { -+ // Not supported in WASM context -+ }, -+ } -+ } -+ -+ fn handleDeviceAttributes(self: *ResponseHandler, req: ansi.DeviceAttributeReq) !void { -+ // Match main Ghostty behavior for device attribute responses -+ switch (req) { -+ .primary => { -+ // DA1 - Primary Device Attributes -+ // Report as VT220 with color support (simplified for WASM) -+ // 62 = Level 2 conformance, 22 = Color text -+ try self.response_buffer.appendSlice(self.alloc, "\x1B[?62;22c"); -+ }, -+ .secondary => { -+ // DA2 - Secondary Device Attributes -+ // Report firmware version 1.10.0 (matching main Ghostty) -+ try self.response_buffer.appendSlice(self.alloc, "\x1B[>1;10;0c"); -+ }, -+ else => { -+ // DA3 and other requests - not implemented in WASM context -+ }, -+ } -+ } -+ -+ inline fn horizontalTab(self: *ResponseHandler, count: u16) !void { -+ for (0..count) |_| { -+ const x = self.terminal.screens.active.cursor.x; -+ try self.terminal.horizontalTab(); -+ if (x == self.terminal.screens.active.cursor.x) break; -+ } -+ } -+ -+ inline fn horizontalTabBack(self: *ResponseHandler, count: u16) !void { -+ for (0..count) |_| { -+ const x = self.terminal.screens.active.cursor.x; -+ try self.terminal.horizontalTabBack(); -+ if (x == self.terminal.screens.active.cursor.x) break; -+ } -+ } -+ -+ fn setMode(self: *ResponseHandler, mode: modespkg.Mode, enabled: bool) !void { -+ self.terminal.modes.set(mode, enabled); -+ switch (mode) { -+ .autorepeat, .reverse_colors => {}, -+ .origin => self.terminal.setCursorPos(1, 1), -+ .enable_left_and_right_margin => if (!enabled) { -+ self.terminal.scrolling_region.left = 0; -+ self.terminal.scrolling_region.right = self.terminal.cols - 1; -+ }, -+ .alt_screen_legacy => try self.terminal.switchScreenMode(.@"47", enabled), -+ .alt_screen => try self.terminal.switchScreenMode(.@"1047", enabled), -+ .alt_screen_save_cursor_clear_enter => try self.terminal.switchScreenMode(.@"1049", enabled), -+ .save_cursor => if (enabled) { -+ self.terminal.saveCursor(); -+ } else { -+ try self.terminal.restoreCursor(); -+ }, -+ .enable_mode_3 => {}, -+ .@"132_column" => try self.terminal.deccolm( -+ self.terminal.screens.active.alloc, -+ if (enabled) .@"132_cols" else .@"80_cols", -+ ), -+ else => {}, -+ } -+ } -+ -+ fn colorOperation(self: *ResponseHandler, op: anytype, requests: anytype) !void { -+ _ = self; -+ _ = op; -+ _ = requests; -+ // Color operations are not supported in WASM context -+ } -+ -+ fn kittyColorOperation(self: *ResponseHandler, value: anytype) !void { -+ _ = self; -+ _ = value; -+ // Kitty color operations are not supported in WASM context -+ } -+}; -+ -+/// The stream type using our response handler -+const ResponseStream = stream.Stream(ResponseHandler); -+ -+/// Wrapper struct that owns the Terminal, stream, and RenderState. -+const TerminalWrapper = struct { -+ alloc: Allocator, -+ terminal: Terminal, -+ handler: ResponseHandler, -+ stream: ResponseStream, -+ render_state: RenderState, -+ /// Response buffer for DSR and other query responses -+ response_buffer: std.ArrayList(u8), -+ /// Track alternate screen state to detect screen switches -+ last_screen_is_alternate: bool = false, -+}; -+ -+/// C-compatible cell structure (16 bytes) -+pub const GhosttyCell = extern struct { -+ codepoint: u32, -+ fg_r: u8, -+ fg_g: u8, -+ fg_b: u8, -+ bg_r: u8, -+ bg_g: u8, -+ bg_b: u8, -+ flags: u8, -+ width: u8, -+ hyperlink_id: u16, -+ grapheme_len: u8 = 0, // Number of extra codepoints beyond first -+ _pad: u8 = 0, -+}; -+ -+/// Dirty state -+pub const GhosttyDirty = enum(u8) { -+ none = 0, -+ partial = 1, -+ full = 2, -+}; -+ -+/// C-compatible terminal configuration -+pub const GhosttyTerminalConfig = extern struct { -+ scrollback_limit: u32, -+ fg_color: u32, -+ bg_color: u32, -+ cursor_color: u32, -+ palette: [16]u32, -+}; -+ -+// ============================================================================ -+// Lifecycle -+// ============================================================================ -+ -+pub fn new(cols: c_int, rows: c_int) callconv(.c) ?*anyopaque { -+ return newWithConfig(cols, rows, null); -+} -+ -+pub fn newWithConfig( -+ cols: c_int, -+ rows: c_int, -+ config_: ?*const GhosttyTerminalConfig, -+) callconv(.c) ?*anyopaque { -+ const alloc = if (builtin.target.cpu.arch.isWasm()) -+ std.heap.wasm_allocator -+ else -+ std.heap.c_allocator; -+ -+ const wrapper = alloc.create(TerminalWrapper) catch return null; -+ -+ // Parse config or use defaults -+ const scrollback_limit: usize = if (config_) |cfg| -+ if (cfg.scrollback_limit == 0) std.math.maxInt(usize) else cfg.scrollback_limit -+ else -+ 10_000; -+ -+ // Setup terminal colors -+ var colors = Terminal.Colors.default; -+ if (config_) |cfg| { -+ if (cfg.fg_color != 0) { -+ const rgb = color.RGB{ -+ .r = @truncate((cfg.fg_color >> 16) & 0xFF), -+ .g = @truncate((cfg.fg_color >> 8) & 0xFF), -+ .b = @truncate(cfg.fg_color & 0xFF), -+ }; -+ colors.foreground = color.DynamicRGB.init(rgb); -+ } -+ if (cfg.bg_color != 0) { -+ const rgb = color.RGB{ -+ .r = @truncate((cfg.bg_color >> 16) & 0xFF), -+ .g = @truncate((cfg.bg_color >> 8) & 0xFF), -+ .b = @truncate(cfg.bg_color & 0xFF), -+ }; -+ colors.background = color.DynamicRGB.init(rgb); -+ } -+ if (cfg.cursor_color != 0) { -+ const rgb = color.RGB{ -+ .r = @truncate((cfg.cursor_color >> 16) & 0xFF), -+ .g = @truncate((cfg.cursor_color >> 8) & 0xFF), -+ .b = @truncate(cfg.cursor_color & 0xFF), -+ }; -+ colors.cursor = color.DynamicRGB.init(rgb); -+ } -+ // Apply palette colors (0 = use default) -+ for (cfg.palette, 0..) |palette_color, i| { -+ if (palette_color != 0) { -+ const rgb = color.RGB{ -+ .r = @truncate((palette_color >> 16) & 0xFF), -+ .g = @truncate((palette_color >> 8) & 0xFF), -+ .b = @truncate(palette_color & 0xFF), -+ }; -+ colors.palette.set(@intCast(i), rgb); -+ } -+ } -+ } -+ -+ wrapper.terminal = Terminal.init(alloc, .{ -+ .cols = @intCast(cols), -+ .rows = @intCast(rows), -+ .max_scrollback = scrollback_limit, -+ .colors = colors, -+ }) catch { -+ alloc.destroy(wrapper); -+ return null; -+ }; -+ -+ // Initialize response buffer -+ wrapper.response_buffer = .{}; -+ -+ // Initialize handler with references to terminal and response buffer -+ wrapper.handler = ResponseHandler.init(alloc, &wrapper.terminal, &wrapper.response_buffer); -+ -+ // Initialize stream with the handler -+ wrapper.stream = ResponseStream.init(wrapper.handler); -+ -+ wrapper.* = .{ -+ .alloc = alloc, -+ .terminal = wrapper.terminal, -+ .handler = wrapper.handler, -+ .stream = wrapper.stream, -+ .render_state = RenderState.empty, -+ .response_buffer = wrapper.response_buffer, -+ }; -+ -+ // NOTE: linefeed mode must be FALSE to match native terminal behavior -+ // When true, LF does automatic CR which breaks apps like nvim -+ wrapper.terminal.modes.set(.linefeed, false); -+ -+ // Enable grapheme clustering (mode 2027) by default for proper Unicode support. -+ // This makes Hindi, Arabic, emoji sequences, etc. render correctly by treating -+ // multi-codepoint grapheme clusters as single visual units. -+ wrapper.terminal.modes.set(.grapheme_cluster, true); -+ -+ return @ptrCast(wrapper); -+} -+ -+pub fn free(ptr: ?*anyopaque) callconv(.c) void { -+ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return)); -+ const alloc = wrapper.alloc; -+ wrapper.stream.deinit(); -+ wrapper.response_buffer.deinit(alloc); -+ wrapper.render_state.deinit(alloc); -+ wrapper.terminal.deinit(alloc); -+ alloc.destroy(wrapper); -+} -+ -+pub fn resize(ptr: ?*anyopaque, cols: c_int, rows: c_int) callconv(.c) void { -+ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return)); -+ wrapper.terminal.resize(wrapper.alloc, @intCast(cols), @intCast(rows)) catch return; -+} -+ -+pub fn write(ptr: ?*anyopaque, data: [*]const u8, len: usize) callconv(.c) void { -+ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return)); -+ wrapper.stream.nextSlice(data[0..len]) catch return; -+} -+ -+// ============================================================================ -+// RenderState API - High-performance rendering -+// ============================================================================ -+ -+/// Update render state from terminal. Call once per frame. -+/// Returns dirty state: 0=none, 1=partial, 2=full -+pub fn renderStateUpdate(ptr: ?*anyopaque) callconv(.c) GhosttyDirty { -+ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return .full)); -+ -+ // Detect screen buffer switch (normal <-> alternate) -+ const current_is_alternate = wrapper.terminal.screens.active_key == .alternate; -+ const screen_switched = current_is_alternate != wrapper.last_screen_is_alternate; -+ wrapper.last_screen_is_alternate = current_is_alternate; -+ -+ // When screen switches, we must fully reset the render state to avoid -+ // stale cached cell data from the previous screen buffer. -+ if (screen_switched) { -+ wrapper.render_state.deinit(wrapper.alloc); -+ wrapper.render_state = RenderState.empty; -+ } -+ -+ wrapper.render_state.update(wrapper.alloc, &wrapper.terminal) catch return .full; -+ -+ // If screen switched, always return full dirty to force complete redraw -+ if (screen_switched) { -+ return .full; -+ } -+ -+ return switch (wrapper.render_state.dirty) { -+ .false => .none, -+ .partial => .partial, -+ .full => .full, -+ }; -+} -+ -+/// Get dimensions from render state -+pub fn renderStateGetCols(ptr: ?*anyopaque) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ return @intCast(wrapper.render_state.cols); -+} -+ -+pub fn renderStateGetRows(ptr: ?*anyopaque) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ return @intCast(wrapper.render_state.rows); -+} -+ -+/// Get cursor X position -+pub fn renderStateGetCursorX(ptr: ?*anyopaque) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ return @intCast(wrapper.render_state.cursor.active.x); -+} -+ -+/// Get cursor Y position -+pub fn renderStateGetCursorY(ptr: ?*anyopaque) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ return @intCast(wrapper.render_state.cursor.active.y); -+} -+ -+/// Check if cursor is visible -+pub fn renderStateGetCursorVisible(ptr: ?*anyopaque) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ return wrapper.render_state.cursor.visible; -+} -+ -+/// Get default background color as 0xRRGGBB -+pub fn renderStateGetBgColor(ptr: ?*anyopaque) callconv(.c) u32 { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ const bg = wrapper.render_state.colors.background; -+ return (@as(u32, bg.r) << 16) | (@as(u32, bg.g) << 8) | bg.b; -+} -+ -+/// Get default foreground color as 0xRRGGBB -+pub fn renderStateGetFgColor(ptr: ?*anyopaque) callconv(.c) u32 { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0xCCCCCC)); -+ const fg = wrapper.render_state.colors.foreground; -+ return (@as(u32, fg.r) << 16) | (@as(u32, fg.g) << 8) | fg.b; -+} -+ -+/// Check if row is dirty -+pub fn renderStateIsRowDirty(ptr: ?*anyopaque, y: c_int) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return true)); -+ if (wrapper.render_state.dirty == .full) return true; -+ if (wrapper.render_state.dirty == .false) return false; -+ const y_usize: usize = @intCast(y); -+ if (y_usize >= wrapper.render_state.row_data.len) return false; -+ return wrapper.render_state.row_data.items(.dirty)[y_usize]; -+} -+ -+/// Mark render state as clean after rendering -+pub fn renderStateMarkClean(ptr: ?*anyopaque) callconv(.c) void { -+ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return)); -+ wrapper.render_state.dirty = .false; -+ @memset(wrapper.render_state.row_data.items(.dirty), false); -+} -+ -+/// Get ALL viewport cells in one call - reads directly from terminal screen buffer. -+/// This bypasses the RenderState cache to ensure fresh data for all rows. -+/// Returns total cells written (rows * cols), or -1 on error. -+pub fn renderStateGetViewport( -+ ptr: ?*anyopaque, -+ out: [*]GhosttyCell, -+ buf_size: usize, -+) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); -+ const rs = &wrapper.render_state; -+ const t = &wrapper.terminal; -+ const rows = rs.rows; -+ const cols = rs.cols; -+ const total: usize = @as(usize, rows) * cols; -+ -+ if (buf_size < total) return -1; -+ -+ // Read directly from terminal's active screen, bypassing RenderState cache. -+ // This ensures we always get fresh data for ALL rows, not just dirty ones. -+ const pages = &t.screens.active.pages; -+ -+ var idx: usize = 0; -+ for (0..rows) |y| { -+ // Get the row from the active viewport -+ const pin = pages.pin(.{ .active = .{ .y = @intCast(y) } }) orelse { -+ // Row doesn't exist, fill with defaults -+ for (0..cols) |_| { -+ out[idx] = .{ -+ .codepoint = 0, -+ .fg_r = rs.colors.foreground.r, -+ .fg_g = rs.colors.foreground.g, -+ .fg_b = rs.colors.foreground.b, -+ .bg_r = rs.colors.background.r, -+ .bg_g = rs.colors.background.g, -+ .bg_b = rs.colors.background.b, -+ .flags = 0, -+ .width = 1, -+ .hyperlink_id = 0, -+ }; -+ idx += 1; -+ } -+ continue; -+ }; -+ -+ const cells = pin.cells(.all); -+ const page = pin.node.data; -+ -+ for (0..cols) |x| { -+ if (x >= cells.len) { -+ // Past end of row, fill with default -+ out[idx] = .{ -+ .codepoint = 0, -+ .fg_r = rs.colors.foreground.r, -+ .fg_g = rs.colors.foreground.g, -+ .fg_b = rs.colors.foreground.b, -+ .bg_r = rs.colors.background.r, -+ .bg_g = rs.colors.background.g, -+ .bg_b = rs.colors.background.b, -+ .flags = 0, -+ .width = 1, -+ .hyperlink_id = 0, -+ }; -+ idx += 1; -+ continue; -+ } -+ -+ const cell = &cells[x]; -+ -+ // Get style from page styles (cell has style_id) -+ const sty: Style = if (cell.style_id > 0) -+ page.styles.get(page.memory, cell.style_id).* -+ else -+ .{}; -+ -+ // Resolve colors -+ const fg: color.RGB = switch (sty.fg_color) { -+ .none => rs.colors.foreground, -+ .palette => |i| rs.colors.palette[i], -+ .rgb => |rgb| rgb, -+ }; -+ const bg: color.RGB = if (sty.bg(cell, &rs.colors.palette)) |rgb| rgb else rs.colors.background; -+ -+ // Build flags -+ var flags: u8 = 0; -+ if (sty.flags.bold) flags |= 1 << 0; -+ if (sty.flags.italic) flags |= 1 << 1; -+ if (sty.flags.underline != .none) flags |= 1 << 2; -+ if (sty.flags.strikethrough) flags |= 1 << 3; -+ if (sty.flags.inverse) flags |= 1 << 4; -+ if (sty.flags.invisible) flags |= 1 << 5; -+ if (sty.flags.blink) flags |= 1 << 6; -+ if (sty.flags.faint) flags |= 1 << 7; -+ -+ // Get grapheme length if cell has grapheme data -+ const grapheme_len: u8 = if (cell.hasGrapheme()) -+ if (page.lookupGrapheme(cell)) |cps| @min(@as(u8, @intCast(cps.len)), 255) else 0 -+ else -+ 0; -+ -+ out[idx] = .{ -+ .codepoint = cell.codepoint(), -+ .fg_r = fg.r, -+ .fg_g = fg.g, -+ .fg_b = fg.b, -+ .bg_r = bg.r, -+ .bg_g = bg.g, -+ .bg_b = bg.b, -+ .flags = flags, -+ .width = switch (cell.wide) { -+ .narrow => 1, -+ .wide => 2, -+ .spacer_tail, .spacer_head => 0, -+ }, -+ .hyperlink_id = if (cell.hyperlink) 1 else 0, -+ .grapheme_len = grapheme_len, -+ }; -+ idx += 1; -+ } -+ } -+ -+ return @intCast(total); -+} -+ -+/// Get grapheme codepoints for a cell at (row, col). -+/// Returns all codepoints (including the first one) as u32 values. -+/// Returns the number of codepoints written, or -1 on error. -+pub fn renderStateGetGrapheme( -+ ptr: ?*anyopaque, -+ row: c_int, -+ col: c_int, -+ out: [*]u32, -+ buf_size: usize, -+) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); -+ const rs = &wrapper.render_state; -+ const t = &wrapper.terminal; -+ const cols: usize = @intCast(rs.cols); -+ -+ if (row < 0 or col < 0) return -1; -+ if (@as(usize, @intCast(row)) >= rs.rows) return -1; -+ if (@as(usize, @intCast(col)) >= cols) return -1; -+ if (buf_size < 1) return -1; -+ -+ // Get the pin for this row from the terminal's active screen -+ const pages = &t.screens.active.pages; -+ const pin = pages.pin(.{ .active = .{ .y = @intCast(row) } }) orelse return -1; -+ -+ const cells = pin.cells(.all); -+ const page = pin.node.data; -+ const x: usize = @intCast(col); -+ -+ if (x >= cells.len) return -1; -+ -+ const cell = &cells[x]; -+ -+ // First codepoint is always from the cell -+ out[0] = cell.codepoint(); -+ var count: usize = 1; -+ -+ // Add extra codepoints from grapheme map if present -+ if (cell.hasGrapheme()) { -+ if (page.lookupGrapheme(cell)) |cps| { -+ for (cps) |cp| { -+ if (count >= buf_size) break; -+ out[count] = cp; -+ count += 1; -+ } -+ } -+ } -+ -+ return @intCast(count); -+} -+ -+// ============================================================================ -+// Terminal Modes (minimal set for compatibility) -+// ============================================================================ -+ -+pub fn isAlternateScreen(ptr: ?*anyopaque) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ return wrapper.terminal.screens.active_key == .alternate; -+} -+ -+pub fn hasMouseTracking(ptr: ?*anyopaque) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ return wrapper.terminal.modes.get(.mouse_event_normal) or -+ wrapper.terminal.modes.get(.mouse_event_button) or -+ wrapper.terminal.modes.get(.mouse_event_any); -+} -+ -+/// Query arbitrary terminal mode by number -+/// Returns true if mode is set, false otherwise -+pub fn getMode(ptr: ?*anyopaque, mode_num: c_int, is_ansi: bool) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ const mode = modespkg.modeFromInt(@intCast(mode_num), is_ansi) orelse return false; -+ return wrapper.terminal.modes.get(mode); -+} -+ -+// ============================================================================ -+// Scrollback API -+// ============================================================================ -+ -+/// Get the number of scrollback lines (history, not including active screen) -+pub fn getScrollbackLength(ptr: ?*anyopaque) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ const pages = &wrapper.terminal.screens.active.pages; -+ // total_rows includes both scrollback and active area -+ // We subtract rows (active area) to get just scrollback -+ if (pages.total_rows <= pages.rows) return 0; -+ return @intCast(pages.total_rows - pages.rows); -+} -+ -+/// Get a line from the scrollback buffer -+/// offset 0 = oldest line in scrollback, offset (length-1) = most recent scrollback line -+/// Returns number of cells written, or -1 on error -+pub fn getScrollbackLine( -+ ptr: ?*anyopaque, -+ offset: c_int, -+ out: [*]GhosttyCell, -+ buf_size: usize, -+) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); -+ const rs = &wrapper.render_state; -+ const cols = rs.cols; -+ -+ if (buf_size < cols) return -1; -+ if (offset < 0) return -1; -+ -+ const scrollback_len = getScrollbackLength(ptr); -+ if (offset >= scrollback_len) return -1; -+ -+ // Get the pin for this scrollback row -+ // history point: y=0 is oldest, y=scrollback_len-1 is newest -+ const pages = &wrapper.terminal.screens.active.pages; -+ const pin = pages.pin(.{ .history = .{ .y = @intCast(offset) } }) orelse return -1; -+ -+ // Get cells for this row -+ const cells = pin.cells(.all); -+ const page = pin.node.data; -+ -+ // Fill output buffer -+ for (0..cols) |x| { -+ if (x >= cells.len) { -+ // Fill with default -+ out[x] = .{ -+ .codepoint = 0, -+ .fg_r = rs.colors.foreground.r, -+ .fg_g = rs.colors.foreground.g, -+ .fg_b = rs.colors.foreground.b, -+ .bg_r = rs.colors.background.r, -+ .bg_g = rs.colors.background.g, -+ .bg_b = rs.colors.background.b, -+ .flags = 0, -+ .width = 1, -+ .hyperlink_id = 0, -+ }; -+ continue; -+ } -+ -+ const cell = &cells[x]; -+ -+ // Get style from page styles (cell has style_id) -+ const sty: Style = if (cell.style_id > 0) -+ page.styles.get(page.memory, cell.style_id).* -+ else -+ .{}; -+ -+ // Resolve colors -+ const fg: color.RGB = switch (sty.fg_color) { -+ .none => rs.colors.foreground, -+ .palette => |i| rs.colors.palette[i], -+ .rgb => |rgb| rgb, -+ }; -+ const bg: color.RGB = if (sty.bg(cell, &rs.colors.palette)) |rgb| rgb else rs.colors.background; -+ -+ // Build flags -+ var flags: u8 = 0; -+ if (sty.flags.bold) flags |= 1 << 0; -+ if (sty.flags.italic) flags |= 1 << 1; -+ if (sty.flags.underline != .none) flags |= 1 << 2; -+ if (sty.flags.strikethrough) flags |= 1 << 3; -+ if (sty.flags.inverse) flags |= 1 << 4; -+ if (sty.flags.invisible) flags |= 1 << 5; -+ if (sty.flags.blink) flags |= 1 << 6; -+ if (sty.flags.faint) flags |= 1 << 7; -+ -+ // Get grapheme length if cell has grapheme data -+ const grapheme_len: u8 = if (cell.hasGrapheme()) -+ if (page.lookupGrapheme(cell)) |cps| @min(@as(u8, @intCast(cps.len)), 255) else 0 -+ else -+ 0; -+ -+ out[x] = .{ -+ .codepoint = cell.codepoint(), -+ .fg_r = fg.r, -+ .fg_g = fg.g, -+ .fg_b = fg.b, -+ .bg_r = bg.r, -+ .bg_g = bg.g, -+ .bg_b = bg.b, -+ .flags = flags, -+ .width = switch (cell.wide) { -+ .narrow => 1, -+ .wide => 2, -+ .spacer_tail, .spacer_head => 0, -+ }, -+ .hyperlink_id = if (cell.hyperlink) 1 else 0, -+ .grapheme_len = grapheme_len, -+ }; -+ } -+ return @intCast(cols); -+} -+ -+/// Get grapheme codepoints for a cell in the scrollback buffer. -+/// Returns all codepoints (including the first one) as u32 values. -+/// Returns the number of codepoints written, or -1 on error. -+pub fn getScrollbackGrapheme( -+ ptr: ?*anyopaque, -+ offset: c_int, -+ col: c_int, -+ out: [*]u32, -+ buf_size: usize, -+) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); -+ const rs = &wrapper.render_state; -+ const cols: usize = @intCast(rs.cols); -+ -+ if (offset < 0 or col < 0) return -1; -+ if (@as(usize, @intCast(col)) >= cols) return -1; -+ if (buf_size < 1) return -1; -+ -+ const scrollback_len = getScrollbackLength(ptr); -+ if (offset >= scrollback_len) return -1; -+ -+ // Get the pin for this scrollback row -+ const pages = &wrapper.terminal.screens.active.pages; -+ const pin = pages.pin(.{ .history = .{ .y = @intCast(offset) } }) orelse return -1; -+ -+ const cells = pin.cells(.all); -+ const page = pin.node.data; -+ const x: usize = @intCast(col); -+ -+ if (x >= cells.len) return -1; -+ -+ const cell = &cells[x]; -+ -+ // First codepoint is always from the cell -+ out[0] = cell.codepoint(); -+ var count: usize = 1; -+ -+ // Add extra codepoints from grapheme map if present -+ if (cell.hasGrapheme()) { -+ if (page.lookupGrapheme(cell)) |cps| { -+ for (cps) |cp| { -+ if (count >= buf_size) break; -+ out[count] = cp; -+ count += 1; -+ } -+ } -+ } -+ -+ return @intCast(count); -+} -+ -+/// Check if a row is a continuation from the previous row (soft-wrapped) -+/// This matches xterm.js semantics where isWrapped indicates the row continues -+/// from the previous row, not that it wraps to the next row. -+pub fn isRowWrapped(ptr: ?*anyopaque, y: c_int) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ const pages = &wrapper.terminal.screens.active.pages; -+ -+ // Get pin for this row in active area -+ const pin = pages.pin(.{ .active = .{ .y = @intCast(y) } }) orelse return false; -+ const rac = pin.rowAndCell(); -+ -+ // wrap_continuation means this row continues from the previous row -+ return rac.row.wrap_continuation; -+} -+ -+// ============================================================================ -+// Hyperlink API -+// ============================================================================ -+ -+/// Get the hyperlink URI for a cell in the active viewport. -+/// Returns number of bytes written, 0 if no hyperlink, -1 on error. -+pub fn getHyperlinkUri( -+ ptr: ?*anyopaque, -+ row: c_int, -+ col: c_int, -+ out: [*]u8, -+ buf_size: usize, -+) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); -+ const t = &wrapper.terminal; -+ -+ if (row < 0 or col < 0) return -1; -+ -+ // Get the pin for this row from the terminal's active screen -+ const pages = &t.screens.active.pages; -+ const pin = pages.pin(.{ .active = .{ .y = @intCast(row) } }) orelse return -1; -+ -+ const cells = pin.cells(.all); -+ const page = pin.node.data; -+ const x: usize = @intCast(col); -+ -+ if (x >= cells.len) return -1; -+ -+ const cell = &cells[x]; -+ -+ // Check if cell has a hyperlink -+ if (!cell.hyperlink) return 0; -+ -+ // Look up the hyperlink ID from the page -+ const hyperlink_id = page.lookupHyperlink(cell) orelse return 0; -+ -+ // Get the hyperlink entry from the set -+ const hyperlink_entry = page.hyperlink_set.get(page.memory, hyperlink_id); -+ -+ // Get the URI bytes from the page memory -+ const uri = hyperlink_entry.uri.slice(page.memory); -+ -+ if (uri.len == 0) return 0; -+ if (buf_size < uri.len) return -1; -+ -+ @memcpy(out[0..uri.len], uri); -+ return @intCast(uri.len); -+} -+ -+/// Get the hyperlink URI for a cell in the scrollback buffer. -+/// @param offset Scrollback line offset (0 = oldest, scrollback_len-1 = newest) -+/// @param col Column index (0-based) -+/// Returns number of bytes written, 0 if no hyperlink, -1 on error. -+pub fn getScrollbackHyperlinkUri( -+ ptr: ?*anyopaque, -+ offset: c_int, -+ col: c_int, -+ out: [*]u8, -+ buf_size: usize, -+) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); -+ -+ if (offset < 0 or col < 0) return -1; -+ -+ const scrollback_len = getScrollbackLength(ptr); -+ if (offset >= scrollback_len) return -1; -+ -+ // Get the pin for this scrollback row -+ const pages = &wrapper.terminal.screens.active.pages; -+ const pin = pages.pin(.{ .history = .{ .y = @intCast(offset) } }) orelse return -1; -+ -+ const cells = pin.cells(.all); -+ const page = pin.node.data; -+ const x: usize = @intCast(col); -+ -+ if (x >= cells.len) return -1; -+ -+ const cell = &cells[x]; -+ -+ // Check if cell has a hyperlink -+ if (!cell.hyperlink) return 0; -+ -+ // Look up the hyperlink ID from the page -+ const hyperlink_id = page.lookupHyperlink(cell) orelse return 0; -+ -+ // Get the hyperlink entry from the set -+ const hyperlink_entry = page.hyperlink_set.get(page.memory, hyperlink_id); -+ -+ // Get the URI bytes from the page memory -+ const uri = hyperlink_entry.uri.slice(page.memory); -+ -+ if (uri.len == 0) return 0; -+ if (buf_size < uri.len) return -1; -+ -+ @memcpy(out[0..uri.len], uri); -+ return @intCast(uri.len); -+} -+ -+// ============================================================================ -+// Response API - for DSR and other terminal queries -+// ============================================================================ -+ -+/// Check if there are pending responses from the terminal -+pub fn hasResponse(ptr: ?*anyopaque) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ return wrapper.response_buffer.items.len > 0; -+} -+ -+/// Read pending responses from the terminal. -+/// Returns number of bytes written to buffer, or 0 if no responses pending. -+/// Returns -1 on error (null pointer or buffer too small). -+pub fn readResponse(ptr: ?*anyopaque, out: [*]u8, buf_size: usize) callconv(.c) c_int { -+ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); -+ const len = @min(wrapper.response_buffer.items.len, buf_size); -+ if (len == 0) return 0; -+ -+ @memcpy(out[0..len], wrapper.response_buffer.items[0..len]); -+ -+ // Remove consumed bytes from buffer -+ if (len == wrapper.response_buffer.items.len) { -+ wrapper.response_buffer.clearRetainingCapacity(); -+ } else { -+ // Shift remaining bytes to front -+ std.mem.copyForwards( -+ u8, -+ wrapper.response_buffer.items[0..], -+ wrapper.response_buffer.items[len..], -+ ); -+ wrapper.response_buffer.shrinkRetainingCapacity(wrapper.response_buffer.items.len - len); -+ } -+ -+ return @intCast(len); -+} -+ -+// ============================================================================ -+// Tests -+// ============================================================================ -+ -+test "terminal lifecycle" { -+ const term = new(80, 24); -+ defer free(term); -+ try std.testing.expect(term != null); -+ -+ _ = renderStateUpdate(term); -+ try std.testing.expectEqual(@as(c_int, 80), renderStateGetCols(term)); -+ try std.testing.expectEqual(@as(c_int, 24), renderStateGetRows(term)); -+} -+ -+test "terminal write and read via render state" { -+ const term = new(80, 24); -+ defer free(term); -+ -+ write(term, "Hello", 5); -+ _ = renderStateUpdate(term); -+ -+ var cells: [80 * 24]GhosttyCell = undefined; -+ const count = renderStateGetViewport(term, &cells, 80 * 24); -+ try std.testing.expectEqual(@as(c_int, 80 * 24), count); -+ try std.testing.expectEqual(@as(u32, 'H'), cells[0].codepoint); -+ try std.testing.expectEqual(@as(u32, 'e'), cells[1].codepoint); -+ try std.testing.expectEqual(@as(u32, 'l'), cells[2].codepoint); -+ try std.testing.expectEqual(@as(u32, 'l'), cells[3].codepoint); -+ try std.testing.expectEqual(@as(u32, 'o'), cells[4].codepoint); -+} -diff --git a/src/terminal/render.zig b/src/terminal/render.zig -index b6430ea34..10e0ef79d 100644 ---- a/src/terminal/render.zig -+++ b/src/terminal/render.zig -@@ -322,13 +322,14 @@ pub const RenderState = struct { - // Colors. - self.colors.cursor = t.colors.cursor.get(); - self.colors.palette = t.colors.palette.current; -- bg_fg: { -+ { - // Background/foreground can be unset initially which would -- // depend on "default" background/foreground. The expected use -- // case of Terminal is that the caller set their own configured -- // defaults on load so this doesn't happen. -- const bg = t.colors.background.get() orelse break :bg_fg; -- const fg = t.colors.foreground.get() orelse break :bg_fg; -+ // depend on "default" background/foreground. Use sensible defaults -+ // (black background, light gray foreground) when not explicitly set. -+ const default_bg: color.RGB = .{ .r = 0, .g = 0, .b = 0 }; -+ const default_fg: color.RGB = .{ .r = 204, .g = 204, .b = 204 }; -+ const bg = t.colors.background.get() orelse default_bg; -+ const fg = t.colors.foreground.get() orelse default_fg; - if (t.modes.get(.reverse_colors)) { - self.colors.background = fg; - self.colors.foreground = bg; diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index 53014fd..ca46809 100755 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -3,13 +3,14 @@ set -euo pipefail echo "🔨 Building ghostty-vt.wasm..." -# Check for Zig +# Check for Zig (ghostty's build.zig pins a specific version) if ! command -v zig &> /dev/null; then echo "❌ Error: Zig not found" echo "" - echo "Install Zig 0.15.2+:" - echo " macOS: brew install zig" - echo " Linux: https://ziglang.org/download/" + echo "Use the version pinned by ghostty/build.zig (currently 0.15.2)." + echo " macOS: brew install zig (may not match)" + echo " Nix: nix develop" + echo " Manual: https://ziglang.org/download/" echo "" exit 1 fi @@ -17,39 +18,56 @@ fi ZIG_VERSION=$(zig version) echo "✓ Found Zig $ZIG_VERSION" -# Initialize/update submodule -if [ ! -d "ghostty/.git" ]; then +# Initialize submodule on first checkout (gitlink is a file, not a directory) +if [ ! -e "ghostty/.git" ]; then echo "📦 Initializing Ghostty submodule..." git submodule update --init --recursive else echo "📦 Ghostty submodule already initialized" fi -# Apply patch -echo "🔧 Applying WASM API patch..." +# Ensure submodule worktree is clean before patching (in case a previous build was interrupted) cd ghostty -git apply --check ../patches/ghostty-wasm-api.patch || { - echo "❌ Patch doesn't apply cleanly" - echo "Ghostty may have changed. Check patches/ghostty-wasm-api.patch" - exit 1 -} -git apply ../patches/ghostty-wasm-api.patch +if [ -n "$(git status --porcelain)" ]; then + echo "🧹 Submodule has leftover changes, resetting..." + git restore . + git clean -fd +fi +cd .. + +# Apply patch (optional — skip if empty/missing) +PATCH=patches/ghostty-wasm-api.patch +if [ -s "$PATCH" ]; then + echo "🔧 Applying WASM API patch..." + cd ghostty + git apply --check "../$PATCH" || { + echo "❌ Patch doesn't apply cleanly" + echo "Ghostty may have changed. Check $PATCH" + exit 1 + } + git apply "../$PATCH" + cd .. +else + echo "🔧 No patch to apply (skipping)" +fi # Build WASM echo "⚙️ Building WASM (takes ~20 seconds)..." -zig build lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall +cd ghostty +zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall +cd .. # Copy to project root -cd .. cp ghostty/zig-out/bin/ghostty-vt.wasm ./ -# Revert patch to keep submodule clean +# Revert patch & clean any new files it created so the submodule stays clean echo "🧹 Cleaning up..." cd ghostty -git apply -R ../patches/ghostty-wasm-api.patch -# Remove new files created by the patch -rm -f include/ghostty/vt/terminal.h -rm -f src/terminal/c/terminal.zig +if [ -s "../$PATCH" ]; then + git apply -R "../$PATCH" +fi +git restore . +git clean -fd cd .. SIZE=$(du -h ghostty-vt.wasm | cut -f1) From 3869ca6d37a2024984f9df2e2ca6faec8debd672 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Tue, 28 Apr 2026 13:17:41 -0400 Subject: [PATCH 02/29] feat: Wire terminal new/vt_write/resize to upstream C ABI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/ghostty.ts | 73 +++++++++++++++++++++----------------------------- lib/types.ts | 21 ++++++++++++--- 2 files changed, 48 insertions(+), 46 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index f079885..6b8520b 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -10,7 +10,6 @@ import { CellFlags, type Cursor, DirtyState, - GHOSTTY_CONFIG_SIZE, type GhosttyCell, type GhosttyTerminalConfig, type GhosttyWasmExports, @@ -280,51 +279,39 @@ export class GhosttyTerminal { this._cols = cols; this._rows = rows; - if (config) { - // Allocate config struct in WASM memory - const configPtr = this.exports.ghostty_wasm_alloc_u8_array(GHOSTTY_CONFIG_SIZE); - if (configPtr === 0) { - throw new Error('Failed to allocate config (out of memory)'); - } + // GhosttyTerminalOptions layout (8 bytes on wasm32): + // u16 cols @ 0 + // u16 rows @ 2 + // u32 max_scrollback @ 4 (size_t is u32 on wasm32) + const TERM_OPTS_SIZE = 8; + const optsPtr = this.exports.ghostty_wasm_alloc_u8_array(TERM_OPTS_SIZE); + if (optsPtr === 0) throw new Error('Failed to allocate terminal options'); + const termPtrPtr = this.exports.ghostty_wasm_alloc_opaque(); + if (termPtrPtr === 0) { + this.exports.ghostty_wasm_free_u8_array(optsPtr, TERM_OPTS_SIZE); + throw new Error('Failed to allocate terminal handle'); + } + try { + const optsView = new DataView(this.memory.buffer, optsPtr, TERM_OPTS_SIZE); + optsView.setUint16(0, cols, true); + optsView.setUint16(2, rows, true); + optsView.setUint32(4, config?.scrollbackLimit ?? 10000, true); - try { - // Write config to WASM memory - const view = new DataView(this.memory.buffer); - let offset = configPtr; - - // scrollback_limit (u32) - view.setUint32(offset, config.scrollbackLimit ?? 10000, true); - offset += 4; - - // fg_color (u32) - view.setUint32(offset, config.fgColor ?? 0, true); - offset += 4; - - // bg_color (u32) - view.setUint32(offset, config.bgColor ?? 0, true); - offset += 4; - - // cursor_color (u32) - view.setUint32(offset, config.cursorColor ?? 0, true); - offset += 4; - - // palette[16] (u32 * 16) - for (let i = 0; i < 16; i++) { - view.setUint32(offset, config.palette?.[i] ?? 0, true); - offset += 4; - } + const result = this.exports.ghostty_terminal_new(0, termPtrPtr, optsPtr); + if (result !== 0) throw new Error(`ghostty_terminal_new failed: ${result}`); - this.handle = this.exports.ghostty_terminal_new_with_config(cols, rows, configPtr); - } finally { - // Free the config memory - this.exports.ghostty_wasm_free_u8_array(configPtr, GHOSTTY_CONFIG_SIZE); - } - } else { - this.handle = this.exports.ghostty_terminal_new(cols, rows); + this.handle = new DataView(this.memory.buffer).getUint32(termPtrPtr, true); + } finally { + this.exports.ghostty_wasm_free_u8_array(optsPtr, TERM_OPTS_SIZE); + this.exports.ghostty_wasm_free_opaque(termPtrPtr); } if (!this.handle) throw new Error('Failed to create terminal'); + // TODO: apply config.fgColor / bgColor / cursorColor / palette via + // ghostty_terminal_set(GHOSTTY_TERMINAL_OPT_COLOR_*) once the option + // bindings are wired up. + this.initCellPool(); } @@ -343,7 +330,7 @@ export class GhosttyTerminal { const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; const ptr = this.exports.ghostty_wasm_alloc_u8_array(bytes.length); new Uint8Array(this.memory.buffer).set(bytes, ptr); - this.exports.ghostty_terminal_write(this.handle, ptr, bytes.length); + this.exports.ghostty_terminal_vt_write(this.handle, ptr, bytes.length); this.exports.ghostty_wasm_free_u8_array(ptr, bytes.length); } @@ -351,7 +338,9 @@ export class GhosttyTerminal { if (cols === this._cols && rows === this._rows) return; this._cols = cols; this._rows = rows; - this.exports.ghostty_terminal_resize(this.handle, cols, rows); + // TODO: thread real cell pixel dims (currently 0 = unknown/disabled, + // affects size reports and image protocols only). + this.exports.ghostty_terminal_resize(this.handle, cols, rows, 0, 0); this.invalidateBuffers(); this.initCellPool(); } diff --git a/lib/types.ts b/lib/types.ts index 4d6eefa..c687a82 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -408,11 +408,24 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { ghostty_key_event_set_utf8(event: number, ptr: number, len: number): void; // Terminal lifecycle - ghostty_terminal_new(cols: number, rows: number): TerminalHandle; - ghostty_terminal_new_with_config(cols: number, rows: number, configPtr: number): TerminalHandle; + ghostty_terminal_new( + allocatorPtr: number, + terminalPtrPtr: number, + optionsPtr: number + ): number; // GhosttyResult (0 = success) ghostty_terminal_free(terminal: TerminalHandle): void; - ghostty_terminal_resize(terminal: TerminalHandle, cols: number, rows: number): void; - ghostty_terminal_write(terminal: TerminalHandle, dataPtr: number, dataLen: number): void; + ghostty_terminal_resize( + terminal: TerminalHandle, + cols: number, + rows: number, + cellWidthPx: number, + cellHeightPx: number + ): number; + ghostty_terminal_vt_write( + terminal: TerminalHandle, + dataPtr: number, + dataLen: number + ): void; // RenderState API - high-performance rendering (ONE call gets ALL data) ghostty_render_state_update(terminal: TerminalHandle): number; // 0=none, 1=partial, 2=full From 16312428aadb13fe256e320a8a7a63946ea9cb99 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Tue, 28 Apr 2026 13:40:49 -0400 Subject: [PATCH 03/29] feat: Rewire render state onto upstream's key/value API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/ghostty.ts | 206 +++++++++++++++++++++++++++++++------------------ lib/types.ts | 85 ++++++++++++++------ 2 files changed, 191 insertions(+), 100 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 6b8520b..a52d28a 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -8,6 +8,7 @@ import { CellFlags, + CursorVisualStyle, type Cursor, DirtyState, type GhosttyCell, @@ -19,6 +20,8 @@ import { type RGB, type RenderStateColors, type RenderStateCursor, + RenderStateData, + RenderStateOption, type TerminalHandle, } from './types'; @@ -254,6 +257,7 @@ export class GhosttyTerminal { private exports: GhosttyWasmExports; private memory: WebAssembly.Memory; private handle: TerminalHandle; + private renderHandle: number = 0; private _cols: number; private _rows: number; @@ -312,9 +316,75 @@ export class GhosttyTerminal { // ghostty_terminal_set(GHOSTTY_TERMINAL_OPT_COLOR_*) once the option // bindings are wired up. + // Create the render state that owns the per-frame snapshot read by + // getCursor/getColors/getViewport. Render state is updated explicitly via + // update() rather than implicitly per read, since it's relatively cheap + // when the terminal hasn't changed but still costs a WASM crossing. + { + const stateP = this.exports.ghostty_wasm_alloc_opaque(); + if (stateP === 0) { + this.exports.ghostty_terminal_free(this.handle); + throw new Error('Failed to allocate render state handle'); + } + try { + const r = this.exports.ghostty_render_state_new(0, stateP); + if (r !== 0) { + this.exports.ghostty_terminal_free(this.handle); + throw new Error(`ghostty_render_state_new failed: ${r}`); + } + this.renderHandle = new DataView(this.memory.buffer).getUint32(stateP, true); + } finally { + this.exports.ghostty_wasm_free_opaque(stateP); + } + } + this.initCellPool(); } + // ========================================================================== + // RenderState scratch helpers + // + // The new render-state API exposes a single ghostty_render_state_get(state, + // key, *out) entry point keyed by GhosttyRenderStateData. Each helper + // allocates a small scratch buffer of the right size, performs the read, + // and frees. Per-call allocation is intentionally simple; if profiling + // shows it's hot, we can replace these with a single reusable scratch + // buffer carved up by offset. + // ========================================================================== + + private rsGetU8(key: number): number { + const p = this.exports.ghostty_wasm_alloc_u8(); + this.exports.ghostty_render_state_get(this.renderHandle, key, p); + const v = new DataView(this.memory.buffer).getUint8(p); + this.exports.ghostty_wasm_free_u8(p); + return v; + } + + private rsGetU16(key: number): number { + const p = this.exports.ghostty_wasm_alloc_u8_array(2); + this.exports.ghostty_render_state_get(this.renderHandle, key, p); + const v = new DataView(this.memory.buffer).getUint16(p, true); + this.exports.ghostty_wasm_free_u8_array(p, 2); + return v; + } + + private rsGetU32(key: number): number { + const p = this.exports.ghostty_wasm_alloc_u8_array(4); + this.exports.ghostty_render_state_get(this.renderHandle, key, p); + const v = new DataView(this.memory.buffer).getUint32(p, true); + this.exports.ghostty_wasm_free_u8_array(p, 4); + return v; + } + + private rsGetRgb(key: number): RGB { + const p = this.exports.ghostty_wasm_alloc_u8_array(3); + this.exports.ghostty_render_state_get(this.renderHandle, key, p); + const buf = new Uint8Array(this.memory.buffer, p, 3); + const rgb: RGB = { r: buf[0]!, g: buf[1]!, b: buf[2]! }; + this.exports.ghostty_wasm_free_u8_array(p, 3); + return rgb; + } + get cols(): number { return this._cols; } @@ -350,6 +420,10 @@ export class GhosttyTerminal { this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); this.viewportBufferPtr = 0; } + if (this.renderHandle) { + this.exports.ghostty_render_state_free(this.renderHandle); + this.renderHandle = 0; + } this.exports.ghostty_terminal_free(this.handle); } @@ -371,61 +445,75 @@ export class GhosttyTerminal { * Safe to call multiple times - dirty state persists until markClean(). */ update(): DirtyState { - return this.exports.ghostty_render_state_update(this.handle) as DirtyState; + const r = this.exports.ghostty_render_state_update(this.renderHandle, this.handle); + if (r !== 0) throw new Error(`ghostty_render_state_update failed: ${r}`); + // GhosttyRenderStateDirty is a 4-byte enum (FALSE=0, PARTIAL=1, FULL=2). + return this.rsGetU32(RenderStateData.DIRTY) as DirtyState; } /** * Get cursor state from render state. - * Ensures render state is fresh by calling update(). + * Calls update() first; safe to call repeatedly within a frame. */ getCursor(): RenderStateCursor { - // Call update() to ensure render state is fresh. - // This is safe to call multiple times - dirty state persists until markClean(). this.update(); + + const inViewport = this.rsGetU8(RenderStateData.CURSOR_VIEWPORT_HAS_VALUE) !== 0; + const visible = this.rsGetU8(RenderStateData.CURSOR_VISIBLE) !== 0; + const blinking = this.rsGetU8(RenderStateData.CURSOR_BLINKING) !== 0; + const styleRaw = this.rsGetU32(RenderStateData.CURSOR_VISUAL_STYLE); + + const viewportX = inViewport ? this.rsGetU16(RenderStateData.CURSOR_VIEWPORT_X) : -1; + const viewportY = inViewport ? this.rsGetU16(RenderStateData.CURSOR_VIEWPORT_Y) : -1; + + // Coder's interface only knows three styles; collapse BLOCK_HOLLOW into block. + const style: RenderStateCursor['style'] = + styleRaw === CursorVisualStyle.BAR + ? 'bar' + : styleRaw === CursorVisualStyle.UNDERLINE + ? 'underline' + : 'block'; + return { - x: this.exports.ghostty_render_state_get_cursor_x(this.handle), - y: this.exports.ghostty_render_state_get_cursor_y(this.handle), - viewportX: this.exports.ghostty_render_state_get_cursor_x(this.handle), - viewportY: this.exports.ghostty_render_state_get_cursor_y(this.handle), - visible: this.exports.ghostty_render_state_get_cursor_visible(this.handle), - blinking: false, // TODO: Add blinking support - style: 'block', // TODO: Add style support + x: Math.max(0, viewportX), + y: Math.max(0, viewportY), + viewportX, + viewportY, + visible, + blinking, + style, }; } /** - * Get default colors from render state + * Get default fg/bg/cursor colors from render state. */ getColors(): RenderStateColors { - const bg = this.exports.ghostty_render_state_get_bg_color(this.handle); - const fg = this.exports.ghostty_render_state_get_fg_color(this.handle); - return { - background: { - r: (bg >> 16) & 0xff, - g: (bg >> 8) & 0xff, - b: bg & 0xff, - }, - foreground: { - r: (fg >> 16) & 0xff, - g: (fg >> 8) & 0xff, - b: fg & 0xff, - }, - cursor: null, // TODO: Add cursor color support - }; + this.update(); + const background = this.rsGetRgb(RenderStateData.COLOR_BACKGROUND); + const foreground = this.rsGetRgb(RenderStateData.COLOR_FOREGROUND); + const hasCursor = this.rsGetU8(RenderStateData.COLOR_CURSOR_HAS_VALUE) !== 0; + const cursor = hasCursor ? this.rsGetRgb(RenderStateData.COLOR_CURSOR) : null; + return { background, foreground, cursor }; } /** - * Check if a specific row is dirty + * Check if a specific row is dirty. + * TODO: rewire onto the row iterator API (ghostty_render_state_row_get with + * RENDER_STATE_ROW_DATA_DIRTY). */ - isRowDirty(y: number): boolean { - return this.exports.ghostty_render_state_is_row_dirty(this.handle, y); + isRowDirty(_y: number): boolean { + throw new Error('isRowDirty not yet implemented for the new render-state API'); } /** - * Mark render state as clean (call after rendering) + * Mark render state as clean (call after rendering). */ markClean(): void { - this.exports.ghostty_render_state_mark_clean(this.handle); + const p = this.exports.ghostty_wasm_alloc_u8_array(4); + new DataView(this.memory.buffer).setUint32(p, DirtyState.NONE, true); + this.exports.ghostty_render_state_set(this.renderHandle, RenderStateOption.DIRTY, p); + this.exports.ghostty_wasm_free_u8_array(p, 4); } /** @@ -433,30 +521,12 @@ export class GhosttyTerminal { * Returns a reusable cell array (zero allocation after warmup). */ getViewport(): GhosttyCell[] { - const totalCells = this._cols * this._rows; - const neededSize = totalCells * GhosttyTerminal.CELL_SIZE; - - // Ensure buffer is allocated - if (!this.viewportBufferPtr || this.viewportBufferSize < neededSize) { - if (this.viewportBufferPtr) { - this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); - } - this.viewportBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(neededSize); - this.viewportBufferSize = neededSize; - } - - // Get all cells in one call - const count = this.exports.ghostty_render_state_get_viewport( - this.handle, - this.viewportBufferPtr, - totalCells - ); - - if (count < 0) return this.cellPool; - - // Parse cells into pool (reuses existing objects) - this.parseCellsIntoPool(this.viewportBufferPtr, totalCells); - return this.cellPool; + // TODO: rewire onto the row iterator + row_cells API: + // - _get(state, ROW_ITERATOR, &iter) + // - while (_row_iterator_next(iter)) { _row_get(iter, ROW_DATA_CELLS, &cells); ... } + // The reusable viewportBufferPtr can hold a single row's worth of cells + // and be re-driven each iteration. + throw new Error('getViewport not yet implemented for the new render-state API'); } // ========================================================================== @@ -788,26 +858,10 @@ export class GhosttyTerminal { * (Hindi, emoji with ZWJ, etc.) it returns multiple codepoints. * @returns Array of codepoints, or null on error */ - getGrapheme(row: number, col: number): number[] | null { - // Allocate buffer on first use (16 codepoints should be enough for any grapheme) - if (!this.graphemeBuffer) { - this.graphemeBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(16 * 4); - this.graphemeBuffer = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, 16); - } - - const count = this.exports.ghostty_render_state_get_grapheme( - this.handle, - row, - col, - this.graphemeBufferPtr, - 16 - ); - - if (count < 0) return null; - - // Re-create view in case memory grew - const view = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, count); - return Array.from(view); + getGrapheme(_row: number, _col: number): number[] | null { + // TODO: rewire onto the row cells API: + // _row_cells_select(cells, RAW) -> _row_cells_get(cells, GRAPHEMES, ...) + throw new Error('getGrapheme not yet implemented for the new render-state API'); } /** diff --git a/lib/types.ts b/lib/types.ts index c687a82..6808993 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -427,29 +427,26 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { dataLen: number ): void; - // RenderState API - high-performance rendering (ONE call gets ALL data) - ghostty_render_state_update(terminal: TerminalHandle): number; // 0=none, 1=partial, 2=full - ghostty_render_state_get_cols(terminal: TerminalHandle): number; - ghostty_render_state_get_rows(terminal: TerminalHandle): number; - ghostty_render_state_get_cursor_x(terminal: TerminalHandle): number; - ghostty_render_state_get_cursor_y(terminal: TerminalHandle): number; - ghostty_render_state_get_cursor_visible(terminal: TerminalHandle): boolean; - ghostty_render_state_get_bg_color(terminal: TerminalHandle): number; // 0xRRGGBB - ghostty_render_state_get_fg_color(terminal: TerminalHandle): number; // 0xRRGGBB - ghostty_render_state_is_row_dirty(terminal: TerminalHandle, row: number): boolean; - ghostty_render_state_mark_clean(terminal: TerminalHandle): void; - ghostty_render_state_get_viewport( - terminal: TerminalHandle, - bufPtr: number, - bufLen: number - ): number; // Returns total cells written or -1 on error - ghostty_render_state_get_grapheme( - terminal: TerminalHandle, - row: number, - col: number, - bufPtr: number, - bufLen: number - ): number; // Returns count of codepoints or -1 on error + // RenderState API — render state is a separate object created from a terminal. + // Read fields via the generic _get(state, key, *out) interface keyed by + // GhosttyRenderStateData; see RenderStateData enum. + ghostty_render_state_new(allocatorPtr: number, statePtrPtr: number): number; + ghostty_render_state_free(state: number): void; + ghostty_render_state_update(state: number, terminal: TerminalHandle): number; + ghostty_render_state_get(state: number, key: number, outPtr: number): number; + ghostty_render_state_get_multi( + state: number, + count: number, + keysPtr: number, + valuesPtr: number, + outWrittenPtr: number + ): number; + ghostty_render_state_set(state: number, option: number, valuePtr: number): number; + ghostty_render_state_colors_get(state: number, outColorsPtr: number): number; + // Row iteration (not yet wired up on the TS side): + // ghostty_render_state_row_get / _row_set / _row_iterator_* + // _row_cells_get / _row_cells_get_multi / _row_cells_select + // (used to implement isRowDirty / getViewport / getGrapheme) // Terminal modes ghostty_terminal_is_alternate_screen(terminal: TerminalHandle): boolean; @@ -499,7 +496,7 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { // ============================================================================ /** - * Dirty state from RenderState + * Dirty state from RenderState. Mirrors GhosttyRenderStateDirty. */ export enum DirtyState { NONE = 0, @@ -507,6 +504,46 @@ export enum DirtyState { FULL = 2, } +/** + * Keys for ghostty_render_state_get(). Mirrors GhosttyRenderStateData. + */ +export enum RenderStateData { + COLS = 1, + ROWS = 2, + DIRTY = 3, + ROW_ITERATOR = 4, + COLOR_BACKGROUND = 5, + COLOR_FOREGROUND = 6, + COLOR_CURSOR = 7, + COLOR_CURSOR_HAS_VALUE = 8, + COLOR_PALETTE = 9, + CURSOR_VISUAL_STYLE = 10, + CURSOR_VISIBLE = 11, + CURSOR_BLINKING = 12, + CURSOR_PASSWORD_INPUT = 13, + CURSOR_VIEWPORT_HAS_VALUE = 14, + CURSOR_VIEWPORT_X = 15, + CURSOR_VIEWPORT_Y = 16, + CURSOR_VIEWPORT_WIDE_TAIL = 17, +} + +/** + * Options for ghostty_render_state_set(). Mirrors GhosttyRenderStateOption. + */ +export enum RenderStateOption { + DIRTY = 0, +} + +/** + * Visual cursor style. Mirrors GhosttyRenderStateCursorVisualStyle. + */ +export enum CursorVisualStyle { + BAR = 0, + BLOCK = 1, + UNDERLINE = 2, + BLOCK_HOLLOW = 3, +} + /** * Cursor state from RenderState (8 bytes packed) * Layout: x(u16) + y(u16) + viewport_x(i16) + viewport_y(i16) + visible(bool) + blinking(bool) + style(u8) + _pad(u8) From bd59e4b65aee0be9cd3103d667e933138a43f961 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Tue, 28 Apr 2026 14:16:01 -0400 Subject: [PATCH 04/29] Rewire terminal_get family onto upstream's key/value API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/ghostty.ts | 250 ++++++++++++++----------------------------------- lib/types.ts | 106 +++++++++++++-------- 2 files changed, 134 insertions(+), 222 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index a52d28a..41c60c5 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -17,12 +17,15 @@ import { KeyEncoderOption, type KeyEvent, type KittyKeyFlags, + packMode, type RGB, type RenderStateColors, type RenderStateCursor, RenderStateData, RenderStateOption, + TerminalData, type TerminalHandle, + TerminalScreen, } from './types'; // Re-export types for convenience @@ -385,6 +388,30 @@ export class GhosttyTerminal { return rgb; } + // ========================================================================== + // Terminal property scratch helpers + // + // Same pattern as rsGet* but against ghostty_terminal_get(terminal, key, + // *out). The TerminalData enum encodes the value type; pick the matching + // helper by output size. + // ========================================================================== + + private tGetU8(key: number): number { + const p = this.exports.ghostty_wasm_alloc_u8(); + this.exports.ghostty_terminal_get(this.handle, key, p); + const v = new DataView(this.memory.buffer).getUint8(p); + this.exports.ghostty_wasm_free_u8(p); + return v; + } + + private tGetU32(key: number): number { + const p = this.exports.ghostty_wasm_alloc_u8_array(4); + this.exports.ghostty_terminal_get(this.handle, key, p); + const v = new DataView(this.memory.buffer).getUint32(p, true); + this.exports.ghostty_wasm_free_u8_array(p, 4); + return v; + } + get cols(): number { return this._cols; } @@ -572,7 +599,8 @@ export class GhosttyTerminal { // ========================================================================== isAlternateScreen(): boolean { - return !!this.exports.ghostty_terminal_is_alternate_screen(this.handle); + // ACTIVE_SCREEN returns a GhosttyTerminalScreen enum (4-byte int). + return this.tGetU32(TerminalData.ACTIVE_SCREEN) === TerminalScreen.ALTERNATE; } hasBracketedPaste(): boolean { @@ -586,7 +614,7 @@ export class GhosttyTerminal { } hasMouseTracking(): boolean { - return this.exports.ghostty_terminal_has_mouse_tracking(this.handle) !== 0; + return this.tGetU8(TerminalData.MOUSE_TRACKING) !== 0; } // ========================================================================== @@ -600,7 +628,8 @@ export class GhosttyTerminal { /** Get number of scrollback lines (history, not including active screen) */ getScrollbackLength(): number { - return this.exports.ghostty_terminal_get_scrollback_length(this.handle); + // SCROLLBACK_ROWS is size_t — 4 bytes on wasm32. + return this.tGetU32(TerminalData.SCROLLBACK_ROWS); } /** @@ -608,197 +637,71 @@ export class GhosttyTerminal { * Ensures render state is fresh by calling update(). * @param offset 0 = oldest line, (length-1) = most recent scrollback line */ - getScrollbackLine(offset: number): GhosttyCell[] | null { - const neededSize = this._cols * GhosttyTerminal.CELL_SIZE; - - // Ensure buffer is allocated - if (!this.viewportBufferPtr || this.viewportBufferSize < neededSize) { - if (this.viewportBufferPtr) { - this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); - } - this.viewportBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(neededSize); - this.viewportBufferSize = neededSize; - } - - // Call update() to ensure render state is fresh (needed for colors). - // This is safe to call multiple times - dirty state persists until markClean(). - this.update(); - - const count = this.exports.ghostty_terminal_get_scrollback_line( - this.handle, - offset, - this.viewportBufferPtr, - this._cols - ); - - if (count < 0) return null; - - // Parse cells - const cells: GhosttyCell[] = []; - const buffer = this.memory.buffer; - const u8 = new Uint8Array(buffer, this.viewportBufferPtr, count * GhosttyTerminal.CELL_SIZE); - const view = new DataView(buffer, this.viewportBufferPtr, count * GhosttyTerminal.CELL_SIZE); - - for (let i = 0; i < count; i++) { - const cellOffset = i * GhosttyTerminal.CELL_SIZE; - cells.push({ - codepoint: view.getUint32(cellOffset, true), - fg_r: u8[cellOffset + 4], - fg_g: u8[cellOffset + 5], - fg_b: u8[cellOffset + 6], - bg_r: u8[cellOffset + 7], - bg_g: u8[cellOffset + 8], - bg_b: u8[cellOffset + 9], - flags: u8[cellOffset + 10], - width: u8[cellOffset + 11], - hyperlink_id: view.getUint16(cellOffset + 12, true), - grapheme_len: u8[cellOffset + 14], - }); - } - - return cells; + getScrollbackLine(_offset: number): GhosttyCell[] | null { + // TODO: rewire onto the row iterator API: + // _grid_ref(terminal, ...) for scrollback rows + _row_cells_get(...) + // Old per-row buffer fill API (ghostty_terminal_get_scrollback_line) is gone. + throw new Error('getScrollbackLine not yet implemented for the new C ABI'); } /** Check if a row in the active screen is wrapped (soft-wrapped to next line) */ - isRowWrapped(row: number): boolean { - return this.exports.ghostty_terminal_is_row_wrapped(this.handle, row) !== 0; + isRowWrapped(_row: number): boolean { + // TODO: rewire onto grid_ref / row API. + throw new Error('isRowWrapped not yet implemented for the new C ABI'); } /** * Get the hyperlink URI for a cell at the given position. - * @param row Row index (0-based, in active viewport) - * @param col Column index (0-based) * @returns The URI string, or null if no hyperlink at that position */ - getHyperlinkUri(row: number, col: number): string | null { - // Check if WASM has this function (requires rebuilt WASM with hyperlink support) - if (!this.exports.ghostty_terminal_get_hyperlink_uri) { - return null; - } - - // Try with initial buffer, retry with larger if needed (for very long URLs) - const bufferSizes = [2048, 8192, 32768]; - - for (const bufSize of bufferSizes) { - const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize); - - try { - const bytesWritten = this.exports.ghostty_terminal_get_hyperlink_uri( - this.handle, - row, - col, - bufPtr, - bufSize - ); - - // 0 means no hyperlink at this position - if (bytesWritten === 0) return null; - - // -1 means buffer too small, try next size - if (bytesWritten === -1) continue; - - // Negative values other than -1 are errors - if (bytesWritten < 0) return null; - - const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesWritten); - return new TextDecoder().decode(bytes.slice()); - } finally { - this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize); - } - } - - // URI too long even for largest buffer - return null; + getHyperlinkUri(_row: number, _col: number): string | null { + // TODO: rewire onto grid_ref + cell hyperlink lookup. Old buffer-fill + // API (ghostty_terminal_get_hyperlink_uri) is gone. + throw new Error('getHyperlinkUri not yet implemented for the new C ABI'); } /** * Get the hyperlink URI for a cell in the scrollback buffer. - * @param offset Scrollback line offset (0 = oldest, scrollback_len-1 = newest) - * @param col Column index (0-based) - * @returns The URI string, or null if no hyperlink at that position */ - getScrollbackHyperlinkUri(offset: number, col: number): string | null { - // Check if WASM has this function - if (!this.exports.ghostty_terminal_get_scrollback_hyperlink_uri) { - return null; - } - - // Try with initial buffer, retry with larger if needed (for very long URLs) - const bufferSizes = [2048, 8192, 32768]; - - for (const bufSize of bufferSizes) { - const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize); - - try { - const bytesWritten = this.exports.ghostty_terminal_get_scrollback_hyperlink_uri( - this.handle, - offset, - col, - bufPtr, - bufSize - ); - - // 0 means no hyperlink at this position - if (bytesWritten === 0) return null; - - // -1 means buffer too small, try next size - if (bytesWritten === -1) continue; - - // Negative values other than -1 are errors - if (bytesWritten < 0) return null; - - const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesWritten); - return new TextDecoder().decode(bytes.slice()); - } finally { - this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize); - } - } - - // URI too long even for largest buffer - return null; + getScrollbackHyperlinkUri(_offset: number, _col: number): string | null { + // TODO: same path as getHyperlinkUri once grid_ref is wired up. + throw new Error('getScrollbackHyperlinkUri not yet implemented for the new C ABI'); } /** * Check if there are pending responses from the terminal. - * Responses are generated by escape sequences like DSR (Device Status Report). + * + * NOTE: the upstream C ABI replaced the polling has_response/read_response + * pair with a callback model: install one via + * ghostty_terminal_set(GHOSTTY_TERMINAL_OPT_WRITE_PTY, fn) and the terminal + * invokes it synchronously during vt_write() with response bytes. Until the + * callback infrastructure is wired up on the JS side, we report "no + * responses" so callers (e.g. demo/PTY echo) degrade gracefully. */ hasResponse(): boolean { - return this.exports.ghostty_terminal_has_response(this.handle); + return false; } /** - * Read pending responses from the terminal. - * Returns the response string, or null if no responses pending. - * - * Responses are generated by escape sequences that require replies: - * - DSR 6 (cursor position): Returns \x1b[row;colR - * - DSR 5 (operating status): Returns \x1b[0n + * Read pending responses from the terminal. See hasResponse() for the + * status of the callback-based replacement. */ readResponse(): string | null { - if (!this.hasResponse()) return null; - - const bufSize = 256; // Most responses are small - const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufSize); - - try { - const bytesRead = this.exports.ghostty_terminal_read_response(this.handle, bufPtr, bufSize); - - if (bytesRead <= 0) return null; - - const bytes = new Uint8Array(this.memory.buffer, bufPtr, bytesRead); - return new TextDecoder().decode(bytes.slice()); - } finally { - this.exports.ghostty_wasm_free_u8_array(bufPtr, bufSize); - } + return null; } /** - * Query arbitrary terminal mode by number + * Query arbitrary terminal mode by number. * @param mode Mode number (e.g., 25 for cursor visibility, 2004 for bracketed paste) * @param isAnsi True for ANSI modes, false for DEC modes (default: false) */ getMode(mode: number, isAnsi: boolean = false): boolean { - return this.exports.ghostty_terminal_get_mode(this.handle, mode, isAnsi) !== 0; + const packed = packMode(mode, isAnsi); + const out = this.exports.ghostty_wasm_alloc_u8(); + this.exports.ghostty_terminal_mode_get(this.handle, packed, out); + const v = new DataView(this.memory.buffer).getUint8(out); + this.exports.ghostty_wasm_free_u8(out); + return v !== 0; } // ========================================================================== @@ -880,26 +783,9 @@ export class GhosttyTerminal { * @param col Column index * @returns Array of codepoints, or null on error */ - getScrollbackGrapheme(offset: number, col: number): number[] | null { - // Reuse the same buffer as getGrapheme - if (!this.graphemeBuffer) { - this.graphemeBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(16 * 4); - this.graphemeBuffer = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, 16); - } - - const count = this.exports.ghostty_terminal_get_scrollback_grapheme( - this.handle, - offset, - col, - this.graphemeBufferPtr, - 16 - ); - - if (count < 0) return null; - - // Re-create view in case memory grew - const view = new Uint32Array(this.memory.buffer, this.graphemeBufferPtr, count); - return Array.from(view); + getScrollbackGrapheme(_offset: number, _col: number): number[] | null { + // TODO: rewire onto grid_ref + row_cells_get(GRAPHEMES) for scrollback rows. + throw new Error('getScrollbackGrapheme not yet implemented for the new C ABI'); } /** diff --git a/lib/types.ts b/lib/types.ts index 6808993..b8f70b3 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -448,47 +448,26 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { // _row_cells_get / _row_cells_get_multi / _row_cells_select // (used to implement isRowDirty / getViewport / getGrapheme) - // Terminal modes - ghostty_terminal_is_alternate_screen(terminal: TerminalHandle): boolean; - ghostty_terminal_has_mouse_tracking(terminal: TerminalHandle): number; - ghostty_terminal_get_mode(terminal: TerminalHandle, mode: number, isAnsi: boolean): number; - - // Scrollback API - ghostty_terminal_get_scrollback_length(terminal: TerminalHandle): number; - ghostty_terminal_get_scrollback_line( + // Generic terminal property API. Mirrors render_state_get/set: a single + // entry point keyed by GhosttyTerminalData (see TerminalData enum). + ghostty_terminal_get(terminal: TerminalHandle, key: number, outPtr: number): number; + ghostty_terminal_get_multi( terminal: TerminalHandle, - offset: number, - bufPtr: number, - bufLen: number - ): number; // Returns cells written or -1 on error - ghostty_terminal_get_scrollback_grapheme( - terminal: TerminalHandle, - offset: number, - col: number, - bufPtr: number, - bufLen: number - ): number; // Returns codepoint count or -1 on error - ghostty_terminal_is_row_wrapped(terminal: TerminalHandle, row: number): number; - - // Hyperlink API - ghostty_terminal_get_hyperlink_uri( - terminal: TerminalHandle, - row: number, - col: number, - bufPtr: number, - bufLen: number - ): number; // Returns bytes written, 0 if no hyperlink, -1 on error - ghostty_terminal_get_scrollback_hyperlink_uri( - terminal: TerminalHandle, - offset: number, - col: number, - bufPtr: number, - bufLen: number - ): number; // Returns bytes written, 0 if no hyperlink, -1 on error - - // Response API (for DSR and other terminal queries) - ghostty_terminal_has_response(terminal: TerminalHandle): boolean; - ghostty_terminal_read_response(terminal: TerminalHandle, bufPtr: number, bufLen: number): number; // Returns bytes written, 0 if no response, -1 on error + count: number, + keysPtr: number, + valuesPtr: number, + outWrittenPtr: number + ): number; + ghostty_terminal_set(terminal: TerminalHandle, option: number, valuePtr: number): number; + // Mode queries: mode is a packed u16 (low 15 bits = mode value, bit 15 = ANSI flag). + ghostty_terminal_mode_get(terminal: TerminalHandle, mode: number, outBoolPtr: number): number; + ghostty_terminal_mode_set(terminal: TerminalHandle, mode: number, value: boolean): number; + // grid_ref / point_from_grid_ref: row/cell-level access. Not yet wired + // up on the TS side (used to implement isRowWrapped / getHyperlinkUri / + // scrollback iteration). + // Response handling moved to a callback model: install via + // ghostty_terminal_set(GHOSTTY_TERMINAL_OPT_WRITE_PTY, callback). Old + // has_response / read_response polling API is gone. } // ============================================================================ @@ -544,6 +523,53 @@ export enum CursorVisualStyle { BLOCK_HOLLOW = 3, } +/** + * Keys for ghostty_terminal_get(). Mirrors GhosttyTerminalData. + * Only entries actually used by the TS layer are listed here; the upstream + * enum has more (TITLE, PWD, SCROLLBAR, KITTY_KEYBOARD_FLAGS, palettes, ...). + */ +export enum TerminalData { + COLS = 1, + ROWS = 2, + CURSOR_X = 3, + CURSOR_Y = 4, + CURSOR_PENDING_WRAP = 5, + ACTIVE_SCREEN = 6, + CURSOR_VISIBLE = 7, + KITTY_KEYBOARD_FLAGS = 8, + SCROLLBAR = 9, + CURSOR_STYLE = 10, + MOUSE_TRACKING = 11, + TITLE = 12, + PWD = 13, + TOTAL_ROWS = 14, + SCROLLBACK_ROWS = 15, + WIDTH_PX = 16, + HEIGHT_PX = 17, + COLOR_FOREGROUND = 18, + COLOR_BACKGROUND = 19, + COLOR_CURSOR = 20, + COLOR_PALETTE = 21, +} + +/** + * Active screen identifier. Mirrors GhosttyTerminalScreen. + * Returned as the value for TerminalData.ACTIVE_SCREEN. + */ +export enum TerminalScreen { + PRIMARY = 0, + ALTERNATE = 1, +} + +/** + * Pack a terminal mode number + ANSI flag into the u16 wire format used by + * ghostty_terminal_mode_get/_set. Bits 0–14 hold the value (u15), bit 15 + * is set for ANSI modes (cleared for DEC private modes). + */ +export function packMode(mode: number, isAnsi: boolean): number { + return (mode & 0x7fff) | (isAnsi ? 0x8000 : 0); +} + /** * Cursor state from RenderState (8 bytes packed) * Layout: x(u16) + y(u16) + viewport_x(i16) + viewport_y(i16) + visible(bool) + blinking(bool) + style(u8) + _pad(u8) From 2e329b2c25f3ae318adca47eecec84950731c8e4 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Tue, 28 Apr 2026 14:38:45 -0400 Subject: [PATCH 05/29] feat: Wire getViewport / isRowDirty onto the row iterator API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/ghostty.ts | 348 +++++++++++++++++++++++++++++++++++++++++++++---- lib/types.ts | 56 +++++++- 2 files changed, 372 insertions(+), 32 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 41c60c5..c2b733e 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -23,6 +23,8 @@ import { type RenderStateCursor, RenderStateData, RenderStateOption, + RenderStateRowData, + RowCellsData, TerminalData, type TerminalHandle, TerminalScreen, @@ -261,6 +263,8 @@ export class GhosttyTerminal { private memory: WebAssembly.Memory; private handle: TerminalHandle; private renderHandle: number = 0; + private rowIter: number = 0; + private rowCells: number = 0; private _cols: number; private _rows: number; @@ -274,6 +278,13 @@ export class GhosttyTerminal { /** Cell pool for zero-allocation rendering */ private cellPool: GhosttyCell[] = []; + /** + * Per-row dirty state for the current render-state snapshot. Cleared on + * update() and populated lazily by isRowDirty() (or as a side effect of + * getViewport, which iterates rows anyway). + */ + private rowDirtyCache: boolean[] | null = null; + constructor( exports: GhosttyWasmExports, memory: WebAssembly.Memory, @@ -323,25 +334,77 @@ export class GhosttyTerminal { // getCursor/getColors/getViewport. Render state is updated explicitly via // update() rather than implicitly per read, since it's relatively cheap // when the terminal hasn't changed but still costs a WASM crossing. - { - const stateP = this.exports.ghostty_wasm_alloc_opaque(); - if (stateP === 0) { - this.exports.ghostty_terminal_free(this.handle); - throw new Error('Failed to allocate render state handle'); - } - try { - const r = this.exports.ghostty_render_state_new(0, stateP); - if (r !== 0) { - this.exports.ghostty_terminal_free(this.handle); - throw new Error(`ghostty_render_state_new failed: ${r}`); - } - this.renderHandle = new DataView(this.memory.buffer).getUint32(stateP, true); - } finally { - this.exports.ghostty_wasm_free_opaque(stateP); + this.renderHandle = this.allocOpaqueOrFail( + 'ghostty_render_state_new', + (out) => this.exports.ghostty_render_state_new(0, out) + ); + // Pre-allocate the row iterator and row-cells iterators once and reuse + // them across frames. They're populated from the render state in + // getViewport via _get(ROW_ITERATOR) and _row_get(ROW_DATA_CELLS); the + // handles themselves stay live for the terminal's lifetime. + this.rowIter = this.allocOpaqueOrFail( + 'ghostty_render_state_row_iterator_new', + (out) => this.exports.ghostty_render_state_row_iterator_new(0, out) + ); + this.rowCells = this.allocOpaqueOrFail( + 'ghostty_render_state_row_cells_new', + (out) => this.exports.ghostty_render_state_row_cells_new(0, out) + ); + + this.initCellPool(); + } + + /** + * Allocate an opaque handle through one of the new(allocator, *outHandle) + * factory functions. Wraps the boilerplate of: alloc out-pointer, call + * factory, check Result, read the handle, free out-pointer. + * + * If the factory call fails, frees any already-acquired terminal/render + * resources so the caller-throwing flow doesn't leak across the partially + * constructed object. + */ + private allocOpaqueOrFail( + name: string, + factory: (outPtr: number) => number + ): number { + const outPtr = this.exports.ghostty_wasm_alloc_opaque(); + if (outPtr === 0) { + this.cleanupOnConstructorFailure(); + throw new Error(`Failed to allocate handle for ${name}`); + } + try { + const r = factory(outPtr); + if (r !== 0) { + this.cleanupOnConstructorFailure(); + throw new Error(`${name} failed: ${r}`); } + return new DataView(this.memory.buffer).getUint32(outPtr, true); + } finally { + this.exports.ghostty_wasm_free_opaque(outPtr); } + } - this.initCellPool(); + /** + * Release any resources that have been allocated by the constructor up to + * this point. Called when a subsequent step fails so we don't leak handles + * before the throw propagates. + */ + private cleanupOnConstructorFailure(): void { + if (this.rowCells) { + this.exports.ghostty_render_state_row_cells_free(this.rowCells); + this.rowCells = 0; + } + if (this.rowIter) { + this.exports.ghostty_render_state_row_iterator_free(this.rowIter); + this.rowIter = 0; + } + if (this.renderHandle) { + this.exports.ghostty_render_state_free(this.renderHandle); + this.renderHandle = 0; + } + if (this.handle) { + this.exports.ghostty_terminal_free(this.handle); + } } // ========================================================================== @@ -447,6 +510,14 @@ export class GhosttyTerminal { this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); this.viewportBufferPtr = 0; } + if (this.rowCells) { + this.exports.ghostty_render_state_row_cells_free(this.rowCells); + this.rowCells = 0; + } + if (this.rowIter) { + this.exports.ghostty_render_state_row_iterator_free(this.rowIter); + this.rowIter = 0; + } if (this.renderHandle) { this.exports.ghostty_render_state_free(this.renderHandle); this.renderHandle = 0; @@ -474,6 +545,8 @@ export class GhosttyTerminal { update(): DirtyState { const r = this.exports.ghostty_render_state_update(this.renderHandle, this.handle); if (r !== 0) throw new Error(`ghostty_render_state_update failed: ${r}`); + // Per-row dirty cache is tied to the previous snapshot. + this.rowDirtyCache = null; // GhosttyRenderStateDirty is a 4-byte enum (FALSE=0, PARTIAL=1, FULL=2). return this.rsGetU32(RenderStateData.DIRTY) as DirtyState; } @@ -526,11 +599,50 @@ export class GhosttyTerminal { /** * Check if a specific row is dirty. - * TODO: rewire onto the row iterator API (ghostty_render_state_row_get with - * RENDER_STATE_ROW_DATA_DIRTY). + * + * Backed by a per-row cache populated lazily — first call after update() + * walks the iterator once and reads the dirty flag for each row, then + * subsequent calls are O(1). getViewport() also populates the cache as a + * side effect so a typical "update → for-each-row isRowDirty → getViewport" + * render loop only iterates rows once. */ - isRowDirty(_y: number): boolean { - throw new Error('isRowDirty not yet implemented for the new render-state API'); + isRowDirty(y: number): boolean { + if (y < 0 || y >= this._rows) return false; + if (this.rowDirtyCache === null) this.refreshRowDirtyCache(); + return this.rowDirtyCache![y] ?? false; + } + + /** Walk the row iterator once and capture per-row dirty flags. */ + private refreshRowDirtyCache(): void { + const cache = new Array(this._rows).fill(false); + this.populateHandle( + (out) => + this.exports.ghostty_render_state_get( + this.renderHandle, + RenderStateData.ROW_ITERATOR, + out + ), + this.rowIter + ); + const dirtyPtr = this.exports.ghostty_wasm_alloc_u8(); + try { + let row = 0; + while ( + row < this._rows && + this.exports.ghostty_render_state_row_iterator_next(this.rowIter) + ) { + this.exports.ghostty_render_state_row_get( + this.rowIter, + RenderStateRowData.DIRTY, + dirtyPtr + ); + cache[row] = new DataView(this.memory.buffer).getUint8(dirtyPtr) !== 0; + row++; + } + } finally { + this.exports.ghostty_wasm_free_u8(dirtyPtr); + } + this.rowDirtyCache = cache; } /** @@ -544,16 +656,196 @@ export class GhosttyTerminal { } /** - * Get ALL viewport cells in ONE WASM call - the key performance optimization! - * Returns a reusable cell array (zero allocation after warmup). + * Populate the cellPool from the current render state and return it. + * + * The new C ABI replaces coder's single ghostty_render_state_get_viewport() + * buffer-fill with a row iterator + per-row cells iterator. We allocate + * both iterators once at construction time and re-populate them per call: + * + * _get(state, ROW_ITERATOR, &rowIter) + * while (row_iterator_next(rowIter)) { + * _row_get(rowIter, ROW_DATA_CELLS, &rowCells) + * while (row_cells_next(rowCells)) { + * _row_cells_get(rowCells, GRAPHEMES_LEN, &len) + * _row_cells_get(rowCells, GRAPHEMES_BUF, &codepoint) // if len > 0 + * _row_cells_get(rowCells, FG_COLOR/BG_COLOR, &rgb) // INVALID_VALUE if unset + * } + * } + * + * This is intentionally minimal: we capture codepoint + fg/bg only. + * Style flags, cell width (double-width), and hyperlink IDs are deferred + * — they require parsing the GhosttyStyle sized struct and the per-cell + * ghostty_cell_get(WIDE)/HAS_HYPERLINK paths. The cellPool fields keep + * placeholder defaults (flags=0, width=1, hyperlink_id=0). + * + * Performance: ~3-4 WASM crossings per visible cell. For an 80x24 viewport + * that's ~6k crossings per frame. Profile before optimizing — likely + * candidates are _row_cells_get_multi for batched reads, or RAW + a + * cached layout map for direct memory access. */ getViewport(): GhosttyCell[] { - // TODO: rewire onto the row iterator + row_cells API: - // - _get(state, ROW_ITERATOR, &iter) - // - while (_row_iterator_next(iter)) { _row_get(iter, ROW_DATA_CELLS, &cells); ... } - // The reusable viewportBufferPtr can hold a single row's worth of cells - // and be re-driven each iteration. - throw new Error('getViewport not yet implemented for the new render-state API'); + this.update(); + + // Pre-zero the pool so cells we don't visit (iterator ends early, or + // we exceed the configured cols/rows) read as empty. + this.zeroCellPool(); + + // Populate the row iterator from the render state. + // _get(state, ROW_ITERATOR, &iter) reads `*ptr` to get our pre-allocated + // iterator handle, then re-binds it to the current frame's row data. + this.populateHandle( + (out) => this.exports.ghostty_render_state_get(this.renderHandle, RenderStateData.ROW_ITERATOR, out), + this.rowIter + ); + + // Reusable scratch buffers — declared once outside the loops since cell + // counts are dominant. 4 bytes covers u32 (grapheme len, codepoint). + // 3 bytes covers GhosttyColorRgb. 1 byte covers per-row dirty bool. + const u32Ptr = this.exports.ghostty_wasm_alloc_u8_array(4); + const rgbPtr = this.exports.ghostty_wasm_alloc_u8_array(3); + const dirtyPtr = this.exports.ghostty_wasm_alloc_u8(); + // Populate the row dirty cache as a side effect — saves a redundant + // iterator walk if the renderer also calls isRowDirty() on this snapshot. + const dirtyCache = new Array(this._rows).fill(false); + try { + let row = 0; + while ( + row < this._rows && + this.exports.ghostty_render_state_row_iterator_next(this.rowIter) + ) { + // Capture per-row dirty for the cache. + this.exports.ghostty_render_state_row_get( + this.rowIter, + RenderStateRowData.DIRTY, + dirtyPtr + ); + dirtyCache[row] = + new DataView(this.memory.buffer).getUint8(dirtyPtr) !== 0; + + // Bind rowCells to this row. + this.populateHandle( + (out) => + this.exports.ghostty_render_state_row_get( + this.rowIter, + RenderStateRowData.CELLS, + out + ), + this.rowCells + ); + + let col = 0; + while ( + col < this._cols && + this.exports.ghostty_render_state_row_cells_next(this.rowCells) + ) { + const cell = this.cellPool[row * this._cols + col]!; + + // Grapheme length. 0 = empty cell. + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.GRAPHEMES_LEN, + u32Ptr + ); + const memView = new DataView(this.memory.buffer); + const graphemeLen = memView.getUint32(u32Ptr, true); + cell.grapheme_len = graphemeLen; + + if (graphemeLen > 0) { + // GRAPHEMES_BUF writes graphemeLen u32 codepoints. We only need + // the base codepoint here; multi-codepoint clusters go through + // getGrapheme() separately. + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.GRAPHEMES_BUF, + u32Ptr + ); + cell.codepoint = new DataView(this.memory.buffer).getUint32(u32Ptr, true); + } else { + cell.codepoint = 0; + } + + // Resolved fg/bg. Returns INVALID_VALUE (non-zero) when the cell + // has no explicit color; leave the pool default (0,0,0) which the + // renderer interprets as "use terminal default." + cell.fg_r = cell.fg_g = cell.fg_b = 0; + cell.bg_r = cell.bg_g = cell.bg_b = 0; + if ( + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.FG_COLOR, + rgbPtr + ) === 0 + ) { + const u8 = new Uint8Array(this.memory.buffer, rgbPtr, 3); + cell.fg_r = u8[0]!; + cell.fg_g = u8[1]!; + cell.fg_b = u8[2]!; + } + if ( + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.BG_COLOR, + rgbPtr + ) === 0 + ) { + const u8 = new Uint8Array(this.memory.buffer, rgbPtr, 3); + cell.bg_r = u8[0]!; + cell.bg_g = u8[1]!; + cell.bg_b = u8[2]!; + } + + // TODO: derive flags from STYLE, width from RAW + cell_get(WIDE), + // hyperlink_id from RAW + cell_get(HAS_HYPERLINK). + cell.flags = 0; + cell.width = 1; + cell.hyperlink_id = 0; + + col++; + } + row++; + } + } finally { + this.exports.ghostty_wasm_free_u8_array(u32Ptr, 4); + this.exports.ghostty_wasm_free_u8_array(rgbPtr, 3); + this.exports.ghostty_wasm_free_u8(dirtyPtr); + } + + this.rowDirtyCache = dirtyCache; + return this.cellPool; + } + + /** + * Helper for the in/out pointer pattern used by ROW_ITERATOR / ROW_DATA_CELLS: + * write a handle into a 4-byte slot, hand the slot to a populator, then + * free the slot. The handle value itself is unchanged; the populator uses + * it to find and rebind the iterator's internal data. + */ + private populateHandle( + populator: (slotPtr: number) => number, + handle: number + ): void { + const slot = this.exports.ghostty_wasm_alloc_u8_array(4); + new DataView(this.memory.buffer).setUint32(slot, handle, true); + populator(slot); + this.exports.ghostty_wasm_free_u8_array(slot, 4); + } + + /** + * Reset every cell in the pool to "empty" so cells we don't visit during + * iteration (e.g. iterator stopped early, or grid resized down) don't + * carry stale values from a previous frame. + */ + private zeroCellPool(): void { + for (let i = 0; i < this.cellPool.length; i++) { + const cell = this.cellPool[i]!; + cell.codepoint = 0; + cell.fg_r = cell.fg_g = cell.fg_b = 0; + cell.bg_r = cell.bg_g = cell.bg_b = 0; + cell.flags = 0; + cell.width = 1; + cell.hyperlink_id = 0; + cell.grapheme_len = 0; + } } // ========================================================================== diff --git a/lib/types.ts b/lib/types.ts index b8f70b3..721143c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -443,10 +443,29 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { ): number; ghostty_render_state_set(state: number, option: number, valuePtr: number): number; ghostty_render_state_colors_get(state: number, outColorsPtr: number): number; - // Row iteration (not yet wired up on the TS side): - // ghostty_render_state_row_get / _row_set / _row_iterator_* - // _row_cells_get / _row_cells_get_multi / _row_cells_select - // (used to implement isRowDirty / getViewport / getGrapheme) + // Row iterator: pre-allocated once, repopulated from the render state via + // ghostty_render_state_get(state, ROW_ITERATOR, &iter). + ghostty_render_state_row_iterator_new(allocatorPtr: number, outIterPtrPtr: number): number; + ghostty_render_state_row_iterator_free(iter: number): void; + ghostty_render_state_row_iterator_next(iter: number): boolean; + ghostty_render_state_row_get(iter: number, key: number, outPtr: number): number; + ghostty_render_state_row_set(iter: number, option: number, valuePtr: number): number; + // Row cells iterator: per-row, populated from a row via + // ghostty_render_state_row_get(iter, ROW_DATA_CELLS, &cells). + ghostty_render_state_row_cells_new(allocatorPtr: number, outCellsPtrPtr: number): number; + ghostty_render_state_row_cells_free(cells: number): void; + ghostty_render_state_row_cells_next(cells: number): boolean; + ghostty_render_state_row_cells_select(cells: number, col: number): number; + ghostty_render_state_row_cells_get(cells: number, key: number, outPtr: number): number; + ghostty_render_state_row_cells_get_multi( + cells: number, + count: number, + keysPtr: number, + valuesPtr: number, + outWrittenPtr: number + ): number; + // Per-cell direct access (when you have a raw GhosttyCell). + ghostty_cell_get(cell: number, key: number, outPtr: number): number; // Generic terminal property API. Mirrors render_state_get/set: a single // entry point keyed by GhosttyTerminalData (see TerminalData enum). @@ -561,6 +580,35 @@ export enum TerminalScreen { ALTERNATE = 1, } +/** + * Keys for ghostty_render_state_row_get(). Mirrors GhosttyRenderStateRowData. + */ +export enum RenderStateRowData { + DIRTY = 1, + RAW = 2, + CELLS = 3, +} + +/** + * Options for ghostty_render_state_row_set(). Mirrors GhosttyRenderStateRowOption. + */ +export enum RenderStateRowOption { + DIRTY = 0, +} + +/** + * Keys for ghostty_render_state_row_cells_get(). Mirrors + * GhosttyRenderStateRowCellsData. + */ +export enum RowCellsData { + RAW = 1, + STYLE = 2, + GRAPHEMES_LEN = 3, + GRAPHEMES_BUF = 4, + BG_COLOR = 5, + FG_COLOR = 6, +} + /** * Pack a terminal mode number + ANSI flag into the u16 wire format used by * ghostty_terminal_mode_get/_set. Bits 0–14 hold the value (u15), bit 15 From 669a4d99f065b799ac3f41c97756a2f938185a7f Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Tue, 28 Apr 2026 16:09:39 -0400 Subject: [PATCH 06/29] Fill in style flags, isRowWrapped, and getGrapheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/ghostty.ts | 211 +++++++++++++++++++++++++++++++++++++++++++------ lib/types.ts | 14 ++++ 2 files changed, 200 insertions(+), 25 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index c2b733e..24aeef9 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -25,6 +25,7 @@ import { RenderStateOption, RenderStateRowData, RowCellsData, + RowData, TerminalData, type TerminalHandle, TerminalScreen, @@ -285,6 +286,12 @@ export class GhosttyTerminal { */ private rowDirtyCache: boolean[] | null = null; + /** + * Per-row soft-wrap state for the current render-state snapshot. Same + * lifecycle as rowDirtyCache; the two caches are filled in lockstep. + */ + private rowWrapCache: boolean[] | null = null; + constructor( exports: GhosttyWasmExports, memory: WebAssembly.Memory, @@ -545,8 +552,9 @@ export class GhosttyTerminal { update(): DirtyState { const r = this.exports.ghostty_render_state_update(this.renderHandle, this.handle); if (r !== 0) throw new Error(`ghostty_render_state_update failed: ${r}`); - // Per-row dirty cache is tied to the previous snapshot. + // Per-row caches are tied to the previous snapshot. this.rowDirtyCache = null; + this.rowWrapCache = null; // GhosttyRenderStateDirty is a 4-byte enum (FALSE=0, PARTIAL=1, FULL=2). return this.rsGetU32(RenderStateData.DIRTY) as DirtyState; } @@ -608,13 +616,39 @@ export class GhosttyTerminal { */ isRowDirty(y: number): boolean { if (y < 0 || y >= this._rows) return false; - if (this.rowDirtyCache === null) this.refreshRowDirtyCache(); + if (this.rowDirtyCache === null) this.refreshRowMetaCache(); return this.rowDirtyCache![y] ?? false; } - /** Walk the row iterator once and capture per-row dirty flags. */ - private refreshRowDirtyCache(): void { - const cache = new Array(this._rows).fill(false); + /** + * Check if a row is soft-wrapped (continues onto the next row). + * + * Same cache discipline as isRowDirty: lazy-populated on first call after + * update(), or as a side effect of getViewport. + */ + isRowWrapped(y: number): boolean { + if (y < 0 || y >= this._rows) return false; + if (this.rowWrapCache === null) this.refreshRowMetaCache(); + return this.rowWrapCache![y] ?? false; + } + + /** + * Walk the row iterator once and capture per-row dirty + wrap flags. + * + * Calls update() first since callers (isRowDirty / isRowWrapped) typically + * query right after a terminal write, before any explicit render-state + * refresh has happened. Same idempotency guarantee as getCursor/getColors: + * if no terminal change occurred since the last update, this is cheap. + * + * Reads ROW_DATA_DIRTY directly from the iterator, then ROW_DATA_RAW to + * obtain the GhosttyRow (u64) needed to call ghostty_row_get(WRAP_*). The + * row value is only valid for the current iterator position; we read it + * inline before advancing. + */ + private refreshRowMetaCache(): void { + this.update(); + const dirty = new Array(this._rows).fill(false); + const wrap = new Array(this._rows).fill(false); this.populateHandle( (out) => this.exports.ghostty_render_state_get( @@ -625,24 +659,41 @@ export class GhosttyTerminal { this.rowIter ); const dirtyPtr = this.exports.ghostty_wasm_alloc_u8(); + const rawPtr = this.exports.ghostty_wasm_alloc_u8_array(8); // GhosttyRow = u64 + const wrapPtr = this.exports.ghostty_wasm_alloc_u8(); try { let row = 0; while ( row < this._rows && this.exports.ghostty_render_state_row_iterator_next(this.rowIter) ) { + const view = new DataView(this.memory.buffer); + this.exports.ghostty_render_state_row_get( this.rowIter, RenderStateRowData.DIRTY, dirtyPtr ); - cache[row] = new DataView(this.memory.buffer).getUint8(dirtyPtr) !== 0; + dirty[row] = view.getUint8(dirtyPtr) !== 0; + + this.exports.ghostty_render_state_row_get( + this.rowIter, + RenderStateRowData.RAW, + rawPtr + ); + const rowU64 = new DataView(this.memory.buffer).getBigUint64(rawPtr, true); + this.exports.ghostty_row_get(rowU64, RowData.WRAP_CONTINUATION, wrapPtr); + wrap[row] = new DataView(this.memory.buffer).getUint8(wrapPtr) !== 0; + row++; } } finally { this.exports.ghostty_wasm_free_u8(dirtyPtr); + this.exports.ghostty_wasm_free_u8_array(rawPtr, 8); + this.exports.ghostty_wasm_free_u8(wrapPtr); } - this.rowDirtyCache = cache; + this.rowDirtyCache = dirty; + this.rowWrapCache = wrap; } /** @@ -701,19 +752,30 @@ export class GhosttyTerminal { // Reusable scratch buffers — declared once outside the loops since cell // counts are dominant. 4 bytes covers u32 (grapheme len, codepoint). // 3 bytes covers GhosttyColorRgb. 1 byte covers per-row dirty bool. + // Style is a 72-byte sized struct: write its `size` field once and the + // populator fills the rest each call (layout from ghostty_type_json: + // bold@56, italic@57, faint@58, blink@59, inverse@60, + // invisible@61, strikethrough@62, overline@63, underline@64 (i32)) + const STYLE_SIZE = 72; const u32Ptr = this.exports.ghostty_wasm_alloc_u8_array(4); const rgbPtr = this.exports.ghostty_wasm_alloc_u8_array(3); const dirtyPtr = this.exports.ghostty_wasm_alloc_u8(); - // Populate the row dirty cache as a side effect — saves a redundant - // iterator walk if the renderer also calls isRowDirty() on this snapshot. + const rawPtr = this.exports.ghostty_wasm_alloc_u8_array(8); + const wrapPtr = this.exports.ghostty_wasm_alloc_u8(); + const stylePtr = this.exports.ghostty_wasm_alloc_u8_array(STYLE_SIZE); + new DataView(this.memory.buffer).setUint32(stylePtr, STYLE_SIZE, true); + // Populate the row meta caches as a side effect — saves a redundant + // iterator walk if the renderer also calls isRowDirty() / isRowWrapped() + // on this snapshot. const dirtyCache = new Array(this._rows).fill(false); + const wrapCache = new Array(this._rows).fill(false); try { let row = 0; while ( row < this._rows && this.exports.ghostty_render_state_row_iterator_next(this.rowIter) ) { - // Capture per-row dirty for the cache. + // Capture per-row dirty + wrap for the caches. this.exports.ghostty_render_state_row_get( this.rowIter, RenderStateRowData.DIRTY, @@ -722,6 +784,15 @@ export class GhosttyTerminal { dirtyCache[row] = new DataView(this.memory.buffer).getUint8(dirtyPtr) !== 0; + this.exports.ghostty_render_state_row_get( + this.rowIter, + RenderStateRowData.RAW, + rawPtr + ); + const rowU64 = new DataView(this.memory.buffer).getBigUint64(rawPtr, true); + this.exports.ghostty_row_get(rowU64, RowData.WRAP_CONTINUATION, wrapPtr); + wrapCache[row] = new DataView(this.memory.buffer).getUint8(wrapPtr) !== 0; + // Bind rowCells to this row. this.populateHandle( (out) => @@ -740,7 +811,13 @@ export class GhosttyTerminal { ) { const cell = this.cellPool[row * this._cols + col]!; - // Grapheme length. 0 = empty cell. + // Grapheme length. Upstream includes the base codepoint: + // empty cell -> 0 + // simple ASCII 'a' -> 1 (just 'a') + // ZWJ family emoji -> N (base + N-1 combining) + // Coder's cell.grapheme_len counts only the "extras" beyond the + // base, so we subtract one (clamped at 0). The full count is + // available to callers that want it through getGrapheme(). this.exports.ghostty_render_state_row_cells_get( this.rowCells, RowCellsData.GRAPHEMES_LEN, @@ -748,7 +825,7 @@ export class GhosttyTerminal { ); const memView = new DataView(this.memory.buffer); const graphemeLen = memView.getUint32(u32Ptr, true); - cell.grapheme_len = graphemeLen; + cell.grapheme_len = graphemeLen > 0 ? graphemeLen - 1 : 0; if (graphemeLen > 0) { // GRAPHEMES_BUF writes graphemeLen u32 codepoints. We only need @@ -794,9 +871,35 @@ export class GhosttyTerminal { cell.bg_b = u8[2]!; } - // TODO: derive flags from STYLE, width from RAW + cell_get(WIDE), - // hyperlink_id from RAW + cell_get(HAS_HYPERLINK). - cell.flags = 0; + // Read the per-cell style and pack the booleans into the flags + // bitmask coder's renderer / Buffer API consumes. The function + // always returns a valid style (default for unstyled cells). + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.STYLE, + stylePtr + ); + { + const u8 = new Uint8Array(this.memory.buffer, stylePtr, STYLE_SIZE); + let f = 0; + if (u8[56]) f |= CellFlags.BOLD; + if (u8[57]) f |= CellFlags.ITALIC; + if (u8[58]) f |= CellFlags.FAINT; + if (u8[59]) f |= CellFlags.BLINK; + if (u8[60]) f |= CellFlags.INVERSE; + if (u8[61]) f |= CellFlags.INVISIBLE; + if (u8[62]) f |= CellFlags.STRIKETHROUGH; + // u8[63] is `overline` — coder's CellFlags doesn't model it. + // Underline at offset 64 is an i32 enum (NONE/SINGLE/DOUBLE/ + // CURLY/DOTTED/DASHED); collapse any non-zero to a single flag. + if (new DataView(this.memory.buffer).getInt32(stylePtr + 64, true) !== 0) { + f |= CellFlags.UNDERLINE; + } + cell.flags = f; + } + + // TODO: width from RAW + cell_get(WIDE), hyperlink_id from + // RAW + cell_get(HAS_HYPERLINK). cell.width = 1; cell.hyperlink_id = 0; @@ -808,9 +911,13 @@ export class GhosttyTerminal { this.exports.ghostty_wasm_free_u8_array(u32Ptr, 4); this.exports.ghostty_wasm_free_u8_array(rgbPtr, 3); this.exports.ghostty_wasm_free_u8(dirtyPtr); + this.exports.ghostty_wasm_free_u8_array(rawPtr, 8); + this.exports.ghostty_wasm_free_u8(wrapPtr); + this.exports.ghostty_wasm_free_u8_array(stylePtr, STYLE_SIZE); } this.rowDirtyCache = dirtyCache; + this.rowWrapCache = wrapCache; return this.cellPool; } @@ -936,12 +1043,6 @@ export class GhosttyTerminal { throw new Error('getScrollbackLine not yet implemented for the new C ABI'); } - /** Check if a row in the active screen is wrapped (soft-wrapped to next line) */ - isRowWrapped(_row: number): boolean { - // TODO: rewire onto grid_ref / row API. - throw new Error('isRowWrapped not yet implemented for the new C ABI'); - } - /** * Get the hyperlink URI for a cell at the given position. * @returns The URI string, or null if no hyperlink at that position @@ -1053,10 +1154,70 @@ export class GhosttyTerminal { * (Hindi, emoji with ZWJ, etc.) it returns multiple codepoints. * @returns Array of codepoints, or null on error */ - getGrapheme(_row: number, _col: number): number[] | null { - // TODO: rewire onto the row cells API: - // _row_cells_select(cells, RAW) -> _row_cells_get(cells, GRAPHEMES, ...) - throw new Error('getGrapheme not yet implemented for the new render-state API'); + getGrapheme(row: number, col: number): number[] | null { + if (row < 0 || row >= this._rows) return null; + if (col < 0 || col >= this._cols) return null; + + this.update(); + + // Bind iterator to current state and walk forward to the target row. + this.populateHandle( + (out) => + this.exports.ghostty_render_state_get( + this.renderHandle, + RenderStateData.ROW_ITERATOR, + out + ), + this.rowIter + ); + for (let r = 0; r <= row; r++) { + if (!this.exports.ghostty_render_state_row_iterator_next(this.rowIter)) { + return null; + } + } + + // Bind cells from this row, then position at the target column. + this.populateHandle( + (out) => + this.exports.ghostty_render_state_row_get( + this.rowIter, + RenderStateRowData.CELLS, + out + ), + this.rowCells + ); + if (this.exports.ghostty_render_state_row_cells_select(this.rowCells, col) !== 0) { + return null; + } + + const lenPtr = this.exports.ghostty_wasm_alloc_u8_array(4); + let len = 0; + try { + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.GRAPHEMES_LEN, + lenPtr + ); + len = new DataView(this.memory.buffer).getUint32(lenPtr, true); + } finally { + this.exports.ghostty_wasm_free_u8_array(lenPtr, 4); + } + if (len === 0) return []; + + const bufBytes = len * 4; + const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufBytes); + try { + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.GRAPHEMES_BUF, + bufPtr + ); + // Copy out before freeing — the array reference shares the WASM memory + // buffer and a subsequent allocation could detach it. + return Array.from(new Uint32Array(this.memory.buffer, bufPtr, len)); + } finally { + this.exports.ghostty_wasm_free_u8_array(bufPtr, bufBytes); + } } /** diff --git a/lib/types.ts b/lib/types.ts index 721143c..688d115 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -466,6 +466,8 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { ): number; // Per-cell direct access (when you have a raw GhosttyCell). ghostty_cell_get(cell: number, key: number, outPtr: number): number; + // Per-row direct access. GhosttyRow is a u64 — passed as bigint in JS. + ghostty_row_get(row: bigint, key: number, outPtr: number): number; // Generic terminal property API. Mirrors render_state_get/set: a single // entry point keyed by GhosttyTerminalData (see TerminalData enum). @@ -609,6 +611,18 @@ export enum RowCellsData { FG_COLOR = 6, } +/** + * Keys for ghostty_row_get(). Mirrors GhosttyRowData. Used with the raw + * GhosttyRow value obtained via _render_state_row_get(iter, RAW, &row). + */ +export enum RowData { + WRAP = 1, + WRAP_CONTINUATION = 2, + GRAPHEME = 3, + STYLED = 4, + HYPERLINK = 5, +} + /** * Pack a terminal mode number + ANSI flag into the u16 wire format used by * ghostty_terminal_mode_get/_set. Bits 0–14 hold the value (u15), bit 15 From 219c5a4462f26f5e791ee051882ff9dde90e3dcb Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Tue, 28 Apr 2026 16:30:05 -0400 Subject: [PATCH 07/29] feat: Get to 331/331: scrollback, markClean, config apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/ghostty.ts | 362 +++++++++++++++++++++++++++++++++++++++---------- lib/types.ts | 81 ++++++++++- 2 files changed, 369 insertions(+), 74 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 24aeef9..a9d54d5 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -24,10 +24,14 @@ import { RenderStateData, RenderStateOption, RenderStateRowData, + RenderStateRowOption, + PointTag, RowCellsData, RowData, + CellData, TerminalData, type TerminalHandle, + TerminalOption, TerminalScreen, } from './types'; @@ -269,12 +273,6 @@ export class GhosttyTerminal { private _cols: number; private _rows: number; - /** Size of GhosttyCell in WASM (16 bytes) */ - private static readonly CELL_SIZE = 16; - - /** Reusable buffer for viewport operations */ - private viewportBufferPtr: number = 0; - private viewportBufferSize: number = 0; /** Cell pool for zero-allocation rendering */ private cellPool: GhosttyCell[] = []; @@ -333,9 +331,17 @@ export class GhosttyTerminal { if (!this.handle) throw new Error('Failed to create terminal'); - // TODO: apply config.fgColor / bgColor / cursorColor / palette via - // ghostty_terminal_set(GHOSTTY_TERMINAL_OPT_COLOR_*) once the option - // bindings are wired up. + // Apply theme colors + palette overrides. The constructor's options + // struct only carries cols/rows/scrollback, so colors land here via + // ghostty_terminal_set(COLOR_*). + if (config) this.applyConfig(config); + + // Mode 2027 (grapheme clustering) is what lets the terminal treat + // multi-codepoint clusters (flag emoji, ZWJ sequences, skin tones) as + // a single cell. Coder's old C-side patch enabled it inside the + // terminal_new() shim; the new public C ABI doesn't, so we enable it + // here from JS to preserve coder's defaults. + this.exports.ghostty_terminal_mode_set(this.handle, packMode(2027, false), true); // Create the render state that owns the per-frame snapshot read by // getCursor/getColors/getViewport. Render state is updated explicitly via @@ -391,6 +397,65 @@ export class GhosttyTerminal { } } + /** + * Apply user-supplied colors + palette overrides to the freshly-created + * terminal via ghostty_terminal_set(COLOR_*). + * + * For the palette: the new C ABI takes a full 256-entry array, but coder's + * config carries only the legacy 16 ANSI entries (each as a 0xRRGGBB int, + * 0 meaning "use default"). To preserve indices ≥16 we read the existing + * default palette first, overlay the non-zero entries from config, and + * write the merged 768-byte buffer back. + */ + private applyConfig(config: GhosttyTerminalConfig): void { + if (config.fgColor) this.setColorOption(TerminalOption.COLOR_FOREGROUND, config.fgColor); + if (config.bgColor) this.setColorOption(TerminalOption.COLOR_BACKGROUND, config.bgColor); + if (config.cursorColor) { + this.setColorOption(TerminalOption.COLOR_CURSOR, config.cursorColor); + } + + if (config.palette && config.palette.some((v) => v !== 0)) { + const PALETTE_SIZE = 256 * 3; + const ptr = this.exports.ghostty_wasm_alloc_u8_array(PALETTE_SIZE); + try { + // Seed from the upstream default palette so untouched indices + // keep their canonical ANSI colors. + const seedRes = this.exports.ghostty_terminal_get( + this.handle, + TerminalData.COLOR_PALETTE_DEFAULT, + ptr + ); + if (seedRes !== 0) { + // Couldn't read defaults — fall back to all-black so we don't + // smear stale memory into the palette. + new Uint8Array(this.memory.buffer, ptr, PALETTE_SIZE).fill(0); + } + const buf = new Uint8Array(this.memory.buffer, ptr, PALETTE_SIZE); + const limit = Math.min(config.palette.length, 16); + for (let i = 0; i < limit; i++) { + const c = config.palette[i]!; + if (c === 0) continue; // 0 = "leave default in place" + buf[i * 3 + 0] = (c >> 16) & 0xff; + buf[i * 3 + 1] = (c >> 8) & 0xff; + buf[i * 3 + 2] = c & 0xff; + } + this.exports.ghostty_terminal_set(this.handle, TerminalOption.COLOR_PALETTE, ptr); + } finally { + this.exports.ghostty_wasm_free_u8_array(ptr, PALETTE_SIZE); + } + } + } + + private setColorOption(opt: TerminalOption, rgb: number): void { + const ptr = this.exports.ghostty_wasm_alloc_u8_array(3); + const buf = new Uint8Array(this.memory.buffer, ptr, 3); + buf[0] = (rgb >> 16) & 0xff; + buf[1] = (rgb >> 8) & 0xff; + buf[2] = rgb & 0xff; + this.exports.ghostty_terminal_set(this.handle, opt, ptr); + this.exports.ghostty_wasm_free_u8_array(ptr, 3); + } + /** * Release any resources that have been allocated by the constructor up to * this point. Called when a subsequent step fails so we don't leak handles @@ -508,15 +573,10 @@ export class GhosttyTerminal { // TODO: thread real cell pixel dims (currently 0 = unknown/disabled, // affects size reports and image protocols only). this.exports.ghostty_terminal_resize(this.handle, cols, rows, 0, 0); - this.invalidateBuffers(); this.initCellPool(); } free(): void { - if (this.viewportBufferPtr) { - this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); - this.viewportBufferPtr = 0; - } if (this.rowCells) { this.exports.ghostty_render_state_row_cells_free(this.rowCells); this.rowCells = 0; @@ -697,13 +757,43 @@ export class GhosttyTerminal { } /** - * Mark render state as clean (call after rendering). + * Mark render state as clean — clears both global and per-row dirty. + * + * Per the upstream contract, "setting one dirty state doesn't unset the + * other." Global dirty is cleared via _set(OPTION_DIRTY, FALSE); per-row + * dirty is cleared by walking the row iterator and calling _row_set on + * each. Without the per-row pass, the next update() would still report + * the old per-row flags as dirty even though the terminal hasn't changed. */ markClean(): void { const p = this.exports.ghostty_wasm_alloc_u8_array(4); new DataView(this.memory.buffer).setUint32(p, DirtyState.NONE, true); this.exports.ghostty_render_state_set(this.renderHandle, RenderStateOption.DIRTY, p); this.exports.ghostty_wasm_free_u8_array(p, 4); + + // Re-bind the iterator to the current state and clear each row's dirty. + this.populateHandle( + (out) => + this.exports.ghostty_render_state_get( + this.renderHandle, + RenderStateData.ROW_ITERATOR, + out + ), + this.rowIter + ); + const falsePtr = this.exports.ghostty_wasm_alloc_u8(); + new DataView(this.memory.buffer).setUint8(falsePtr, 0); + while (this.exports.ghostty_render_state_row_iterator_next(this.rowIter)) { + this.exports.ghostty_render_state_row_set( + this.rowIter, + RenderStateRowOption.DIRTY, + falsePtr + ); + } + this.exports.ghostty_wasm_free_u8(falsePtr); + + // Caches captured the now-stale "dirty" state. + this.rowDirtyCache = null; } /** @@ -1033,32 +1123,161 @@ export class GhosttyTerminal { /** * Get a line from the scrollback buffer. - * Ensures render state is fresh by calling update(). - * @param offset 0 = oldest line, (length-1) = most recent scrollback line + * @param offset 0 = oldest scrollback line, (scrollbackLength-1) = most + * recent scrollback line. + * + * Uses ghostty_terminal_grid_ref with POINT_TAG_HISTORY to address rows + * outside the active viewport. The render-state row iterator only walks + * the viewport, so scrollback access has to go through grid_ref. + * + * Cell content is currently codepoint-only; fg/bg colors, style flags, + * and hyperlinks are deferred (defaults: 0 colors, flags=0, width=1). + * The text-extraction tests that drove this commit only check codepoints. */ - getScrollbackLine(_offset: number): GhosttyCell[] | null { - // TODO: rewire onto the row iterator API: - // _grid_ref(terminal, ...) for scrollback rows + _row_cells_get(...) - // Old per-row buffer fill API (ghostty_terminal_get_scrollback_line) is gone. - throw new Error('getScrollbackLine not yet implemented for the new C ABI'); + getScrollbackLine(offset: number): GhosttyCell[] | null { + return this.readGridLine(PointTag.HISTORY, offset); } /** - * Get the hyperlink URI for a cell at the given position. - * @returns The URI string, or null if no hyperlink at that position + * Get the hyperlink URI for a cell at the given position in the active + * viewport. Returns null when no hyperlink is attached. */ - getHyperlinkUri(_row: number, _col: number): string | null { - // TODO: rewire onto grid_ref + cell hyperlink lookup. Old buffer-fill - // API (ghostty_terminal_get_hyperlink_uri) is gone. - throw new Error('getHyperlinkUri not yet implemented for the new C ABI'); + getHyperlinkUri(row: number, col: number): string | null { + if (row < 0 || row >= this._rows) return null; + if (col < 0 || col >= this._cols) return null; + return this.readHyperlinkUri(PointTag.ACTIVE, row, col); } /** * Get the hyperlink URI for a cell in the scrollback buffer. */ - getScrollbackHyperlinkUri(_offset: number, _col: number): string | null { - // TODO: same path as getHyperlinkUri once grid_ref is wired up. - throw new Error('getScrollbackHyperlinkUri not yet implemented for the new C ABI'); + getScrollbackHyperlinkUri(offset: number, col: number): string | null { + if (col < 0 || col >= this._cols) return null; + return this.readHyperlinkUri(PointTag.HISTORY, offset, col); + } + + // ========================================================================== + // grid_ref helpers + // + // GhosttyPoint : 24 bytes (tag@0:u32, value@8:union 16 bytes). + // The union's first member is GhosttyPointCoordinate + // (x@0:u16, y@4:u32). + // GhosttyGridRef: 12 bytes — sized struct (size@0:u32, node@4:opaque, + // x@8:u16, y@10:u16). x/y are public so we can step + // along a row by mutating ref.x in place rather than + // re-resolving the point per cell. + // + // A grid ref is invalidated by ANY terminal mutation. The whole helper + // body must run between vt_writes — read everything we need, copy out, + // free. + // ========================================================================== + + private readGridLine(tag: PointTag, y: number): GhosttyCell[] | null { + const pointPtr = this.allocPoint(tag, 0, y); + const refPtr = this.exports.ghostty_wasm_alloc_u8_array(12); + new DataView(this.memory.buffer).setUint32(refPtr, 12, true); // size field + try { + if (this.exports.ghostty_terminal_grid_ref(this.handle, pointPtr, refPtr) !== 0) { + return null; + } + + const cells: GhosttyCell[] = new Array(this._cols); + const cellPtr = this.exports.ghostty_wasm_alloc_u8_array(8); + const u32Ptr = this.exports.ghostty_wasm_alloc_u8_array(4); + try { + for (let col = 0; col < this._cols; col++) { + // Step along the row by mutating ref.x in place. + new DataView(this.memory.buffer).setUint16(refPtr + 8, col, true); + if (this.exports.ghostty_grid_ref_cell(refPtr, cellPtr) !== 0) { + cells[col] = this.makeEmptyCell(); + continue; + } + const cellU64 = new DataView(this.memory.buffer).getBigUint64(cellPtr, true); + this.exports.ghostty_cell_get(cellU64, CellData.CODEPOINT, u32Ptr); + const cp = new DataView(this.memory.buffer).getUint32(u32Ptr, true); + cells[col] = { ...this.makeEmptyCell(), codepoint: cp }; + } + } finally { + this.exports.ghostty_wasm_free_u8_array(cellPtr, 8); + this.exports.ghostty_wasm_free_u8_array(u32Ptr, 4); + } + return cells; + } finally { + this.exports.ghostty_wasm_free_u8_array(pointPtr, 24); + this.exports.ghostty_wasm_free_u8_array(refPtr, 12); + } + } + + private readHyperlinkUri(tag: PointTag, y: number, col: number): string | null { + const pointPtr = this.allocPoint(tag, col, y); + const refPtr = this.exports.ghostty_wasm_alloc_u8_array(12); + new DataView(this.memory.buffer).setUint32(refPtr, 12, true); + try { + if (this.exports.ghostty_terminal_grid_ref(this.handle, pointPtr, refPtr) !== 0) { + return null; + } + // Two-pass read: first call with len=0 to get required size, then + // allocate exactly. Most cells have no hyperlink — we get out_len=0 + // on the first call and skip the second alloc entirely. + const outLenPtr = this.exports.ghostty_wasm_alloc_usize(); + try { + // First pass: pass NULL buf (0) and len=0; out_len gets populated. + // ghostty_grid_ref_hyperlink_uri returns OUT_OF_SPACE when there + // is data; SUCCESS with out_len=0 when there is none. + this.exports.ghostty_grid_ref_hyperlink_uri(refPtr, 0, 0, outLenPtr); + const needed = new DataView(this.memory.buffer).getUint32(outLenPtr, true); + if (needed === 0) return null; + + const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(needed); + try { + const r = this.exports.ghostty_grid_ref_hyperlink_uri( + refPtr, + bufPtr, + needed, + outLenPtr + ); + if (r !== 0) return null; + const written = new DataView(this.memory.buffer).getUint32(outLenPtr, true); + const bytes = new Uint8Array(this.memory.buffer, bufPtr, written); + return new TextDecoder().decode(bytes.slice()); + } finally { + this.exports.ghostty_wasm_free_u8_array(bufPtr, needed); + } + } finally { + this.exports.ghostty_wasm_free_usize(outLenPtr); + } + } finally { + this.exports.ghostty_wasm_free_u8_array(pointPtr, 24); + this.exports.ghostty_wasm_free_u8_array(refPtr, 12); + } + } + + private allocPoint(tag: PointTag, x: number, y: number): number { + // GhosttyPoint = { tag: u32 @ 0, padding: 4, value.coordinate: { x: u16 @ 0, y: u32 @ 4 } @ 8 } + const ptr = this.exports.ghostty_wasm_alloc_u8_array(24); + const view = new DataView(this.memory.buffer); + // Zero the padding bytes too, since we don't want stale memory in the union. + new Uint8Array(this.memory.buffer, ptr, 24).fill(0); + view.setUint32(ptr + 0, tag, true); + view.setUint16(ptr + 8, x, true); + view.setUint32(ptr + 12, y, true); + return ptr; + } + + private makeEmptyCell(): GhosttyCell { + return { + codepoint: 0, + fg_r: 0, + fg_g: 0, + fg_b: 0, + bg_r: 0, + bg_g: 0, + bg_b: 0, + flags: 0, + width: 1, + hyperlink_id: 0, + grapheme_len: 0, + }; } /** @@ -1122,32 +1341,6 @@ export class GhosttyTerminal { } } - private parseCellsIntoPool(ptr: number, count: number): void { - const buffer = this.memory.buffer; - const u8 = new Uint8Array(buffer, ptr, count * GhosttyTerminal.CELL_SIZE); - const view = new DataView(buffer, ptr, count * GhosttyTerminal.CELL_SIZE); - - for (let i = 0; i < count; i++) { - const offset = i * GhosttyTerminal.CELL_SIZE; - const cell = this.cellPool[i]; - cell.codepoint = view.getUint32(offset, true); - cell.fg_r = u8[offset + 4]; - cell.fg_g = u8[offset + 5]; - cell.fg_b = u8[offset + 6]; - cell.bg_r = u8[offset + 7]; - cell.bg_g = u8[offset + 8]; - cell.bg_b = u8[offset + 9]; - cell.flags = u8[offset + 10]; - cell.width = u8[offset + 11]; - cell.hyperlink_id = view.getUint16(offset + 12, true); - cell.grapheme_len = u8[offset + 14]; // grapheme_len is at byte 14 - } - } - - /** Small buffer for grapheme lookups (reused to avoid allocation) */ - private graphemeBuffer: Uint32Array | null = null; - private graphemeBufferPtr: number = 0; - /** * Get all codepoints for a grapheme cluster at the given position. * For most cells this returns a single codepoint, but for complex scripts @@ -1236,9 +1429,46 @@ export class GhosttyTerminal { * @param col Column index * @returns Array of codepoints, or null on error */ - getScrollbackGrapheme(_offset: number, _col: number): number[] | null { - // TODO: rewire onto grid_ref + row_cells_get(GRAPHEMES) for scrollback rows. - throw new Error('getScrollbackGrapheme not yet implemented for the new C ABI'); + getScrollbackGrapheme(offset: number, col: number): number[] | null { + if (col < 0 || col >= this._cols) return null; + + const pointPtr = this.allocPoint(PointTag.HISTORY, col, offset); + const refPtr = this.exports.ghostty_wasm_alloc_u8_array(12); + new DataView(this.memory.buffer).setUint32(refPtr, 12, true); + try { + if (this.exports.ghostty_terminal_grid_ref(this.handle, pointPtr, refPtr) !== 0) { + return null; + } + // Same two-pass pattern as readHyperlinkUri: query length first, then + // allocate the exact codepoint buffer. + const outLenPtr = this.exports.ghostty_wasm_alloc_usize(); + try { + this.exports.ghostty_grid_ref_graphemes(refPtr, 0, 0, outLenPtr); + const needed = new DataView(this.memory.buffer).getUint32(outLenPtr, true); + if (needed === 0) return []; + + const bytes = needed * 4; // codepoints are u32 + const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bytes); + try { + const r = this.exports.ghostty_grid_ref_graphemes( + refPtr, + bufPtr, + needed, + outLenPtr + ); + if (r !== 0) return null; + const written = new DataView(this.memory.buffer).getUint32(outLenPtr, true); + return Array.from(new Uint32Array(this.memory.buffer, bufPtr, written)); + } finally { + this.exports.ghostty_wasm_free_u8_array(bufPtr, bytes); + } + } finally { + this.exports.ghostty_wasm_free_usize(outLenPtr); + } + } finally { + this.exports.ghostty_wasm_free_u8_array(pointPtr, 24); + this.exports.ghostty_wasm_free_u8_array(refPtr, 12); + } } /** @@ -1250,16 +1480,4 @@ export class GhosttyTerminal { return String.fromCodePoint(...codepoints); } - private invalidateBuffers(): void { - if (this.viewportBufferPtr) { - this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); - this.viewportBufferPtr = 0; - this.viewportBufferSize = 0; - } - if (this.graphemeBufferPtr) { - this.exports.ghostty_wasm_free_u8_array(this.graphemeBufferPtr, 16 * 4); - this.graphemeBufferPtr = 0; - } - this.graphemeBuffer = null; - } } diff --git a/lib/types.ts b/lib/types.ts index 688d115..83933ac 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -464,10 +464,35 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { valuesPtr: number, outWrittenPtr: number ): number; - // Per-cell direct access (when you have a raw GhosttyCell). - ghostty_cell_get(cell: number, key: number, outPtr: number): number; + // Per-cell direct access. GhosttyCell is a u64 — passed as bigint in JS. + ghostty_cell_get(cell: bigint, key: number, outPtr: number): number; // Per-row direct access. GhosttyRow is a u64 — passed as bigint in JS. ghostty_row_get(row: bigint, key: number, outPtr: number): number; + // Grid references: read cells / rows / graphemes / hyperlinks at a + // specific GhosttyPoint. Useful for off-viewport (scrollback / history) + // access where the render-state row iterator doesn't reach. + // Note: refs are invalidated by ANY terminal mutation — read and copy out + // before the next vt_write. + ghostty_terminal_grid_ref( + terminal: TerminalHandle, + pointPtr: number, + outRefPtr: number + ): number; + ghostty_grid_ref_cell(refPtr: number, outCellPtr: number): number; + ghostty_grid_ref_row(refPtr: number, outRowPtr: number): number; + ghostty_grid_ref_graphemes( + refPtr: number, + bufPtr: number, + bufLen: number, + outLenPtr: number + ): number; + ghostty_grid_ref_hyperlink_uri( + refPtr: number, + bufPtr: number, + bufLen: number, + outLenPtr: number + ): number; + ghostty_grid_ref_style(refPtr: number, outStylePtr: number): number; // Generic terminal property API. Mirrors render_state_get/set: a single // entry point keyed by GhosttyTerminalData (see TerminalData enum). @@ -571,6 +596,29 @@ export enum TerminalData { COLOR_BACKGROUND = 19, COLOR_CURSOR = 20, COLOR_PALETTE = 21, + COLOR_FOREGROUND_DEFAULT = 22, + COLOR_BACKGROUND_DEFAULT = 23, + COLOR_CURSOR_DEFAULT = 24, + COLOR_PALETTE_DEFAULT = 25, +} + +/** + * Options for ghostty_terminal_set(). Mirrors GhosttyTerminalOption. + * Only the entries the TS layer touches are listed; the upstream enum has + * more (callbacks for BELL/TITLE_CHANGED/etc., kitty-image limits, ...). + */ +export enum TerminalOption { + USERDATA = 0, + WRITE_PTY = 1, + BELL = 2, + ENQUIRY = 3, + XTVERSION = 4, + TITLE_CHANGED = 5, + SIZE = 6, + COLOR_FOREGROUND = 11, + COLOR_BACKGROUND = 12, + COLOR_CURSOR = 13, + COLOR_PALETTE = 14, } /** @@ -623,6 +671,35 @@ export enum RowData { HYPERLINK = 5, } +/** + * Tag values for GhosttyPoint. Mirrors GhosttyPointTag. The tag selects + * which coordinate space y is interpreted in. + */ +export enum PointTag { + ACTIVE = 0, + VIEWPORT = 1, + SCREEN = 2, + HISTORY = 3, +} + +/** + * Keys for ghostty_cell_get(). Mirrors GhosttyCellData. Used with the + * raw GhosttyCell value obtained via grid_ref_cell or row_cells_get(RAW). + */ +export enum CellData { + CODEPOINT = 1, + CONTENT_TAG = 2, + WIDE = 3, + HAS_TEXT = 4, + HAS_STYLING = 5, + STYLE_ID = 6, + HAS_HYPERLINK = 7, + PROTECTED = 8, + SEMANTIC_CONTENT = 9, + COLOR_PALETTE = 10, + COLOR_RGB = 11, +} + /** * Pack a terminal mode number + ANSI flag into the u16 wire format used by * ghostty_terminal_mode_get/_set. Bits 0–14 hold the value (u15), bit 15 From c4c00448c2ae3f2d00b11946b49602a71ca0f33b Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Tue, 28 Apr 2026 19:26:55 -0400 Subject: [PATCH 08/29] Reinstate hasResponse / readResponse via WRITE_PTY trampoline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 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 --- lib/ghostty.ts | 162 ++++++++++++++++++++++++++++++----- lib/terminal.test.ts | 50 +++++++++++ lib/write_pty_trampoline.ts | 49 +++++++++++ lib/write_pty_trampoline.wat | 24 ++++++ 4 files changed, 263 insertions(+), 22 deletions(-) create mode 100644 lib/write_pty_trampoline.ts create mode 100644 lib/write_pty_trampoline.wat diff --git a/lib/ghostty.ts b/lib/ghostty.ts index a9d54d5..5714d74 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -6,6 +6,7 @@ * snapshot of all render data in a single update call. */ +import { makeWritePtyTrampoline, type WritePtyCallback } from './write_pty_trampoline'; import { CellFlags, CursorVisualStyle, @@ -290,6 +291,39 @@ export class GhosttyTerminal { */ private rowWrapCache: boolean[] | null = null; + /** + * Bytes the terminal would have written back to a real PTY in response + * to query sequences (DSR, XTVERSION, in-band size reports, ...). + * Captured by the WRITE_PTY callback installed in the constructor and + * drained by readResponse(). Each slot is one callback invocation, so + * a single response sequence may span multiple slots. + */ + private pendingResponses: Uint8Array[] = []; + + /** + * Per-table registry for the WRITE_PTY callback. Keyed on the WASM + * module's __indirect_function_table so that multiple Ghostty.load() + * instances each get their own trampoline slot and routing map — + * terminal handles are only unique within a single WASM instance, and + * indices into one module's table are meaningless in another. + * + * `index` is the slot we own in that table; `instancesByHandle` routes + * incoming WRITE_PTY callbacks back to the matching GhosttyTerminal. + */ + private static writePtyRegistries = new WeakMap< + WebAssembly.Table, + { index: number; instancesByHandle: Map } + >(); + + /** + * Cached pointer to this terminal's registry. We only need it to + * deregister cleanly in free() / cleanupOnConstructorFailure(). + */ + private writePtyRegistry?: { + index: number; + instancesByHandle: Map; + }; + constructor( exports: GhosttyWasmExports, memory: WebAssembly.Memory, @@ -331,17 +365,32 @@ export class GhosttyTerminal { if (!this.handle) throw new Error('Failed to create terminal'); - // Apply theme colors + palette overrides. The constructor's options - // struct only carries cols/rows/scrollback, so colors land here via - // ghostty_terminal_set(COLOR_*). - if (config) this.applyConfig(config); - - // Mode 2027 (grapheme clustering) is what lets the terminal treat - // multi-codepoint clusters (flag emoji, ZWJ sequences, skin tones) as - // a single cell. Coder's old C-side patch enabled it inside the - // terminal_new() shim; the new public C ABI doesn't, so we enable it - // here from JS to preserve coder's defaults. - this.exports.ghostty_terminal_mode_set(this.handle, packMode(2027, false), true); + // Everything below could fail; if it does we need to undo the + // post-terminal_new init (registry entry, callback wiring) in + // addition to the WASM resource frees that cleanupOnConstructorFailure + // already handles. + try { + // Install the trampoline callback so the terminal can deliver + // response bytes (DSR, XTVERSION, in-band size reports, ...) back + // to JS. Resolves / creates the per-table registry on first use, + // registers `this` in it, then sets WRITE_PTY on the terminal. + this.installWritePtyCallback(); + + // Apply theme colors + palette overrides. The constructor's options + // struct only carries cols/rows/scrollback, so colors land here via + // ghostty_terminal_set(COLOR_*). + if (config) this.applyConfig(config); + + // Mode 2027 (grapheme clustering) is what lets the terminal treat + // multi-codepoint clusters (flag emoji, ZWJ sequences, skin tones) + // as a single cell. Coder's old C-side patch enabled it inside the + // terminal_new() shim; the new public C ABI doesn't, so we enable + // it here from JS to preserve coder's defaults. + this.exports.ghostty_terminal_mode_set(this.handle, packMode(2027, false), true); + } catch (e) { + this.cleanupOnConstructorFailure(); + throw e; + } // Create the render state that owns the per-frame snapshot read by // getCursor/getColors/getViewport. Render state is updated explicitly via @@ -462,6 +511,10 @@ export class GhosttyTerminal { * before the throw propagates. */ private cleanupOnConstructorFailure(): void { + if (this.writePtyRegistry) { + this.writePtyRegistry.instancesByHandle.delete(this.handle); + this.writePtyRegistry = undefined; + } if (this.rowCells) { this.exports.ghostty_render_state_row_cells_free(this.rowCells); this.rowCells = 0; @@ -577,6 +630,9 @@ export class GhosttyTerminal { } free(): void { + if (this.writePtyRegistry) { + this.writePtyRegistry.instancesByHandle.delete(this.handle); + } if (this.rowCells) { this.exports.ghostty_render_state_row_cells_free(this.rowCells); this.rowCells = 0; @@ -1281,25 +1337,87 @@ export class GhosttyTerminal { } /** - * Check if there are pending responses from the terminal. + * Whether any terminal response bytes are queued for readResponse(). * - * NOTE: the upstream C ABI replaced the polling has_response/read_response - * pair with a callback model: install one via - * ghostty_terminal_set(GHOSTTY_TERMINAL_OPT_WRITE_PTY, fn) and the terminal - * invokes it synchronously during vt_write() with response bytes. Until the - * callback infrastructure is wired up on the JS side, we report "no - * responses" so callers (e.g. demo/PTY echo) degrade gracefully. + * Responses are delivered synchronously during vt_write() by the + * WRITE_PTY callback (e.g. DSR replies, XTVERSION, in-band size reports). + * They sit in pendingResponses until drained. */ hasResponse(): boolean { - return false; + return this.pendingResponses.length > 0; } /** - * Read pending responses from the terminal. See hasResponse() for the - * status of the callback-based replacement. + * Drain queued response bytes, decode as UTF-8, return as a single + * string. Multiple callback invocations are concatenated. Returns null + * when nothing's pending so the demo's echo loop can short-circuit. */ readResponse(): string | null { - return null; + if (this.pendingResponses.length === 0) return null; + let total = 0; + for (const chunk of this.pendingResponses) total += chunk.length; + const merged = new Uint8Array(total); + let offset = 0; + for (const chunk of this.pendingResponses) { + merged.set(chunk, offset); + offset += chunk.length; + } + this.pendingResponses.length = 0; + return new TextDecoder().decode(merged); + } + + /** + * Install the WRITE_PTY callback through the trampoline. + * + * The trampoline is shared across all terminals that come from the + * same WASM instance, but NOT across instances — terminal handles are + * only unique within their parent module, and table indices in module + * A are meaningless in module B's table. So we keep a per-table + * registry (WeakMap keyed on the indirect function table) that owns + * the slot index plus the handle→instance routing map for that table. + * + * On first use for a given table we instantiate the trampoline, + * `table.grow(1)`, and `table.set(idx, fwd)`. Subsequent terminals + * from the same module reuse the registry and just register their + * handle in instancesByHandle. + */ + private installWritePtyCallback(): void { + const table = (this.exports as unknown as { __indirect_function_table: WebAssembly.Table }) + .__indirect_function_table; + + let registry = GhosttyTerminal.writePtyRegistries.get(table); + if (!registry) { + const instancesByHandle = new Map(); + const dispatch: WritePtyCallback = (handle, _userdata, dataPtr, dataLen) => { + const term = instancesByHandle.get(handle); + if (!term) return; + // Copy out — the underlying WASM memory may be mutated or + // detached by the next allocation, and the chunk lives until + // readResponse drains it. + term.pendingResponses.push( + new Uint8Array(term.memory.buffer, dataPtr, dataLen).slice(), + ); + }; + const fwd = makeWritePtyTrampoline(dispatch); + const idx = table.grow(1); + table.set(idx, fwd); + registry = { index: idx, instancesByHandle }; + GhosttyTerminal.writePtyRegistries.set(table, registry); + } + + // Register `this` so the dispatcher (closed over instancesByHandle) + // can route incoming bytes to the right pendingResponses queue. + registry.instancesByHandle.set(this.handle, this); + this.writePtyRegistry = registry; + + // The third arg to _set is the value — for callbacks ("pointer + // types"), the value IS the function pointer, i.e. the table index + // we just installed, passed directly. + this.exports.ghostty_terminal_set( + this.handle, + TerminalOption.WRITE_PTY, + registry.index, + ); } /** diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index d56011e..e604068 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -2990,3 +2990,53 @@ describe('Synchronous open()', () => { term.dispose(); }); }); + +// ===================================================================== +// WRITE_PTY callback routing +// +// The new C ABI delivers terminal-generated bytes (DSR replies, in-band +// size reports, XTVERSION, ...) via a callback installed with +// ghostty_terminal_set(WRITE_PTY, fn). The TS wrapper buffers them into +// a per-instance pendingResponses queue drained by readResponse(). +// +// These tests cover the routing — single instance, and two parallel +// Ghostty.load() instances. The latter is a regression caught in code +// review: handle IDs and table indices are only unique within their +// parent WASM module, so a process-wide registry corrupts the routing +// once you have two live instances. +// ===================================================================== +describe('Write PTY response routing', () => { + test('DSR 6 (cursor position) round-trips through readResponse', async () => { + const { Ghostty } = await import('./ghostty'); + const g = await Ghostty.load(); + const t = g.createTerminal(80, 24); + + expect(t.hasResponse()).toBe(false); + t.write('\x1b[6n'); // DSR 6 → cursor position report + expect(t.hasResponse()).toBe(true); + expect(t.readResponse()).toBe('\x1b[1;1R'); + expect(t.hasResponse()).toBe(false); + expect(t.readResponse()).toBe(null); + + t.free(); + }); + + test('two parallel Ghostty.load() instances each route to themselves', async () => { + const { Ghostty } = await import('./ghostty'); + // Each load() owns its own __indirect_function_table; the registry + // is keyed off that table so the trampoline slots and routing maps + // don't collide. + const a = await Ghostty.load(); + const b = await Ghostty.load(); + const ta = a.createTerminal(80, 24); + const tb = b.createTerminal(80, 24); + + ta.write('\x1b[6n'); + tb.write('\x1b[6n'); + expect(ta.readResponse()).toBe('\x1b[1;1R'); + expect(tb.readResponse()).toBe('\x1b[1;1R'); + + ta.free(); + tb.free(); + }); +}); diff --git a/lib/write_pty_trampoline.ts b/lib/write_pty_trampoline.ts new file mode 100644 index 0000000..490b261 --- /dev/null +++ b/lib/write_pty_trampoline.ts @@ -0,0 +1,49 @@ +/** + * Tiny WASM trampoline that lets us install a JS callback into the main + * libghostty-vt module's __indirect_function_table. + * + * Why this exists: ghostty_terminal_set(WRITE_PTY, fnPtr) takes a function + * pointer (a table index in WASM-land). To put a JS function at a given + * table index we'd normally use `new WebAssembly.Function(...)`, but that's + * part of the Type Reflection proposal which only Chrome ships — Bun and + * Node both report `typeof WebAssembly.Function === 'undefined'`. + * + * Workaround: instantiate a tiny separate WASM module that imports the + * JS callback as `env.cb` and exports a wrapper `fwd` with the same + * GhosttyTerminalWritePtyFn signature (i32, i32, i32, i32) -> nil. The + * wrapper's exported funcref is portable across modules with compatible + * funcref tables, so we can add it to the main module's + * __indirect_function_table and pass that index to terminal_set. + * + * The bytes below are the output of: + * wat2wasm lib/write_pty_trampoline.wat -o /tmp/trampoline.wasm + * + * Source is in write_pty_trampoline.wat — keep both in sync if you edit. + */ +const TRAMPOLINE_BYTES = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x01, 0x60, + 0x04, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x02, 0x0a, 0x01, 0x03, 0x65, 0x6e, + 0x76, 0x02, 0x63, 0x62, 0x00, 0x00, 0x03, 0x02, 0x01, 0x00, 0x07, 0x07, + 0x01, 0x03, 0x66, 0x77, 0x64, 0x00, 0x01, 0x0a, 0x0e, 0x01, 0x0c, 0x00, + 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x20, 0x03, 0x10, 0x00, 0x0b, +]); + +export type WritePtyCallback = ( + terminal: number, + userdata: number, + dataPtr: number, + dataLen: number, +) => void; + +/** + * Compile the trampoline once, then instantiate per-Ghostty with the JS + * callback as the `env.cb` import. Returns the exported `fwd` function + * which is a `funcref` callable from any WASM module via call_indirect. + */ +let compiled: WebAssembly.Module | null = null; + +export function makeWritePtyTrampoline(cb: WritePtyCallback): Function { + if (!compiled) compiled = new WebAssembly.Module(TRAMPOLINE_BYTES); + const inst = new WebAssembly.Instance(compiled, { env: { cb } }); + return inst.exports.fwd as Function; +} diff --git a/lib/write_pty_trampoline.wat b/lib/write_pty_trampoline.wat new file mode 100644 index 0000000..89b282a --- /dev/null +++ b/lib/write_pty_trampoline.wat @@ -0,0 +1,24 @@ +;; Tiny trampoline so we can install a JS callback into the main wasm +;; module's __indirect_function_table without WebAssembly.Function support +;; (Bun and Node lack it; only modern browsers ship the Type Reflection +;; proposal). +;; +;; This module imports a JS function `env.cb` and re-exports a wrapper +;; with the GhosttyTerminalWritePtyFn signature +;; (terminal: i32, userdata: i32, data: i32, len: i32). The wrapper's +;; exported funcref can be added to the main module's table, where +;; ghostty_terminal_set(WRITE_PTY, idx) wires it up. +;; +;; Rebuild after edits: +;; wat2wasm lib/write_pty_trampoline.wat -o lib/write_pty_trampoline.wasm +;; Then update the byte literal in lib/write_pty_trampoline.ts with the +;; new content. +(module + (type $sig (func (param i32 i32 i32 i32))) + (import "env" "cb" (func $cb (type $sig))) + (func $fwd (export "fwd") (type $sig) + local.get 0 + local.get 1 + local.get 2 + local.get 3 + call $cb)) From f22c7ac3e8dfc0b3e23ca6f93ea29daf57dfb0d7 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Tue, 28 Apr 2026 19:30:15 -0400 Subject: [PATCH 09/29] fix: Mirror isDefaultBg fallback for foreground rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/renderer.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/renderer.ts b/lib/renderer.ts index 3b51bfd..24db27b 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -614,19 +614,26 @@ export class CanvasRenderer { } else if (isSelected) { this.ctx.fillStyle = this.theme.selectionForeground; } else { - // Extract colors and handle inverse + // Extract colors and handle inverse. Mirrors the background path + // above: cells with no explicit color come back as (0,0,0) — treat + // that as a sentinel for "use theme default" rather than rendering + // literal black. Without this, default-fg text on a dark theme is + // invisible. let fg_r = cell.fg_r, fg_g = cell.fg_g, fg_b = cell.fg_b; if (cell.flags & CellFlags.INVERSE) { - // When inverted, foreground becomes background + // When inverted, foreground becomes background. fg_r = cell.bg_r; fg_g = cell.bg_g; fg_b = cell.bg_b; } - this.ctx.fillStyle = this.rgbToCSS(fg_r, fg_g, fg_b); + const fgIsDefault = fg_r === 0 && fg_g === 0 && fg_b === 0; + this.ctx.fillStyle = fgIsDefault + ? this.theme.foreground + : this.rgbToCSS(fg_r, fg_g, fg_b); } // Apply faint effect From aa807ef323f0e2775700c718e1d0fb9cd0d072bd Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Tue, 28 Apr 2026 20:04:18 -0400 Subject: [PATCH 10/29] Restore cell.width for CJK and wide emoji MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/ghostty.ts | 35 ++++++++++++++++++++++++++++++++--- lib/types.ts | 15 +++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 5714d74..2cea015 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -30,6 +30,7 @@ import { RowCellsData, RowData, CellData, + CellWide, TerminalData, type TerminalHandle, TerminalOption, @@ -910,6 +911,10 @@ export class GhosttyTerminal { const wrapPtr = this.exports.ghostty_wasm_alloc_u8(); const stylePtr = this.exports.ghostty_wasm_alloc_u8_array(STYLE_SIZE); new DataView(this.memory.buffer).setUint32(stylePtr, STYLE_SIZE, true); + // Per-cell RAW + WIDE scratch. Cells are 8 bytes (u64); the WIDE + // enum is a 4-byte int. + const cellRawPtr = this.exports.ghostty_wasm_alloc_u8_array(8); + const widePtr = this.exports.ghostty_wasm_alloc_u8_array(4); // Populate the row meta caches as a side effect — saves a redundant // iterator walk if the renderer also calls isRowDirty() / isRowWrapped() // on this snapshot. @@ -1044,9 +1049,31 @@ export class GhosttyTerminal { cell.flags = f; } - // TODO: width from RAW + cell_get(WIDE), hyperlink_id from - // RAW + cell_get(HAS_HYPERLINK). - cell.width = 1; + // Read the raw cell value once, then use it to query per-cell + // properties not exposed at the row_cells level. Width matters + // for CJK / wide emoji — without it the renderer skips the + // spacer cells correctly only if the wide cell itself has + // width=2, otherwise glyphs overlap or the spacer cell paints + // an empty box. + this.exports.ghostty_render_state_row_cells_get( + this.rowCells, + RowCellsData.RAW, + cellRawPtr + ); + const cellU64 = new DataView(this.memory.buffer).getBigUint64( + cellRawPtr, + true + ); + this.exports.ghostty_cell_get(cellU64, CellData.WIDE, widePtr); + const wide = new DataView(this.memory.buffer).getUint32(widePtr, true); + cell.width = + wide === CellWide.WIDE + ? 2 + : wide === CellWide.SPACER_TAIL || wide === CellWide.SPACER_HEAD + ? 0 + : 1; + + // TODO: hyperlink_id from RAW + cell_get(HAS_HYPERLINK). cell.hyperlink_id = 0; col++; @@ -1060,6 +1087,8 @@ export class GhosttyTerminal { this.exports.ghostty_wasm_free_u8_array(rawPtr, 8); this.exports.ghostty_wasm_free_u8(wrapPtr); this.exports.ghostty_wasm_free_u8_array(stylePtr, STYLE_SIZE); + this.exports.ghostty_wasm_free_u8_array(cellRawPtr, 8); + this.exports.ghostty_wasm_free_u8_array(widePtr, 4); } this.rowDirtyCache = dirtyCache; diff --git a/lib/types.ts b/lib/types.ts index 83933ac..a84f2e6 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -700,6 +700,21 @@ export enum CellData { COLOR_RGB = 11, } +/** + * Cell width classification. Mirrors GhosttyCellWide. + * NARROW: single-column cell (most ASCII, BMP) + * WIDE: leading half of a double-width cell (CJK, most emoji) + * SPACER_TAIL: trailing half of a wide cell — placeholder, no glyph + * SPACER_HEAD: leading placeholder when a wide cell would have crossed + * the right margin and got pushed to the next row + */ +export enum CellWide { + NARROW = 0, + WIDE = 1, + SPACER_TAIL = 2, + SPACER_HEAD = 3, +} + /** * Pack a terminal mode number + ANSI flag into the u16 wire format used by * ghostty_terminal_mode_get/_set. Bits 0–14 hold the value (u15), bit 15 From 9125434d4527939907fca1329333f4f200603d04 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Tue, 28 Apr 2026 20:37:45 -0400 Subject: [PATCH 11/29] Restore cell pixel dims and CSI 14/16/18 t responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/ghostty.ts | 173 ++++++++++++++++++++++++++--------- lib/terminal.test.ts | 21 +++++ lib/terminal.ts | 30 ++++++ lib/write_pty_trampoline.ts | 78 +++++++++++----- lib/write_pty_trampoline.wat | 43 +++++---- 5 files changed, 266 insertions(+), 79 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 2cea015..ade4da9 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -6,7 +6,11 @@ * snapshot of all render data in a single update call. */ -import { makeWritePtyTrampoline, type WritePtyCallback } from './write_pty_trampoline'; +import { + makeCallbackTrampolines, + type SizeCallback, + type WritePtyCallback, +} from './write_pty_trampoline'; import { CellFlags, CursorVisualStyle, @@ -279,6 +283,15 @@ export class GhosttyTerminal { /** Cell pool for zero-allocation rendering */ private cellPool: GhosttyCell[] = []; + /** + * Cell pixel dimensions last pushed to the WASM terminal via + * ghostty_terminal_resize. Zero means "unknown / disabled" — kitty + * graphics image sizing and CSI 14/16/18 t in-band size reports will + * return zero/no-op until setCellPixelSize() is called with real values. + */ + private cellWidthPx = 0; + private cellHeightPx = 0; + /** * Per-row dirty state for the current render-state snapshot. Cleared on * update() and populated lazily by isRowDirty() (or as a side effect of @@ -302,26 +315,33 @@ export class GhosttyTerminal { private pendingResponses: Uint8Array[] = []; /** - * Per-table registry for the WRITE_PTY callback. Keyed on the WASM + * Per-table registry for callback trampolines. Keyed on the WASM * module's __indirect_function_table so that multiple Ghostty.load() - * instances each get their own trampoline slot and routing map — + * instances each get their own trampoline slots and routing map — * terminal handles are only unique within a single WASM instance, and * indices into one module's table are meaningless in another. * - * `index` is the slot we own in that table; `instancesByHandle` routes - * incoming WRITE_PTY callbacks back to the matching GhosttyTerminal. + * One trampoline pair (write_pty + size) is installed per table; their + * slot indices live here alongside the routing map. The dispatchers + * close over the same instancesByHandle so any GhosttyTerminal coming + * from this WASM module routes correctly. */ - private static writePtyRegistries = new WeakMap< + private static callbackRegistries = new WeakMap< WebAssembly.Table, - { index: number; instancesByHandle: Map } + { + writePtyIndex: number; + sizeIndex: number; + instancesByHandle: Map; + } >(); /** * Cached pointer to this terminal's registry. We only need it to * deregister cleanly in free() / cleanupOnConstructorFailure(). */ - private writePtyRegistry?: { - index: number; + private callbackRegistry?: { + writePtyIndex: number; + sizeIndex: number; instancesByHandle: Map; }; @@ -371,11 +391,12 @@ export class GhosttyTerminal { // addition to the WASM resource frees that cleanupOnConstructorFailure // already handles. try { - // Install the trampoline callback so the terminal can deliver - // response bytes (DSR, XTVERSION, in-band size reports, ...) back - // to JS. Resolves / creates the per-table registry on first use, - // registers `this` in it, then sets WRITE_PTY on the terminal. - this.installWritePtyCallback(); + // Install the trampoline callbacks so the terminal can deliver + // response bytes (DSR, XTVERSION, etc.) back to JS via WRITE_PTY, + // and so the embedder can answer XTWINOPS size queries (CSI 14/16/18 t) + // via SIZE. Resolves / creates the per-table registry on first + // use, registers `this` in it, then sets the options on the terminal. + this.installCallbacks(); // Apply theme colors + palette overrides. The constructor's options // struct only carries cols/rows/scrollback, so colors land here via @@ -512,9 +533,9 @@ export class GhosttyTerminal { * before the throw propagates. */ private cleanupOnConstructorFailure(): void { - if (this.writePtyRegistry) { - this.writePtyRegistry.instancesByHandle.delete(this.handle); - this.writePtyRegistry = undefined; + if (this.callbackRegistry) { + this.callbackRegistry.instancesByHandle.delete(this.handle); + this.callbackRegistry = undefined; } if (this.rowCells) { this.exports.ghostty_render_state_row_cells_free(this.rowCells); @@ -624,15 +645,51 @@ export class GhosttyTerminal { if (cols === this._cols && rows === this._rows) return; this._cols = cols; this._rows = rows; - // TODO: thread real cell pixel dims (currently 0 = unknown/disabled, - // affects size reports and image protocols only). - this.exports.ghostty_terminal_resize(this.handle, cols, rows, 0, 0); + this.exports.ghostty_terminal_resize( + this.handle, + cols, + rows, + this.cellWidthPx, + this.cellHeightPx + ); this.initCellPool(); } + /** + * Push the renderer's per-cell pixel size into the WASM terminal. + * + * The new C ABI doesn't expose a separate "set pixel size" call — + * dimensions only flow through ghostty_terminal_resize, which takes + * (cols, rows, cell_width_px, cell_height_px). We cache the cell pixel + * dims on the instance so subsequent resize() calls keep the values + * stable, and short-circuit when nothing has changed. + * + * The width/height arguments are PER-CELL CSS pixels — matches what + * the renderer reports via getMetrics(). Coder's old setPixelSize + * took TOTAL screen pixels (cell_width * cols, cell_height * rows); + * we renamed to avoid silent value mis-passing. + * + * Affects in-band size reports (CSI 14/16/18 t) and kitty graphics + * placement sizing. Until called, those query paths return zero. + */ + setCellPixelSize(cellWidthPx: number, cellHeightPx: number): void { + const w = Math.max(1, Math.round(cellWidthPx)); + const h = Math.max(1, Math.round(cellHeightPx)); + if (w === this.cellWidthPx && h === this.cellHeightPx) return; + this.cellWidthPx = w; + this.cellHeightPx = h; + this.exports.ghostty_terminal_resize( + this.handle, + this._cols, + this._rows, + w, + h + ); + } + free(): void { - if (this.writePtyRegistry) { - this.writePtyRegistry.instancesByHandle.delete(this.handle); + if (this.callbackRegistry) { + this.callbackRegistry.instancesByHandle.delete(this.handle); } if (this.rowCells) { this.exports.ghostty_render_state_row_cells_free(this.rowCells); @@ -1396,28 +1453,34 @@ export class GhosttyTerminal { } /** - * Install the WRITE_PTY callback through the trampoline. + * Install the WRITE_PTY and SIZE trampoline callbacks. * - * The trampoline is shared across all terminals that come from the + * Trampolines are shared across all terminals that come from the * same WASM instance, but NOT across instances — terminal handles are * only unique within their parent module, and table indices in module * A are meaningless in module B's table. So we keep a per-table * registry (WeakMap keyed on the indirect function table) that owns - * the slot index plus the handle→instance routing map for that table. + * the slot indices plus the handle→instance routing map for that + * table. * - * On first use for a given table we instantiate the trampoline, - * `table.grow(1)`, and `table.set(idx, fwd)`. Subsequent terminals - * from the same module reuse the registry and just register their - * handle in instancesByHandle. + * On first use for a given table we instantiate the trampolines, + * `table.grow(2)`, and write both into the new slots. Subsequent + * terminals from the same module reuse the registry and just + * register their handle in instancesByHandle. */ - private installWritePtyCallback(): void { + private installCallbacks(): void { const table = (this.exports as unknown as { __indirect_function_table: WebAssembly.Table }) .__indirect_function_table; - let registry = GhosttyTerminal.writePtyRegistries.get(table); + let registry = GhosttyTerminal.callbackRegistries.get(table); if (!registry) { const instancesByHandle = new Map(); - const dispatch: WritePtyCallback = (handle, _userdata, dataPtr, dataLen) => { + const writePtyDispatch: WritePtyCallback = ( + handle, + _userdata, + dataPtr, + dataLen, + ) => { const term = instancesByHandle.get(handle); if (!term) return; // Copy out — the underlying WASM memory may be mutated or @@ -1427,17 +1490,40 @@ export class GhosttyTerminal { new Uint8Array(term.memory.buffer, dataPtr, dataLen).slice(), ); }; - const fwd = makeWritePtyTrampoline(dispatch); - const idx = table.grow(1); - table.set(idx, fwd); - registry = { index: idx, instancesByHandle }; - GhosttyTerminal.writePtyRegistries.set(table, registry); + const sizeDispatch: SizeCallback = (handle, _userdata, outSizePtr) => { + const term = instancesByHandle.get(handle); + if (!term) return 0; + // Without real cell pixel dims the response would be nonsense; + // returning false (0) tells the terminal to silently drop the + // size query, matching coder's old behavior for unconfigured + // pixel sizes. + if (term.cellWidthPx === 0 || term.cellHeightPx === 0) return 0; + // GhosttySizeReportSize: rows@0:u16, cols@2:u16, cell_w@4:u32, + // cell_h@8:u32 (12 bytes total). + const view = new DataView(term.memory.buffer); + view.setUint16(outSizePtr + 0, term._rows, true); + view.setUint16(outSizePtr + 2, term._cols, true); + view.setUint32(outSizePtr + 4, term.cellWidthPx, true); + view.setUint32(outSizePtr + 8, term.cellHeightPx, true); + return 1; + }; + const { writePtyFwd, sizeFwd } = makeCallbackTrampolines( + writePtyDispatch, + sizeDispatch, + ); + // Grow once for both, write each into its slot. + const writePtyIndex = table.grow(1); + table.set(writePtyIndex, writePtyFwd); + const sizeIndex = table.grow(1); + table.set(sizeIndex, sizeFwd); + registry = { writePtyIndex, sizeIndex, instancesByHandle }; + GhosttyTerminal.callbackRegistries.set(table, registry); } - // Register `this` so the dispatcher (closed over instancesByHandle) - // can route incoming bytes to the right pendingResponses queue. + // Register `this` so the dispatchers (both close over + // instancesByHandle) can route to the right instance. registry.instancesByHandle.set(this.handle, this); - this.writePtyRegistry = registry; + this.callbackRegistry = registry; // The third arg to _set is the value — for callbacks ("pointer // types"), the value IS the function pointer, i.e. the table index @@ -1445,7 +1531,12 @@ export class GhosttyTerminal { this.exports.ghostty_terminal_set( this.handle, TerminalOption.WRITE_PTY, - registry.index, + registry.writePtyIndex, + ); + this.exports.ghostty_terminal_set( + this.handle, + TerminalOption.SIZE, + registry.sizeIndex, ); } diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index e604068..adf98cf 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -3021,6 +3021,27 @@ describe('Write PTY response routing', () => { t.free(); }); + test('XTWINOPS size queries (CSI 14/16/18 t) round-trip after setCellPixelSize', async () => { + const { Ghostty } = await import('./ghostty'); + const g = await Ghostty.load(); + const t = g.createTerminal(80, 24); + + // Without pixel dims set, the SIZE callback returns false and the + // terminal silently drops the query. + t.write('\x1b[14t'); + expect(t.readResponse()).toBe(null); + + t.setCellPixelSize(8, 16); + t.write('\x1b[14t'); // text area in pixels — \e[4;;t + expect(t.readResponse()).toBe('\x1b[4;384;640t'); + t.write('\x1b[16t'); // cell in pixels — \e[6;;t + expect(t.readResponse()).toBe('\x1b[6;16;8t'); + t.write('\x1b[18t'); // rows / cols — \e[8;;t + expect(t.readResponse()).toBe('\x1b[8;24;80t'); + + t.free(); + }); + test('two parallel Ghostty.load() instances each route to themselves', async () => { const { Ghostty } = await import('./ghostty'); // Each load() owns its own __indirect_function_table; the registry diff --git a/lib/terminal.ts b/lib/terminal.ts index eeb7acd..b4dc154 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -250,6 +250,10 @@ export class Terminal implements ITerminalCore { this.canvas.style.width = `${metrics.width * this.cols}px`; this.canvas.style.height = `${metrics.height * this.rows}px`; + // Push the new per-cell pixel size into the WASM terminal so size + // reports / kitty graphics see the updated metrics. + this.updateWasmPixelSize(); + // Force full re-render with new font this.renderer.render(this.wasmTerm, true, this.viewportY, this); } @@ -428,6 +432,10 @@ export class Terminal implements ITerminalCore { // Size canvas to terminal dimensions (use renderer.resize for proper DPI scaling) this.renderer.resize(this.cols, this.rows); + // Push initial cell pixel dims into the WASM terminal — needed for + // size reports and kitty graphics from the very first vt_write. + this.updateWasmPixelSize(); + // Create mouse tracking configuration const canvas = this.canvas; const renderer = this.renderer; @@ -689,6 +697,11 @@ export class Terminal implements ITerminalCore { this.canvas!.style.width = `${metrics.width * cols}px`; this.canvas!.style.height = `${metrics.height * rows}px`; + // Refresh WASM cell pixel dims after the resize. Cell metrics + // typically don't change on a logical resize, but this handles + // DPR changes and is cheap (no-ops when values are unchanged). + this.updateWasmPixelSize(); + // Fire resize event this.resizeEmitter.fire({ cols, rows }); @@ -1129,6 +1142,23 @@ export class Terminal implements ITerminalCore { // Private Methods // ========================================================================== + /** + * Push the renderer's per-cell pixel size into the WASM terminal. + * + * Called from setup, open(), and resize() — everywhere the renderer + * may have rebuilt its FontMetrics. Affects in-band size reports + * (CSI 14/16/18 t) and kitty graphics placement sizing; without it + * the terminal returns zeros for those queries. + * + * GhosttyTerminal.setCellPixelSize short-circuits when the values + * haven't changed, so this is cheap to call from any of the above. + */ + private updateWasmPixelSize(): void { + if (!this.renderer || !this.wasmTerm) return; + const metrics = this.renderer.getMetrics(); + this.wasmTerm.setCellPixelSize(metrics.width, metrics.height); + } + /** * Cancel the render loop */ diff --git a/lib/write_pty_trampoline.ts b/lib/write_pty_trampoline.ts index 490b261..fdd0210 100644 --- a/lib/write_pty_trampoline.ts +++ b/lib/write_pty_trampoline.ts @@ -1,19 +1,24 @@ /** - * Tiny WASM trampoline that lets us install a JS callback into the main + * Tiny WASM trampolines that let us install JS callbacks into the main * libghostty-vt module's __indirect_function_table. * - * Why this exists: ghostty_terminal_set(WRITE_PTY, fnPtr) takes a function + * Why this exists: ghostty_terminal_set(OPT_*, fnPtr) takes a function * pointer (a table index in WASM-land). To put a JS function at a given - * table index we'd normally use `new WebAssembly.Function(...)`, but that's - * part of the Type Reflection proposal which only Chrome ships — Bun and - * Node both report `typeof WebAssembly.Function === 'undefined'`. + * table index we'd normally use `new WebAssembly.Function(...)`, but + * that's part of the Type Reflection proposal which only Chrome ships — + * Bun and Node both report `typeof WebAssembly.Function === 'undefined'`. * - * Workaround: instantiate a tiny separate WASM module that imports the - * JS callback as `env.cb` and exports a wrapper `fwd` with the same - * GhosttyTerminalWritePtyFn signature (i32, i32, i32, i32) -> nil. The - * wrapper's exported funcref is portable across modules with compatible - * funcref tables, so we can add it to the main module's - * __indirect_function_table and pass that index to terminal_set. + * Workaround: instantiate a tiny separate WASM module that imports JS + * callbacks (one per signature) and exports matching wrappers. Each + * exported funcref is portable across modules with compatible funcref + * tables, so we can add it to the main module's table and pass the + * index to terminal_set. + * + * Currently bridged: + * WRITE_PTY: (terminal, userdata, data, len) -> void + * For DSR replies, in-band size reports, XTVERSION, etc. + * SIZE: (terminal, userdata, out_size) -> bool + * For CSI 14/16/18 t (XTWINOPS) — embedder fills the out_size struct. * * The bytes below are the output of: * wat2wasm lib/write_pty_trampoline.wat -o /tmp/trampoline.wasm @@ -21,11 +26,17 @@ * Source is in write_pty_trampoline.wat — keep both in sync if you edit. */ const TRAMPOLINE_BYTES = new Uint8Array([ - 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x01, 0x60, - 0x04, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x02, 0x0a, 0x01, 0x03, 0x65, 0x6e, - 0x76, 0x02, 0x63, 0x62, 0x00, 0x00, 0x03, 0x02, 0x01, 0x00, 0x07, 0x07, - 0x01, 0x03, 0x66, 0x77, 0x64, 0x00, 0x01, 0x0a, 0x0e, 0x01, 0x0c, 0x00, - 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x20, 0x03, 0x10, 0x00, 0x0b, + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x0f, 0x02, 0x60, + 0x04, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x60, 0x03, 0x7f, 0x7f, 0x7f, 0x01, + 0x7f, 0x02, 0x22, 0x02, 0x03, 0x65, 0x6e, 0x76, 0x0c, 0x77, 0x72, 0x69, + 0x74, 0x65, 0x5f, 0x70, 0x74, 0x79, 0x5f, 0x63, 0x62, 0x00, 0x00, 0x03, + 0x65, 0x6e, 0x76, 0x07, 0x73, 0x69, 0x7a, 0x65, 0x5f, 0x63, 0x62, 0x00, + 0x01, 0x03, 0x03, 0x02, 0x00, 0x01, 0x07, 0x1c, 0x02, 0x0d, 0x77, 0x72, + 0x69, 0x74, 0x65, 0x5f, 0x70, 0x74, 0x79, 0x5f, 0x66, 0x77, 0x64, 0x00, + 0x02, 0x08, 0x73, 0x69, 0x7a, 0x65, 0x5f, 0x66, 0x77, 0x64, 0x00, 0x03, + 0x0a, 0x19, 0x02, 0x0c, 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x20, + 0x03, 0x10, 0x00, 0x0b, 0x0a, 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, + 0x10, 0x01, 0x0b, ]); export type WritePtyCallback = ( @@ -35,15 +46,40 @@ export type WritePtyCallback = ( dataLen: number, ) => void; +/** + * SIZE callback: writes its result into out_size (a 12-byte + * GhosttySizeReportSize struct: rows@0:u16, cols@2:u16, cell_w@4:u32, + * cell_h@8:u32) and returns 1 to indicate "responded" or 0 to drop the + * query. + */ +export type SizeCallback = ( + terminal: number, + userdata: number, + outSizePtr: number, +) => number; + /** * Compile the trampoline once, then instantiate per-Ghostty with the JS - * callback as the `env.cb` import. Returns the exported `fwd` function - * which is a `funcref` callable from any WASM module via call_indirect. + * callbacks as the `env.*_cb` imports. Returns both exported wrappers + * — funcrefs callable from any WASM module via call_indirect. */ let compiled: WebAssembly.Module | null = null; -export function makeWritePtyTrampoline(cb: WritePtyCallback): Function { +export interface TrampolineExports { + writePtyFwd: Function; + sizeFwd: Function; +} + +export function makeCallbackTrampolines( + writePtyCb: WritePtyCallback, + sizeCb: SizeCallback, +): TrampolineExports { if (!compiled) compiled = new WebAssembly.Module(TRAMPOLINE_BYTES); - const inst = new WebAssembly.Instance(compiled, { env: { cb } }); - return inst.exports.fwd as Function; + const inst = new WebAssembly.Instance(compiled, { + env: { write_pty_cb: writePtyCb, size_cb: sizeCb }, + }); + return { + writePtyFwd: inst.exports.write_pty_fwd as Function, + sizeFwd: inst.exports.size_fwd as Function, + }; } diff --git a/lib/write_pty_trampoline.wat b/lib/write_pty_trampoline.wat index 89b282a..1150864 100644 --- a/lib/write_pty_trampoline.wat +++ b/lib/write_pty_trampoline.wat @@ -1,24 +1,33 @@ -;; Tiny trampoline so we can install a JS callback into the main wasm +;; Tiny trampolines so we can install JS callbacks into the main wasm ;; module's __indirect_function_table without WebAssembly.Function support ;; (Bun and Node lack it; only modern browsers ship the Type Reflection ;; proposal). ;; -;; This module imports a JS function `env.cb` and re-exports a wrapper -;; with the GhosttyTerminalWritePtyFn signature -;; (terminal: i32, userdata: i32, data: i32, len: i32). The wrapper's -;; exported funcref can be added to the main module's table, where -;; ghostty_terminal_set(WRITE_PTY, idx) wires it up. +;; Each trampoline imports a JS function from `env` and re-exports a +;; wrapper with the matching libghostty-vt callback signature. The +;; wrapper's exported funcref can be added to the main module's table, +;; where ghostty_terminal_set(OPT_*, idx) wires it up. +;; +;; Callbacks currently bridged: +;; WRITE_PTY: (terminal: i32, userdata: i32, data: i32, len: i32) -> nil +;; Used for DSR replies, in-band size reports, etc. +;; SIZE: (terminal: i32, userdata: i32, out_size: i32) -> i32 (bool) +;; Used for CSI 14/16/18 t responses; embedder fills out_size. ;; ;; Rebuild after edits: -;; wat2wasm lib/write_pty_trampoline.wat -o lib/write_pty_trampoline.wasm -;; Then update the byte literal in lib/write_pty_trampoline.ts with the -;; new content. +;; wat2wasm lib/write_pty_trampoline.wat -o /tmp/trampoline.wasm +;; Then update the byte literal in lib/write_pty_trampoline.ts. (module - (type $sig (func (param i32 i32 i32 i32))) - (import "env" "cb" (func $cb (type $sig))) - (func $fwd (export "fwd") (type $sig) - local.get 0 - local.get 1 - local.get 2 - local.get 3 - call $cb)) + (type $write_pty_sig (func (param i32 i32 i32 i32))) + (type $size_sig (func (param i32 i32 i32) (result i32))) + + (import "env" "write_pty_cb" (func $write_pty_cb (type $write_pty_sig))) + (import "env" "size_cb" (func $size_cb (type $size_sig))) + + (func $write_pty_fwd (export "write_pty_fwd") (type $write_pty_sig) + local.get 0 local.get 1 local.get 2 local.get 3 + call $write_pty_cb) + + (func $size_fwd (export "size_fwd") (type $size_sig) + local.get 0 local.get 1 local.get 2 + call $size_cb)) From a9dc7221b36eb5a4fee09697f2e4db9a597087e3 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Tue, 28 Apr 2026 22:40:16 -0400 Subject: [PATCH 12/29] Wire kitty graphics: WASM enable patch + C ABI bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/ghostty.ts | 33 +++++++ lib/types.ts | 164 +++++++++++++++++++++++++++++++++ patches/ghostty-wasm-api.patch | 116 +++++++++++++++++++++++ 3 files changed, 313 insertions(+) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index ade4da9..38ce324 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -409,6 +409,13 @@ export class GhosttyTerminal { // terminal_new() shim; the new public C ABI doesn't, so we enable // it here from JS to preserve coder's defaults. this.exports.ghostty_terminal_mode_set(this.handle, packMode(2027, false), true); + + // Enable kitty graphics by giving the terminal a non-zero image + // storage limit. The new C ABI ships kitty graphics disabled by + // default — image transmission commands are silently dropped at + // parse time until this limit is set. 64MB is enough for typical + // TUI use and matches what coder's old WASM defaulted to. + this.setKittyImageStorageLimit(64 * 1024 * 1024); } catch (e) { this.cleanupOnConstructorFailure(); throw e; @@ -655,6 +662,32 @@ export class GhosttyTerminal { this.initCellPool(); } + /** + * Set the maximum bytes of image data the terminal will retain across + * all kitty graphics images. Zero disables kitty graphics entirely + * (transmissions will be parsed and dropped). Set this BEFORE any + * image-bearing data is written to the terminal — there's no + * retroactive recovery of dropped images. + * + * Input is uint64_t* on the C side, so we use a u32-pair little-endian + * write to keep the byte count exact even past 4GB (probably overkill + * but free). + */ + setKittyImageStorageLimit(bytes: number): void { + const ptr = this.exports.ghostty_wasm_alloc_u8_array(8); + const view = new DataView(this.memory.buffer); + const lo = bytes >>> 0; + const hi = Math.floor(bytes / 0x100000000) >>> 0; + view.setUint32(ptr + 0, lo, true); + view.setUint32(ptr + 4, hi, true); + this.exports.ghostty_terminal_set( + this.handle, + TerminalOption.KITTY_IMAGE_STORAGE_LIMIT, + ptr + ); + this.exports.ghostty_wasm_free_u8_array(ptr, 8); + } + /** * Push the renderer's per-cell pixel size into the WASM terminal. * diff --git a/lib/types.ts b/lib/types.ts index a84f2e6..a3d340a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -494,6 +494,84 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { ): number; ghostty_grid_ref_style(refPtr: number, outStylePtr: number): number; + // Kitty graphics — placement iteration + image lookup. The graphics + // handle comes from ghostty_terminal_get(terminal, KITTY_GRAPHICS, *out) + // and is borrowed: invalidated by ANY mutating terminal call. + ghostty_kitty_graphics_get(graphics: number, key: number, outPtr: number): number; + ghostty_kitty_graphics_image(graphics: number, imageId: number): number; // returns image handle (0 if missing) + ghostty_kitty_graphics_image_get(image: number, key: number, outPtr: number): number; + ghostty_kitty_graphics_image_get_multi( + image: number, + count: number, + keysPtr: number, + valuesPtr: number, + outWrittenPtr: number + ): number; + ghostty_kitty_graphics_placement_iterator_new( + allocatorPtr: number, + outIterPtrPtr: number + ): number; + ghostty_kitty_graphics_placement_iterator_free(iter: number): void; + ghostty_kitty_graphics_placement_iterator_set( + iter: number, + option: number, + valuePtr: number + ): number; + ghostty_kitty_graphics_placement_next(iter: number): boolean; + ghostty_kitty_graphics_placement_get(iter: number, key: number, outPtr: number): number; + ghostty_kitty_graphics_placement_get_multi( + iter: number, + count: number, + keysPtr: number, + valuesPtr: number, + outWrittenPtr: number + ): number; + ghostty_kitty_graphics_placement_rect( + iter: number, + image: number, + terminal: TerminalHandle, + outSelectionPtr: number + ): number; + ghostty_kitty_graphics_placement_pixel_size( + iter: number, + image: number, + terminal: TerminalHandle, + outWidthPtr: number, + outHeightPtr: number + ): number; + ghostty_kitty_graphics_placement_grid_size( + iter: number, + image: number, + terminal: TerminalHandle, + outColsPtr: number, + outRowsPtr: number + ): number; + ghostty_kitty_graphics_placement_viewport_pos( + iter: number, + image: number, + terminal: TerminalHandle, + outColPtr: number, + outRowPtr: number + ): number; + ghostty_kitty_graphics_placement_source_rect( + iter: number, + image: number, + outX: number, + outY: number, + outW: number, + outH: number + ): number; + // The all-in-one render path: fills a 44-byte PlacementRenderInfo + // sized struct in a single call. Use this in the hot render loop + // instead of stringing together pixel_size + grid_size + viewport_pos + // + source_rect. + ghostty_kitty_graphics_placement_render_info( + iter: number, + image: number, + terminal: TerminalHandle, + outInfoPtr: number + ): number; + // Generic terminal property API. Mirrors render_state_get/set: a single // entry point keyed by GhosttyTerminalData (see TerminalData enum). ghostty_terminal_get(terminal: TerminalHandle, key: number, outPtr: number): number; @@ -600,6 +678,8 @@ export enum TerminalData { COLOR_BACKGROUND_DEFAULT = 23, COLOR_CURSOR_DEFAULT = 24, COLOR_PALETTE_DEFAULT = 25, + KITTY_IMAGE_STORAGE_LIMIT = 26, + KITTY_GRAPHICS = 30, } /** @@ -619,6 +699,90 @@ export enum TerminalOption { COLOR_BACKGROUND = 12, COLOR_CURSOR = 13, COLOR_PALETTE = 14, + KITTY_IMAGE_STORAGE_LIMIT = 15, +} + +/** + * Keys for ghostty_kitty_graphics_get(). Mirrors GhosttyKittyGraphicsData. + */ +export enum KittyGraphicsData { + PLACEMENT_ITERATOR = 1, +} + +/** + * Keys for ghostty_kitty_graphics_placement_get(). Mirrors + * GhosttyKittyGraphicsPlacementData. All values are u32 except Z (i32). + */ +export enum KittyGraphicsPlacementData { + IMAGE_ID = 1, + PLACEMENT_ID = 2, + IS_VIRTUAL = 3, + X_OFFSET = 4, + Y_OFFSET = 5, + SOURCE_X = 6, + SOURCE_Y = 7, + SOURCE_WIDTH = 8, + SOURCE_HEIGHT = 9, + COLUMNS = 10, + ROWS = 11, + Z = 12, +} + +/** + * Keys for ghostty_kitty_graphics_image_get(). Mirrors GhosttyKittyGraphicsImageData. + */ +export enum KittyGraphicsImageData { + ID = 1, + NUMBER = 2, + WIDTH = 3, + HEIGHT = 4, + FORMAT = 5, + COMPRESSION = 6, + DATA_PTR = 7, + DATA_LEN = 8, +} + +/** + * Z-layer filter for the placement iterator. Mirrors GhosttyKittyPlacementLayer. + */ +export enum KittyGraphicsPlacementLayer { + ALL = 0, + BELOW_BG = 1, + BELOW_TEXT = 2, + ABOVE_TEXT = 3, +} + +/** + * Settable options on the placement iterator. Mirrors + * GhosttyKittyGraphicsPlacementIteratorOption. + */ +export enum KittyGraphicsPlacementIteratorOption { + LAYER = 0, +} + +/** + * Pixel format of a Kitty graphics image. Mirrors GhosttyKittyImageFormat. + * RGB: 24-bit, 3 bytes/px + * RGBA: 32-bit, 4 bytes/px (the canvas-friendly path) + * PNG: compressed; needs a JS-side decoder hooked up via + * ghostty_sys_set(DECODE_PNG, fn) + * GRAY_ALPHA: 16-bit, 2 bytes/px + * GRAY: 8-bit, 1 byte/px + */ +export enum KittyImageFormat { + RGB = 0, + RGBA = 1, + PNG = 2, + GRAY_ALPHA = 3, + GRAY = 4, +} + +/** + * Compression of a Kitty graphics image. Mirrors GhosttyKittyImageCompression. + */ +export enum KittyImageCompression { + NONE = 0, + ZLIB_DEFLATE = 1, } /** diff --git a/patches/ghostty-wasm-api.patch b/patches/ghostty-wasm-api.patch index e69de29..a3e5f6b 100644 --- a/patches/ghostty-wasm-api.patch +++ b/patches/ghostty-wasm-api.patch @@ -0,0 +1,116 @@ +diff --git a/src/terminal/build_options.zig b/src/terminal/build_options.zig +index 136e0f101..a1d9215cf 100644 +--- a/src/terminal/build_options.zig ++++ b/src/terminal/build_options.zig +@@ -64,11 +64,14 @@ pub const Options = struct { + // We disable it on wasm32-freestanding because we at the least + // require the ability to get timestamps and there is no way to + // do that with freestanding targets. +- const target = m.resolved_target.?.result; ++ // ghostty-web: enabled with a monotonic-counter shim in ++ // src/terminal/kitty/graphics_image.zig (see Timestamp / ++ // transmitTimeNow) and a comptime guard against non-direct ++ // mediums. + opts.addOption( + bool, + "kitty_graphics", +- !(target.cpu.arch == .wasm32 and target.os.tag == .freestanding), ++ true, + ); + + // These are synthesized based on other options. +diff --git a/src/terminal/kitty/graphics_image.zig b/src/terminal/kitty/graphics_image.zig +index 8243a6323..0365263ef 100644 +--- a/src/terminal/kitty/graphics_image.zig ++++ b/src/terminal/kitty/graphics_image.zig +@@ -18,6 +18,31 @@ const temp_dir = struct { + + const log = std.log.scoped(.kitty_gfx); + ++/// ghostty-web: WASM-safe substitute for std.time.Instant. ++/// On freestanding targets there's no clock; we use a monotonic counter ++/// which is sufficient for the LRU-eviction ordering this is used for. ++/// On native, we alias to std.time.Instant for full fidelity. ++pub const Timestamp = if (builtin.target.cpu.arch.isWasm()) struct { ++ value: u64 = 0, ++ ++ pub fn order(self: @This(), other: @This()) std.math.Order { ++ return std.math.order(self.value, other.value); ++ } ++} else std.time.Instant; ++ ++var wasm_next_transmit_time: u64 = 0; ++ ++/// ghostty-web: Get a Timestamp for the current moment. On WASM this is ++/// a counter; on native it's the actual system clock. ++fn transmitTimeNow() !Timestamp { ++ if (comptime builtin.target.cpu.arch.isWasm()) { ++ wasm_next_transmit_time +%= 1; ++ return .{ .value = wasm_next_transmit_time }; ++ } else { ++ return std.time.Instant.now(); ++ } ++} ++ + /// Maximum width or height of an image. Taken directly from Kitty. + const max_dimension = 10000; + +@@ -100,6 +125,14 @@ pub const LoadingImage = struct { + return result; + } + ++ // ghostty-web: on freestanding/WASM we have no filesystem or shared ++ // memory, so any non-direct medium is unsupported. Bail here before ++ // the code below, which references std.fs.max_path_bytes and ++ // posix.realpath — both fail to compile on freestanding. ++ if (comptime builtin.target.cpu.arch.isWasm()) { ++ return error.UnsupportedMedium; ++ } ++ + // Verify our capabilities and limits allow this. + { + // Special case if we don't support decoding PNGs and the format +@@ -402,7 +435,7 @@ pub const LoadingImage = struct { + } + + // Set our time +- self.image.transmit_time = std.time.Instant.now() catch |err| { ++ self.image.transmit_time = transmitTimeNow() catch |err| { + log.warn("failed to get time: {}", .{err}); + return error.InternalError; + }; +@@ -512,7 +545,7 @@ pub const Image = struct { + format: command.Transmission.Format = .rgb, + compression: command.Transmission.Compression = .none, + data: []const u8 = "", +- transmit_time: std.time.Instant = undefined, ++ transmit_time: Timestamp = undefined, + + /// Set this to true if this image was loaded by a command that + /// doesn't specify an ID or number, since such commands should +diff --git a/src/terminal/kitty/graphics_storage.zig b/src/terminal/kitty/graphics_storage.zig +index e017d5f79..c68db764c 100644 +--- a/src/terminal/kitty/graphics_storage.zig ++++ b/src/terminal/kitty/graphics_storage.zig +@@ -9,8 +9,10 @@ const size = @import("../size.zig"); + const command = @import("graphics_command.zig"); + const PageList = @import("../PageList.zig"); + const Screen = @import("../Screen.zig"); +-const LoadingImage = @import("graphics_image.zig").LoadingImage; +-const Image = @import("graphics_image.zig").Image; ++const imagepkg = @import("graphics_image.zig"); ++const LoadingImage = imagepkg.LoadingImage; ++const Image = imagepkg.Image; ++const Timestamp = imagepkg.Timestamp; + const Rect = @import("graphics_image.zig").Rect; + const Command = command.Command; + +@@ -526,7 +528,7 @@ pub const ImageStorage = struct { + // bit is fine compared to the megabytes we're looking to save. + const Candidate = struct { + id: u32, +- time: std.time.Instant, ++ time: Timestamp, + used: bool, + }; + From 8aa9aeb957ce22c69b679407755097cf6b6b3b6a Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 29 Apr 2026 09:46:58 -0400 Subject: [PATCH 13/29] Composite kitty graphics placements onto the canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/ghostty.ts | 201 ++++++++++++++++++++++++++++++++++++++++++++++++ lib/renderer.ts | 139 ++++++++++++++++++++++++++++++++- lib/types.ts | 54 +++++++++++++ 3 files changed, 392 insertions(+), 2 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 38ce324..6e89f25 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -35,6 +35,13 @@ import { RowData, CellData, CellWide, + KITTY_PLACEMENT_RENDER_INFO_SIZE, + KittyGraphicsData, + KittyGraphicsImageData, + type KittyImagePixels, + KittyGraphicsPlacementData, + KittyImageFormat, + type KittyPlacementInfo, TerminalData, type TerminalHandle, TerminalOption, @@ -688,6 +695,200 @@ export class GhosttyTerminal { this.exports.ghostty_wasm_free_u8_array(ptr, 8); } + // ========================================================================== + // Kitty graphics — placement iteration + image data lookup. + // + // The renderer calls these per frame: iterate visible placements, look up + // pixel data for each, composite onto the canvas. All handles returned + // here (storage, image) are borrowed from the terminal and invalidated by + // ANY mutating terminal call (vt_write, resize, reset, ...). + // Callers must finish any read/copy before the next mutation. + // ========================================================================== + + /** + * Get the kitty graphics storage handle for the active screen, or null + * if storage is disabled or no images are stored. Cheap to call; returns + * a borrowed pointer. + */ + getKittyGraphics(): number | null { + const out = this.exports.ghostty_wasm_alloc_u8_array(4); + try { + const r = this.exports.ghostty_terminal_get( + this.handle, + TerminalData.KITTY_GRAPHICS, + out + ); + if (r !== 0) return null; + const handle = new DataView(this.memory.buffer).getUint32(out, true); + return handle === 0 ? null : handle; + } finally { + this.exports.ghostty_wasm_free_u8_array(out, 4); + } + } + + /** + * Iterate placements in the active screen, yielding render-ready info + * for each. The optional `onlyVisible` flag (default true) drops + * placements that don't intersect the viewport — most renderers want + * this. Use `false` if you need to track invalidated regions for + * partial damage. + * + * Internally this uses the upstream placement iterator + the one-shot + * placement_render_info call (fills 12 fields in one WASM crossing + * instead of 5 separate getters). + */ + *iterPlacements( + graphics: number, + onlyVisible: boolean = true, + ): Generator { + // Allocate iterator + scratch buffers once for the whole walk. + const iterPP = this.exports.ghostty_wasm_alloc_opaque(); + if (iterPP === 0) return; + let iter = 0; + try { + const r = this.exports.ghostty_kitty_graphics_placement_iterator_new(0, iterPP); + if (r !== 0) return; + iter = new DataView(this.memory.buffer).getUint32(iterPP, true); + if (iter === 0) return; + + // Bind the iterator to the current placements. + const handlePtr = this.exports.ghostty_wasm_alloc_u8_array(4); + try { + new DataView(this.memory.buffer).setUint32(handlePtr, iter, true); + this.exports.ghostty_kitty_graphics_get( + graphics, + KittyGraphicsData.PLACEMENT_ITERATOR, + handlePtr + ); + } finally { + this.exports.ghostty_wasm_free_u8_array(handlePtr, 4); + } + + const idPtr = this.exports.ghostty_wasm_alloc_u8_array(4); + const infoPtr = this.exports.ghostty_wasm_alloc_u8_array( + KITTY_PLACEMENT_RENDER_INFO_SIZE + ); + // Sized struct: write the discriminator once, the populator + // overwrites the rest each call. + new DataView(this.memory.buffer).setUint32( + infoPtr, + KITTY_PLACEMENT_RENDER_INFO_SIZE, + true + ); + try { + while (this.exports.ghostty_kitty_graphics_placement_next(iter)) { + // Look up image_id for this placement so we can pair it with + // pixel data in the caller. + this.exports.ghostty_kitty_graphics_placement_get( + iter, + KittyGraphicsPlacementData.IMAGE_ID, + idPtr + ); + const imageId = new DataView(this.memory.buffer).getUint32(idPtr, true); + + // Resolve the image handle — placement_render_info needs it. + const imageHandle = this.exports.ghostty_kitty_graphics_image( + graphics, + imageId + ); + if (imageHandle === 0) continue; + + // Reset the size discriminator (the populator may have written + // the actual struct size back, but we don't rely on that — be + // explicit so the call always sees the buffer as fully owned). + new DataView(this.memory.buffer).setUint32( + infoPtr, + KITTY_PLACEMENT_RENDER_INFO_SIZE, + true + ); + const r2 = this.exports.ghostty_kitty_graphics_placement_render_info( + iter, + imageHandle, + this.handle, + infoPtr + ); + if (r2 !== 0) continue; + + const v = new DataView(this.memory.buffer); + const info: KittyPlacementInfo = { + imageId, + pixelWidth: v.getUint32(infoPtr + 4, true), + pixelHeight: v.getUint32(infoPtr + 8, true), + gridCols: v.getUint32(infoPtr + 12, true), + gridRows: v.getUint32(infoPtr + 16, true), + viewportCol: v.getInt32(infoPtr + 20, true), + viewportRow: v.getInt32(infoPtr + 24, true), + viewportVisible: v.getUint8(infoPtr + 28) !== 0, + sourceX: v.getUint32(infoPtr + 32, true), + sourceY: v.getUint32(infoPtr + 36, true), + sourceWidth: v.getUint32(infoPtr + 40, true), + sourceHeight: v.getUint32(infoPtr + 44, true), + }; + if (onlyVisible && !info.viewportVisible) continue; + yield info; + } + } finally { + this.exports.ghostty_wasm_free_u8_array(idPtr, 4); + this.exports.ghostty_wasm_free_u8_array( + infoPtr, + KITTY_PLACEMENT_RENDER_INFO_SIZE + ); + } + } finally { + if (iter !== 0) { + this.exports.ghostty_kitty_graphics_placement_iterator_free(iter); + } + this.exports.ghostty_wasm_free_opaque(iterPP); + } + } + + /** + * Get the pixel data + metadata for an image by id. Returns null if the + * image isn't stored or isn't in a format we can hand the renderer + * directly (RGB / RGBA / GRAY / GRAY_ALPHA). + * + * The returned `data` is a borrowed view into WASM memory — copy before + * the next vt_write if you need to retain. Most callers will turn this + * into an ImageData / canvas immediately and discard the view. + */ + getKittyImagePixels(graphics: number, imageId: number): KittyImagePixels | null { + const image = this.exports.ghostty_kitty_graphics_image(graphics, imageId); + if (image === 0) return null; + + const u32Ptr = this.exports.ghostty_wasm_alloc_u8_array(4); + try { + const view = new DataView(this.memory.buffer); + const read = (key: number): number => { + if ( + this.exports.ghostty_kitty_graphics_image_get(image, key, u32Ptr) !== 0 + ) { + return 0; + } + return new DataView(this.memory.buffer).getUint32(u32Ptr, true); + }; + + const width = read(KittyGraphicsImageData.WIDTH); + const height = read(KittyGraphicsImageData.HEIGHT); + const format = read(KittyGraphicsImageData.FORMAT) as KittyImageFormat; + const dataPtr = read(KittyGraphicsImageData.DATA_PTR); + const dataLen = read(KittyGraphicsImageData.DATA_LEN); + void view; + + if (width === 0 || height === 0 || dataPtr === 0 || dataLen === 0) { + return null; + } + + return { + width, + height, + format, + data: new Uint8Array(this.memory.buffer, dataPtr, dataLen), + }; + } finally { + this.exports.ghostty_wasm_free_u8_array(u32Ptr, 4); + } + } + /** * Push the renderer's per-cell pixel size into the WASM terminal. * diff --git a/lib/renderer.ts b/lib/renderer.ts index 24db27b..92fc056 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -12,8 +12,8 @@ import type { ITheme } from './interfaces'; import type { SelectionManager } from './selection-manager'; -import type { GhosttyCell, ILink } from './types'; -import { CellFlags } from './types'; +import type { GhosttyCell, ILink, KittyImagePixels, KittyPlacementInfo } from './types'; +import { CellFlags, KittyImageFormat } from './types'; // Interface for objects that can be rendered export interface IRenderable { @@ -30,6 +30,13 @@ export interface IRenderable { * For simple cells, returns the single character. */ getGraphemeString?(row: number, col: number): string; + + // Kitty graphics — optional. When implemented, the renderer composites + // images onto the canvas after text rendering. GhosttyTerminal provides + // these; other IRenderable implementations (e.g. test fakes) can omit. + getKittyGraphics?(): number | null; + iterPlacements?(graphics: number, onlyVisible?: boolean): Iterable; + getKittyImagePixels?(graphics: number, imageId: number): KittyImagePixels | null; } export interface IScrollbackProvider { @@ -114,6 +121,18 @@ export class CanvasRenderer { // Current buffer being rendered (for grapheme lookups) private currentBuffer: IRenderable | null = null; + /** + * Decoded kitty graphics images, keyed by image id. Each entry caches + * a canvas painted from the WASM-side RGBA bytes so per-frame compositing + * is just a drawImage call. We track dataLen to invalidate on + * re-transmission (the kitty protocol allows reusing an id with new + * bytes); a length mismatch is a cheap, correct staleness signal. + */ + private kittyImageCache = new Map< + number, + { canvas: HTMLCanvasElement; dataLen: number } + >(); + // Selection manager (for rendering selection) private selectionManager?: SelectionManager; // Cached selection coordinates for current render pass (viewport-relative) @@ -483,6 +502,13 @@ export class CanvasRenderer { // Link underlines are drawn during cell rendering (see renderCell) + // Composite kitty graphics images on top of the text. MVP z-order is + // "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 via PlacementLayer if real apps + // need it. + this.renderKittyImages(buffer); + // Render cursor (only if we're at the bottom, not scrolled) if (viewportY === 0 && cursor.visible && this.cursorVisible) { this.renderCursor(cursor.x, cursor.y); @@ -720,6 +746,115 @@ export class CanvasRenderer { } } + /** + * Composite all visible kitty graphics placements onto the canvas. + * Cheap when no graphics are active (one method check, one terminal_get). + * Decode work is amortized across frames via kittyImageCache. + */ + private renderKittyImages(buffer: IRenderable): void { + if (!buffer.getKittyGraphics || !buffer.iterPlacements || !buffer.getKittyImagePixels) { + return; + } + const graphics = buffer.getKittyGraphics(); + if (graphics === null) return; + + for (const p of buffer.iterPlacements(graphics)) { + let cached = this.kittyImageCache.get(p.imageId); + const pixels = buffer.getKittyImagePixels(graphics, p.imageId); + if (!pixels) continue; + + // Cache miss or stale (image was re-transmitted with new bytes + // under the same id). dataLen is a cheap discriminator that catches + // the common cases without hashing. + if (!cached || cached.dataLen !== pixels.data.length) { + const canvas = this.decodeKittyImageToCanvas(pixels); + if (!canvas) continue; + cached = { canvas, dataLen: pixels.data.length }; + this.kittyImageCache.set(p.imageId, cached); + } + + // Composite. Source/dest rects come straight from the C ABI's + // PlacementRenderInfo; viewport_col/row may be negative when a + // placement has scrolled partway off the top — drawImage handles + // that correctly (clips to the canvas). + this.ctx.drawImage( + cached.canvas, + p.sourceX, + p.sourceY, + p.sourceWidth, + p.sourceHeight, + p.viewportCol * this.metrics.width, + p.viewportRow * this.metrics.height, + p.pixelWidth, + p.pixelHeight, + ); + } + } + + /** + * Decode a kitty graphics image into a canvas suitable for drawImage. + * Expands non-RGBA formats into RGBA via putImageData; PNG payloads + * (which require a JS-side decoder set up via ghostty_sys_set) are + * not supported in this MVP and return null. + */ + private decodeKittyImageToCanvas( + pixels: KittyImagePixels, + ): HTMLCanvasElement | null { + const { width, height, format, data } = pixels; + if (width === 0 || height === 0) return null; + + // Allocate a fresh ArrayBuffer (not a WASM-memory view) so that + // (a) the bytes survive the next vt_write that might detach the + // WASM memory buffer, and + // (b) ImageData accepts the buffer (it rejects ArrayBufferLike + // which would include SharedArrayBuffer). + const rgba = new Uint8ClampedArray(new ArrayBuffer(width * height * 4)); + switch (format) { + case KittyImageFormat.RGBA: + rgba.set(data); + break; + case KittyImageFormat.RGB: + for (let i = 0, o = 0; i < data.length; i += 3, o += 4) { + rgba[o] = data[i]!; + rgba[o + 1] = data[i + 1]!; + rgba[o + 2] = data[i + 2]!; + rgba[o + 3] = 255; + } + break; + case KittyImageFormat.GRAY: + for (let i = 0, o = 0; i < data.length; i++, o += 4) { + const v = data[i]!; + rgba[o] = v; + rgba[o + 1] = v; + rgba[o + 2] = v; + rgba[o + 3] = 255; + } + break; + case KittyImageFormat.GRAY_ALPHA: + for (let i = 0, o = 0; i < data.length; i += 2, o += 4) { + const v = data[i]!; + rgba[o] = v; + rgba[o + 1] = v; + rgba[o + 2] = v; + rgba[o + 3] = data[i + 1]!; + } + break; + default: + // PNG and unknown formats — skip silently. The terminal would have + // dropped a PNG payload at parse time anyway unless a decoder was + // installed via ghostty_sys_set(DECODE_PNG, fn). + return null; + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + ctx.putImageData(new ImageData(rgba, width, height), 0, 0); + return canvas; + } + /** * Render cursor */ diff --git a/lib/types.ts b/lib/types.ts index a3d340a..4ef4123 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -785,6 +785,60 @@ export enum KittyImageCompression { ZLIB_DEFLATE = 1, } +/** + * Parsed GhosttyKittyGraphicsPlacementRenderInfo — everything the renderer + * needs about a single placement to composite it on the canvas. + * + * Wire layout on wasm32 (48 bytes, extern struct, 4-byte aligned): + * size: u32 @ 0 (sized-struct discriminator; we just write 48) + * pixel_width: u32 @ 4 + * pixel_height: u32 @ 8 + * grid_cols: u32 @ 12 + * grid_rows: u32 @ 16 + * viewport_col: i32 @ 20 + * viewport_row: i32 @ 24 + * viewport_visible: bool @ 28 (1 byte + 3 bytes padding to next u32) + * source_x: u32 @ 32 + * source_y: u32 @ 36 + * source_width: u32 @ 40 + * source_height: u32 @ 44 + */ +export interface KittyPlacementInfo { + imageId: number; + /** Destination size on the canvas, in pixels. */ + pixelWidth: number; + pixelHeight: number; + /** Destination size on the grid, in cells. */ + gridCols: number; + gridRows: number; + /** Top-left in viewport-relative cells. Negative when scrolled partway off the top. */ + viewportCol: number; + viewportRow: number; + /** Whether any part of the placement intersects the visible viewport. */ + viewportVisible: boolean; + /** Source rect within the image, in pixels (already clamped to image bounds). */ + sourceX: number; + sourceY: number; + sourceWidth: number; + sourceHeight: number; +} + +/** Size in bytes of GhosttyKittyGraphicsPlacementRenderInfo on wasm32. */ +export const KITTY_PLACEMENT_RENDER_INFO_SIZE = 48; + +/** + * Image bytes + metadata returned by GhosttyTerminal.getKittyImageRgba. + * `data` is a *view* into WASM memory and is invalidated by the next + * mutating terminal call — copy out before vt_write if you need to retain. + */ +export interface KittyImagePixels { + width: number; + height: number; + format: KittyImageFormat; + /** Borrowed view into WASM memory; copy before vt_write to retain. */ + data: Uint8Array; +} + /** * Active screen identifier. Mirrors GhosttyTerminalScreen. * Returned as the value for TerminalData.ACTIVE_SCREEN. From b63e587947ad746d625ee07a2d66310ac5b9b4ce Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 29 Apr 2026 09:59:36 -0400 Subject: [PATCH 14/29] Demo: thread cell pixel dims through to the PTY winsize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- demo/bin/demo.js | 22 +++++++++++++++++++--- demo/bun.lock | 18 ++++-------------- demo/index.html | 39 +++++++++++++++++++++++++++++++++++++-- demo/package.json | 2 +- 4 files changed, 61 insertions(+), 20 deletions(-) diff --git a/demo/bin/demo.js b/demo/bin/demo.js index e619a5e..5255f1d 100644 --- a/demo/bin/demo.js +++ b/demo/bin/demo.js @@ -13,8 +13,13 @@ import { homedir } from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; -// Node-pty for cross-platform PTY support -import pty from '@lydell/node-pty'; +// Node-pty for cross-platform PTY support. The 1.2.0-beta.x line adds a +// `pixelSize` argument to resize(), which sets ws_xpixel / ws_ypixel in +// the slave PTY's winsize struct so kitty kittens (icat etc.) can detect +// graphics support via TIOCGWINSZ instead of falling back to terminal +// queries. Lydell's fork is based on 1.1.0-beta14 (pre-pixelSize), so we +// use upstream's beta directly. +import pty from 'node-pty'; // WebSocket server import { WebSocketServer } from 'ws'; @@ -463,7 +468,18 @@ wss.on('connection', (ws, req) => { try { const msg = JSON.parse(message); if (msg.type === 'resize') { - ptyProcess.resize(msg.cols, msg.rows); + // node-pty 1.2.0+ accepts a third pixelSize arg that sets + // ws_xpixel / ws_ypixel in the PTY winsize struct. Without it, + // kitty kittens (icat, etc.) read zeros via TIOCGWINSZ and + // refuse to render images. + if (msg.xpixel > 0 && msg.ypixel > 0) { + ptyProcess.resize(msg.cols, msg.rows, { + width: msg.xpixel, + height: msg.ypixel, + }); + } else { + ptyProcess.resize(msg.cols, msg.rows); + } return; } } catch (e) { diff --git a/demo/bun.lock b/demo/bun.lock index 26dac1c..4aea4a4 100644 --- a/demo/bun.lock +++ b/demo/bun.lock @@ -5,28 +5,18 @@ "": { "name": "@ghostty-web/demo", "dependencies": { - "@lydell/node-pty": "^1.0.1", "ghostty-web": "latest", + "node-pty": "1.2.0-beta.12", "ws": "^8.18.0", }, }, }, "packages": { - "@lydell/node-pty": ["@lydell/node-pty@1.1.0", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.1.0", "@lydell/node-pty-darwin-x64": "1.1.0", "@lydell/node-pty-linux-arm64": "1.1.0", "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0" } }, "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw=="], - - "@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w=="], - - "@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA=="], - - "@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg=="], - - "@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA=="], - - "@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w=="], + "ghostty-web": ["ghostty-web@0.2.1", "", {}, "sha512-wrovbPlHcl+nIkp7S7fY7vOTsmBjwMFihZEe2PJe/M6G4/EwuyJnwaWTTzNfuY7RcM/lVlN+PvGWqJIhKSB5hw=="], - "@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw=="], + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], - "ghostty-web": ["ghostty-web@0.2.1", "", {}, "sha512-wrovbPlHcl+nIkp7S7fY7vOTsmBjwMFihZEe2PJe/M6G4/EwuyJnwaWTTzNfuY7RcM/lVlN+PvGWqJIhKSB5hw=="], + "node-pty": ["node-pty@1.2.0-beta.12", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-uExTCG/4VmSJa4+TjxFwPXv8BfacmfFEBL6JpxCMDghcwqzvD0yTcGmZ1fKOK6HY33tp0CelLblqTECJizc+Yw=="], "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], } diff --git a/demo/index.html b/demo/index.html index 2a0d323..ce5e9a1 100644 --- a/demo/index.html +++ b/demo/index.html @@ -138,6 +138,20 @@ let ws; let fitAddon; + // Read total canvas pixel dimensions (CSS pixels). Used so the + // server can stuff ws_xpixel / ws_ypixel into the PTY winsize via + // node-pty's resize(cols, rows, pixelSize) — kitty kittens (icat + // etc.) read those fields from their stdin and bail "doesn't + // support reporting screen sizes in pixels" if they're zero. + // Module-scope so both initTerminal and connectWebSocket can call it. + function getPixelSize() { + const container = document.getElementById('terminal-container'); + const canvas = container?.querySelector('canvas'); + return canvas + ? { xpixel: canvas.clientWidth, ypixel: canvas.clientHeight } + : { xpixel: 0, ypixel: 0 }; + } + async function initTerminal() { // Initialize WASM await init(); @@ -168,8 +182,16 @@ // Handle terminal resize term.onResize((size) => { if (ws && ws.readyState === WebSocket.OPEN) { - // Send resize as control sequence (server expects this format) - ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows })); + const px = getPixelSize(); + ws.send( + JSON.stringify({ + type: 'resize', + cols: size.cols, + rows: size.rows, + xpixel: px.xpixel, + ypixel: px.ypixel, + }) + ); } }); @@ -200,6 +222,19 @@ ws.onopen = () => { console.log('WebSocket connected'); updateConnectionStatus(true); + // Push initial pixel dims into the PTY winsize so tools that + // gate on TIOCGWINSZ (e.g. kitten icat) can detect graphics + // support without falling back to terminal queries. + const px = getPixelSize(); + ws.send( + JSON.stringify({ + type: 'resize', + cols: term.cols, + rows: term.rows, + xpixel: px.xpixel, + ypixel: px.ypixel, + }) + ); }; ws.onmessage = (event) => { diff --git a/demo/package.json b/demo/package.json index 09be088..7801f70 100644 --- a/demo/package.json +++ b/demo/package.json @@ -11,7 +11,7 @@ "dev": "node bin/demo.js --dev" }, "dependencies": { - "@lydell/node-pty": "^1.0.1", + "node-pty": "1.2.0-beta.12", "ghostty-web": "latest", "ws": "^8.18.0" }, From 3e2dc553e919bf95b08246d120badf4a3b95081e Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 29 Apr 2026 10:00:47 -0400 Subject: [PATCH 15/29] Wire a JS PNG decoder via ghostty_sys_set(DECODE_PNG) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bun.lock | 11 +++ lib/ghostty.ts | 166 ++++++++++++++++++++++++++++++++++- lib/types.ts | 18 ++++ lib/write_pty_trampoline.ts | 67 ++++++++++---- lib/write_pty_trampoline.wat | 17 +++- package.json | 3 + 6 files changed, 258 insertions(+), 24 deletions(-) diff --git a/bun.lock b/bun.lock index 557c739..20a486d 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "@cmux/ghostty-terminal", + "dependencies": { + "fast-png": "^7.0.0", + }, "devDependencies": { "@biomejs/biome": "^1.9.4", "@happy-dom/global-registrator": "^15.11.0", @@ -125,6 +128,8 @@ "@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="], + "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="], + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], "@volar/language-core": ["@volar/language-core@2.4.23", "", { "dependencies": { "@volar/source-map": "2.4.23" } }, "sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ=="], @@ -187,6 +192,8 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-png": ["fast-png@7.0.1", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^6.0.0", "pako": "^2.1.0" } }, "sha512-aD5BELuxRrAPlRhb9V/z1PVMFJy3cUXqIvoxM3IQ+7Rku+T4cbXxWclZ47f1XwhViEl4n30TAN8JmvTJKKc2Dw=="], + "fs-extra": ["fs-extra@11.3.2", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -205,6 +212,8 @@ "import-lazy": ["import-lazy@4.0.0", "", {}, "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw=="], + "iobuffer": ["iobuffer@6.0.1", "", {}, "sha512-SZWYkWNfjIXIBYSDpXDYIgshqtbOPsi4lviawAEceR1Kqk+sHDlcQjWrzNQsii80AyBY0q5c8HCTNjqo74ul+Q=="], + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="], @@ -235,6 +244,8 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "pako": ["pako@2.1.0", "", {}, "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 6e89f25..8788a77 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -6,7 +6,9 @@ * snapshot of all render data in a single update call. */ +import { decode as decodePng } from 'fast-png'; import { + type DecodePngCallback, makeCallbackTrampolines, type SizeCallback, type WritePtyCallback, @@ -42,6 +44,7 @@ import { KittyGraphicsPlacementData, KittyImageFormat, type KittyPlacementInfo, + SysOption, TerminalData, type TerminalHandle, TerminalOption, @@ -338,6 +341,7 @@ export class GhosttyTerminal { { writePtyIndex: number; sizeIndex: number; + decodePngIndex: number; instancesByHandle: Map; } >(); @@ -349,6 +353,7 @@ export class GhosttyTerminal { private callbackRegistry?: { writePtyIndex: number; sizeIndex: number; + decodePngIndex: number; instancesByHandle: Map; }; @@ -1741,17 +1746,68 @@ export class GhosttyTerminal { view.setUint32(outSizePtr + 8, term.cellHeightPx, true); return 1; }; - const { writePtyFwd, sizeFwd } = makeCallbackTrampolines( + // PNG decoder dispatcher. Called by ghostty when it needs to + // decode a kitty graphics PNG payload (kitten icat sends these by + // default — won't work without a decoder installed). Synchronous; + // we lean on fast-png for sync decode since createImageBitmap is + // async and unavailable from a sync C callback. + // + // Inputs: an allocator pointer (the library's, so the buffer we + // hand back gets freed on the same heap), PNG bytes in WASM + // memory, and a 16-byte out struct to fill. + // Out layout (GhosttySysImage): u32 width @ 0, u32 height @ 4, + // u32 data_ptr @ 8, u32 data_len @ 12. + const exports = this.exports; + const memory = this.memory; + const decodePngDispatch: DecodePngCallback = ( + _userdata, + allocator, + dataPtr, + dataLen, + outImagePtr, + ) => { + try { + const pngBytes = new Uint8Array(memory.buffer, dataPtr, dataLen).slice(); + const img = decodePng(pngBytes); + // fast-png returns 8/16-bit per channel data and various + // channel counts (plus an optional palette for indexed PNGs). + // The library expects RGBA u8. Normalize. + const rgba = pngToRgba8(img); + if (!rgba) return 0; + const outBuf = exports.ghostty_alloc(allocator, rgba.length); + if (outBuf === 0) return 0; + new Uint8Array(memory.buffer, outBuf, rgba.length).set(rgba); + const view = new DataView(memory.buffer); + view.setUint32(outImagePtr + 0, img.width, true); + view.setUint32(outImagePtr + 4, img.height, true); + view.setUint32(outImagePtr + 8, outBuf, true); + view.setUint32(outImagePtr + 12, rgba.length, true); + return 1; + } catch { + return 0; + } + }; + + const { writePtyFwd, sizeFwd, decodePngFwd } = makeCallbackTrampolines( writePtyDispatch, sizeDispatch, + decodePngDispatch, ); - // Grow once for both, write each into its slot. + // Grow once per slot, write each. const writePtyIndex = table.grow(1); table.set(writePtyIndex, writePtyFwd); const sizeIndex = table.grow(1); table.set(sizeIndex, sizeFwd); - registry = { writePtyIndex, sizeIndex, instancesByHandle }; + const decodePngIndex = table.grow(1); + table.set(decodePngIndex, decodePngFwd); + registry = { writePtyIndex, sizeIndex, decodePngIndex, instancesByHandle }; GhosttyTerminal.callbackRegistries.set(table, registry); + + // Install PNG decoder system-wide for this WASM instance. sys_set + // is process/instance-global (not per-terminal) so we do it + // exactly once per __indirect_function_table — same lifetime as + // the trampoline registry itself. + this.exports.ghostty_sys_set(SysOption.DECODE_PNG, decodePngIndex); } // Register `this` so the dispatchers (both close over @@ -1953,3 +2009,107 @@ export class GhosttyTerminal { } } + +/** + * Normalize a fast-png decode result into a tightly packed 8-bit RGBA + * buffer (4 bytes/pixel). fast-png returns whichever channel count and + * bit depth the source PNG used (1/8/16-bit; 1/2/3/4 channels); + * libghostty wants u8 RGBA. + * + * Returns null on any unexpected shape. + */ +function pngToRgba8(img: { + width: number; + height: number; + channels: number; + depth: number; + // fast-png types this as PngDataArray (Uint8Array | Uint8ClampedArray | + // Uint16Array). All three index numerically — we just need to handle + // depth 8 vs 16 since 1/2/4-bit PNGs come back already expanded to 8. + data: ArrayLike; + /** For indexed (palette) PNGs: array of [r,g,b] triples; data values + * are 1-byte indices into this array. Absent for non-indexed PNGs. */ + palette?: number[][]; + /** Per-index alpha for tRNS in indexed PNGs (each value 0-255 in the + * low byte regardless of bit depth). Indices past this array's + * length are fully opaque. */ + transparency?: ArrayLike; +}): Uint8Array | null { + const { width, height, channels, depth, data, palette, transparency } = img; + const px = width * height; + const out = new Uint8Array(px * 4); + + // Indexed (palette) PNG. fast-png reports channels=1 with the palette + // separate; if we just blitted `data` we'd get black-and-white because + // palette indices look like dim grayscale values. Apply the palette + // and per-index alpha here. + // + // Alpha source order — fast-png is inconsistent across PNG layouts: + // 1. palette[idx][3] — fast-png folds tRNS-derived alpha into the + // palette tuples themselves for many indexed-with-transparency + // PNGs (its `IndexedColors` type is documented as RGB triples + // but the runtime values are RGBA quadruples). + // 2. transparency[idx] — when fast-png does surface tRNS as its + // own field instead of folding into palette entries. + // 3. 255 fallback — fully opaque. + if (palette && palette.length > 0) { + for (let i = 0, o = 0; i < px; i++, o += 4) { + const idx = data[i]! ?? 0; + const rgb = palette[idx] ?? palette[0]!; + out[o] = rgb[0]!; + out[o + 1] = rgb[1]!; + out[o + 2] = rgb[2]!; + out[o + 3] = + rgb.length >= 4 + ? rgb[3]! + : transparency && idx < transparency.length + ? transparency[idx]! + : 255; + } + return out; + } + + // Bring 16-bit channels down to 8 by dropping the low byte. + const get = (i: number): number => { + if (depth === 16) return data[i]! >> 8; + return data[i]! ?? 0; + }; + switch (channels) { + case 4: + for (let i = 0, o = 0; i < px * 4; i += 4, o += 4) { + out[o] = get(i); + out[o + 1] = get(i + 1); + out[o + 2] = get(i + 2); + out[o + 3] = get(i + 3); + } + return out; + case 3: + for (let i = 0, o = 0; i < px * 3; i += 3, o += 4) { + out[o] = get(i); + out[o + 1] = get(i + 1); + out[o + 2] = get(i + 2); + out[o + 3] = 255; + } + return out; + case 2: + for (let i = 0, o = 0; i < px * 2; i += 2, o += 4) { + const v = get(i); + out[o] = v; + out[o + 1] = v; + out[o + 2] = v; + out[o + 3] = get(i + 1); + } + return out; + case 1: + for (let i = 0, o = 0; i < px; i++, o += 4) { + const v = get(i); + out[o] = v; + out[o + 1] = v; + out[o + 2] = v; + out[o + 3] = 255; + } + return out; + default: + return null; + } +} diff --git a/lib/types.ts b/lib/types.ts index 4ef4123..68c449d 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -583,6 +583,14 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { outWrittenPtr: number ): number; ghostty_terminal_set(terminal: TerminalHandle, option: number, valuePtr: number): number; + // System-wide options (process-global / per-WASM-instance). Used to + // install the PNG decoder callback for kitty graphics PNG payloads. + ghostty_sys_set(option: number, valuePtr: number): number; + // Allocate / free memory through the library's allocator. Used by + // callbacks (e.g. the PNG decoder) that need to hand WASM-allocated + // buffers back to the library. + ghostty_alloc(allocatorPtr: number, len: number): number; + ghostty_free(allocatorPtr: number, ptr: number, len: number): void; // Mode queries: mode is a packed u16 (low 15 bits = mode value, bit 15 = ANSI flag). ghostty_terminal_mode_get(terminal: TerminalHandle, mode: number, outBoolPtr: number): number; ghostty_terminal_mode_set(terminal: TerminalHandle, mode: number, value: boolean): number; @@ -702,6 +710,16 @@ export enum TerminalOption { KITTY_IMAGE_STORAGE_LIMIT = 15, } +/** + * Options for ghostty_sys_set(). Mirrors GhosttySysOption. + * Process-global / per-WASM-instance settings. + */ +export enum SysOption { + USERDATA = 0, + DECODE_PNG = 1, + LOG = 2, +} + /** * Keys for ghostty_kitty_graphics_get(). Mirrors GhosttyKittyGraphicsData. */ diff --git a/lib/write_pty_trampoline.ts b/lib/write_pty_trampoline.ts index fdd0210..882bc1e 100644 --- a/lib/write_pty_trampoline.ts +++ b/lib/write_pty_trampoline.ts @@ -2,8 +2,8 @@ * Tiny WASM trampolines that let us install JS callbacks into the main * libghostty-vt module's __indirect_function_table. * - * Why this exists: ghostty_terminal_set(OPT_*, fnPtr) takes a function - * pointer (a table index in WASM-land). To put a JS function at a given + * Why this exists: ghostty_terminal_set / ghostty_sys_set take function + * pointers (table indices in WASM-land). To put a JS function at a given * table index we'd normally use `new WebAssembly.Function(...)`, but * that's part of the Type Reflection proposal which only Chrome ships — * Bun and Node both report `typeof WebAssembly.Function === 'undefined'`. @@ -12,13 +12,17 @@ * callbacks (one per signature) and exports matching wrappers. Each * exported funcref is portable across modules with compatible funcref * tables, so we can add it to the main module's table and pass the - * index to terminal_set. + * index to terminal_set / sys_set. * * Currently bridged: - * WRITE_PTY: (terminal, userdata, data, len) -> void + * WRITE_PTY: (terminal, userdata, data, len) -> void * For DSR replies, in-band size reports, XTVERSION, etc. - * SIZE: (terminal, userdata, out_size) -> bool + * SIZE: (terminal, userdata, out_size) -> bool * For CSI 14/16/18 t (XTWINOPS) — embedder fills the out_size struct. + * DECODE_PNG: (userdata, allocator, data, data_len, out_image) -> bool + * For kitty graphics PNG payloads — decoder allocates RGBA via + * ghostty_alloc(allocator, len) and fills the 16-byte + * GhosttySysImage at out_image. * * The bytes below are the output of: * wat2wasm lib/write_pty_trampoline.wat -o /tmp/trampoline.wasm @@ -26,17 +30,22 @@ * Source is in write_pty_trampoline.wat — keep both in sync if you edit. */ const TRAMPOLINE_BYTES = new Uint8Array([ - 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x0f, 0x02, 0x60, + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x18, 0x03, 0x60, 0x04, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x60, 0x03, 0x7f, 0x7f, 0x7f, 0x01, - 0x7f, 0x02, 0x22, 0x02, 0x03, 0x65, 0x6e, 0x76, 0x0c, 0x77, 0x72, 0x69, - 0x74, 0x65, 0x5f, 0x70, 0x74, 0x79, 0x5f, 0x63, 0x62, 0x00, 0x00, 0x03, - 0x65, 0x6e, 0x76, 0x07, 0x73, 0x69, 0x7a, 0x65, 0x5f, 0x63, 0x62, 0x00, - 0x01, 0x03, 0x03, 0x02, 0x00, 0x01, 0x07, 0x1c, 0x02, 0x0d, 0x77, 0x72, - 0x69, 0x74, 0x65, 0x5f, 0x70, 0x74, 0x79, 0x5f, 0x66, 0x77, 0x64, 0x00, - 0x02, 0x08, 0x73, 0x69, 0x7a, 0x65, 0x5f, 0x66, 0x77, 0x64, 0x00, 0x03, - 0x0a, 0x19, 0x02, 0x0c, 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x20, - 0x03, 0x10, 0x00, 0x0b, 0x0a, 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, - 0x10, 0x01, 0x0b, + 0x7f, 0x60, 0x05, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x01, 0x7f, 0x02, 0x36, + 0x03, 0x03, 0x65, 0x6e, 0x76, 0x0c, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, + 0x70, 0x74, 0x79, 0x5f, 0x63, 0x62, 0x00, 0x00, 0x03, 0x65, 0x6e, 0x76, + 0x07, 0x73, 0x69, 0x7a, 0x65, 0x5f, 0x63, 0x62, 0x00, 0x01, 0x03, 0x65, + 0x6e, 0x76, 0x0d, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x5f, 0x70, 0x6e, + 0x67, 0x5f, 0x63, 0x62, 0x00, 0x02, 0x03, 0x04, 0x03, 0x00, 0x01, 0x02, + 0x07, 0x2d, 0x03, 0x0d, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x70, 0x74, + 0x79, 0x5f, 0x66, 0x77, 0x64, 0x00, 0x03, 0x08, 0x73, 0x69, 0x7a, 0x65, + 0x5f, 0x66, 0x77, 0x64, 0x00, 0x04, 0x0e, 0x64, 0x65, 0x63, 0x6f, 0x64, + 0x65, 0x5f, 0x70, 0x6e, 0x67, 0x5f, 0x66, 0x77, 0x64, 0x00, 0x05, 0x0a, + 0x28, 0x03, 0x0c, 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x20, 0x03, + 0x10, 0x00, 0x0b, 0x0a, 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x10, + 0x01, 0x0b, 0x0e, 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x20, 0x03, + 0x20, 0x04, 0x10, 0x02, 0x0b, ]); export type WritePtyCallback = ( @@ -58,28 +67,50 @@ export type SizeCallback = ( outSizePtr: number, ) => number; +/** + * DECODE_PNG callback: receives PNG bytes at dataPtr / dataLen, decodes + * to RGBA, allocates a buffer via ghostty_alloc(allocator, rgbaLen), + * fills the 16-byte GhosttySysImage at outImagePtr (u32 width @ 0, + * u32 height @ 4, u32 data_ptr @ 8, u32 data_len @ 12), and returns 1 + * on success or 0 to indicate decode failure. + */ +export type DecodePngCallback = ( + userdata: number, + allocator: number, + dataPtr: number, + dataLen: number, + outImagePtr: number, +) => number; + /** * Compile the trampoline once, then instantiate per-Ghostty with the JS - * callbacks as the `env.*_cb` imports. Returns both exported wrappers - * — funcrefs callable from any WASM module via call_indirect. + * callbacks as the `env.*_cb` imports. Returns all three exported + * wrappers — funcrefs callable from any WASM module via call_indirect. */ let compiled: WebAssembly.Module | null = null; export interface TrampolineExports { writePtyFwd: Function; sizeFwd: Function; + decodePngFwd: Function; } export function makeCallbackTrampolines( writePtyCb: WritePtyCallback, sizeCb: SizeCallback, + decodePngCb: DecodePngCallback, ): TrampolineExports { if (!compiled) compiled = new WebAssembly.Module(TRAMPOLINE_BYTES); const inst = new WebAssembly.Instance(compiled, { - env: { write_pty_cb: writePtyCb, size_cb: sizeCb }, + env: { + write_pty_cb: writePtyCb, + size_cb: sizeCb, + decode_png_cb: decodePngCb, + }, }); return { writePtyFwd: inst.exports.write_pty_fwd as Function, sizeFwd: inst.exports.size_fwd as Function, + decodePngFwd: inst.exports.decode_png_fwd as Function, }; } diff --git a/lib/write_pty_trampoline.wat b/lib/write_pty_trampoline.wat index 1150864..c3160f9 100644 --- a/lib/write_pty_trampoline.wat +++ b/lib/write_pty_trampoline.wat @@ -9,10 +9,15 @@ ;; where ghostty_terminal_set(OPT_*, idx) wires it up. ;; ;; Callbacks currently bridged: -;; WRITE_PTY: (terminal: i32, userdata: i32, data: i32, len: i32) -> nil +;; WRITE_PTY: (terminal: i32, userdata: i32, data: i32, len: i32) -> nil ;; Used for DSR replies, in-band size reports, etc. -;; SIZE: (terminal: i32, userdata: i32, out_size: i32) -> i32 (bool) +;; SIZE: (terminal: i32, userdata: i32, out_size: i32) -> i32 (bool) ;; Used for CSI 14/16/18 t responses; embedder fills out_size. +;; DECODE_PNG: (userdata: i32, allocator: i32, data: i32, data_len: i32, +;; out_image: i32) -> i32 (bool) +;; Used for kitty graphics PNG payloads. Decoder allocates RGBA via +;; ghostty_alloc(allocator, len) and fills out_image (16-byte struct +;; of u32 width, u32 height, u32 data_ptr, u32 data_len). ;; ;; Rebuild after edits: ;; wat2wasm lib/write_pty_trampoline.wat -o /tmp/trampoline.wasm @@ -20,9 +25,11 @@ (module (type $write_pty_sig (func (param i32 i32 i32 i32))) (type $size_sig (func (param i32 i32 i32) (result i32))) + (type $decode_png_sig (func (param i32 i32 i32 i32 i32) (result i32))) (import "env" "write_pty_cb" (func $write_pty_cb (type $write_pty_sig))) (import "env" "size_cb" (func $size_cb (type $size_sig))) + (import "env" "decode_png_cb" (func $decode_png_cb (type $decode_png_sig))) (func $write_pty_fwd (export "write_pty_fwd") (type $write_pty_sig) local.get 0 local.get 1 local.get 2 local.get 3 @@ -30,4 +37,8 @@ (func $size_fwd (export "size_fwd") (type $size_sig) local.get 0 local.get 1 local.get 2 - call $size_cb)) + call $size_cb) + + (func $decode_png_fwd (export "decode_png_fwd") (type $decode_png_sig) + local.get 0 local.get 1 local.get 2 local.get 3 local.get 4 + call $decode_png_cb)) diff --git a/package.json b/package.json index 0b93cab..42cbd7c 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,9 @@ "lint:fix": "biome check --write .", "prepublishOnly": "bun run build" }, + "dependencies": { + "fast-png": "^7.0.0" + }, "devDependencies": { "@biomejs/biome": "^1.9.4", "@happy-dom/global-registrator": "^15.11.0", From 3b4a9abfac42dcf0abc72ab2020733239d4d4cd3 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 29 Apr 2026 14:08:17 -0400 Subject: [PATCH 16/29] Substitute U+10EEEE cells with kitty graphics image slices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/ghostty.ts | 16 ++ lib/kitty_diacritics.ts | 334 ++++++++++++++++++++++++++++++++++++++++ lib/renderer.ts | 154 ++++++++++++++++++ lib/types.ts | 7 + 4 files changed, 511 insertions(+) create mode 100644 lib/kitty_diacritics.ts diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 8788a77..6f3e413 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -814,6 +814,17 @@ export class GhosttyTerminal { ); if (r2 !== 0) continue; + // Fetch is_virtual via a separate placement_get — it isn't + // in the PlacementRenderInfo struct (which assumes a real + // viewport position). + this.exports.ghostty_kitty_graphics_placement_get( + iter, + KittyGraphicsPlacementData.IS_VIRTUAL, + idPtr // reuse the 4-byte slot; the value is a bool but written as u8 + ); + const isVirtual = + new DataView(this.memory.buffer).getUint8(idPtr) !== 0; + const v = new DataView(this.memory.buffer); const info: KittyPlacementInfo = { imageId, @@ -828,7 +839,12 @@ export class GhosttyTerminal { sourceY: v.getUint32(infoPtr + 36, true), sourceWidth: v.getUint32(infoPtr + 40, true), sourceHeight: v.getUint32(infoPtr + 44, true), + isVirtual, }; + // onlyVisible filter: keep only visible direct placements. Virtual + // placements don't have a viewport position so viewportVisible + // is always false; callers walking unicode-placeholder grids + // pass onlyVisible=false to receive them. if (onlyVisible && !info.viewportVisible) continue; yield info; } diff --git a/lib/kitty_diacritics.ts b/lib/kitty_diacritics.ts new file mode 100644 index 0000000..e11e75c --- /dev/null +++ b/lib/kitty_diacritics.ts @@ -0,0 +1,334 @@ +/** + * Combining diacritics used by the kitty graphics protocol to encode + * row / column positions inside Unicode placeholder cells. + * + * Each diacritic codepoint here represents an integer equal to its + * 0-based index in this list. So U+0305 = 0, U+030D = 1, U+030E = 2, + * and so on through 296. A placeholder cell stacks combining marks on + * U+10EEEE; the first encodes the row index, the second encodes the + * column index, and an optional third encodes the high byte of the + * image id (since the foreground color only carries 24 bits and image + * ids can be 32 bits wide). + * + * Source-of-truth: kovidgoyal/kitty:gen/rowcolumn-diacritics.txt + * (Unicode 6.0.0 combining chars of class 230 that don't precompose; + * see kitty's docs for the full derivation rationale). + */ +export const ROWCOLUMN_DIACRITICS: readonly number[] = [ + 0x0305, + 0x030D, + 0x030E, + 0x0310, + 0x0312, + 0x033D, + 0x033E, + 0x033F, + 0x0346, + 0x034A, + 0x034B, + 0x034C, + 0x0350, + 0x0351, + 0x0352, + 0x0357, + 0x035B, + 0x0363, + 0x0364, + 0x0365, + 0x0366, + 0x0367, + 0x0368, + 0x0369, + 0x036A, + 0x036B, + 0x036C, + 0x036D, + 0x036E, + 0x036F, + 0x0483, + 0x0484, + 0x0485, + 0x0486, + 0x0487, + 0x0592, + 0x0593, + 0x0594, + 0x0595, + 0x0597, + 0x0598, + 0x0599, + 0x059C, + 0x059D, + 0x059E, + 0x059F, + 0x05A0, + 0x05A1, + 0x05A8, + 0x05A9, + 0x05AB, + 0x05AC, + 0x05AF, + 0x05C4, + 0x0610, + 0x0611, + 0x0612, + 0x0613, + 0x0614, + 0x0615, + 0x0616, + 0x0617, + 0x0657, + 0x0658, + 0x0659, + 0x065A, + 0x065B, + 0x065D, + 0x065E, + 0x06D6, + 0x06D7, + 0x06D8, + 0x06D9, + 0x06DA, + 0x06DB, + 0x06DC, + 0x06DF, + 0x06E0, + 0x06E1, + 0x06E2, + 0x06E4, + 0x06E7, + 0x06E8, + 0x06EB, + 0x06EC, + 0x0730, + 0x0732, + 0x0733, + 0x0735, + 0x0736, + 0x073A, + 0x073D, + 0x073F, + 0x0740, + 0x0741, + 0x0743, + 0x0745, + 0x0747, + 0x0749, + 0x074A, + 0x07EB, + 0x07EC, + 0x07ED, + 0x07EE, + 0x07EF, + 0x07F0, + 0x07F1, + 0x07F3, + 0x0816, + 0x0817, + 0x0818, + 0x0819, + 0x081B, + 0x081C, + 0x081D, + 0x081E, + 0x081F, + 0x0820, + 0x0821, + 0x0822, + 0x0823, + 0x0825, + 0x0826, + 0x0827, + 0x0829, + 0x082A, + 0x082B, + 0x082C, + 0x082D, + 0x0951, + 0x0953, + 0x0954, + 0x0F82, + 0x0F83, + 0x0F86, + 0x0F87, + 0x135D, + 0x135E, + 0x135F, + 0x17DD, + 0x193A, + 0x1A17, + 0x1A75, + 0x1A76, + 0x1A77, + 0x1A78, + 0x1A79, + 0x1A7A, + 0x1A7B, + 0x1A7C, + 0x1B6B, + 0x1B6D, + 0x1B6E, + 0x1B6F, + 0x1B70, + 0x1B71, + 0x1B72, + 0x1B73, + 0x1CD0, + 0x1CD1, + 0x1CD2, + 0x1CDA, + 0x1CDB, + 0x1CE0, + 0x1DC0, + 0x1DC1, + 0x1DC3, + 0x1DC4, + 0x1DC5, + 0x1DC6, + 0x1DC7, + 0x1DC8, + 0x1DC9, + 0x1DCB, + 0x1DCC, + 0x1DD1, + 0x1DD2, + 0x1DD3, + 0x1DD4, + 0x1DD5, + 0x1DD6, + 0x1DD7, + 0x1DD8, + 0x1DD9, + 0x1DDA, + 0x1DDB, + 0x1DDC, + 0x1DDD, + 0x1DDE, + 0x1DDF, + 0x1DE0, + 0x1DE1, + 0x1DE2, + 0x1DE3, + 0x1DE4, + 0x1DE5, + 0x1DE6, + 0x1DFE, + 0x20D0, + 0x20D1, + 0x20D4, + 0x20D5, + 0x20D6, + 0x20D7, + 0x20DB, + 0x20DC, + 0x20E1, + 0x20E7, + 0x20E9, + 0x20F0, + 0x2CEF, + 0x2CF0, + 0x2CF1, + 0x2DE0, + 0x2DE1, + 0x2DE2, + 0x2DE3, + 0x2DE4, + 0x2DE5, + 0x2DE6, + 0x2DE7, + 0x2DE8, + 0x2DE9, + 0x2DEA, + 0x2DEB, + 0x2DEC, + 0x2DED, + 0x2DEE, + 0x2DEF, + 0x2DF0, + 0x2DF1, + 0x2DF2, + 0x2DF3, + 0x2DF4, + 0x2DF5, + 0x2DF6, + 0x2DF7, + 0x2DF8, + 0x2DF9, + 0x2DFA, + 0x2DFB, + 0x2DFC, + 0x2DFD, + 0x2DFE, + 0x2DFF, + 0xA66F, + 0xA67C, + 0xA67D, + 0xA6F0, + 0xA6F1, + 0xA8E0, + 0xA8E1, + 0xA8E2, + 0xA8E3, + 0xA8E4, + 0xA8E5, + 0xA8E6, + 0xA8E7, + 0xA8E8, + 0xA8E9, + 0xA8EA, + 0xA8EB, + 0xA8EC, + 0xA8ED, + 0xA8EE, + 0xA8EF, + 0xA8F0, + 0xA8F1, + 0xAAB0, + 0xAAB2, + 0xAAB3, + 0xAAB7, + 0xAAB8, + 0xAABE, + 0xAABF, + 0xAAC1, + 0xFE20, + 0xFE21, + 0xFE22, + 0xFE23, + 0xFE24, + 0xFE25, + 0xFE26, + 0x10A0F, + 0x10A38, + 0x1D185, + 0x1D186, + 0x1D187, + 0x1D188, + 0x1D189, + 0x1D1AA, + 0x1D1AB, + 0x1D1AC, + 0x1D1AD, + 0x1D242, + 0x1D243, + 0x1D244, +]; + +/** + * Reverse lookup: codepoint → integer index. Built once at module load. + * Returns -1 for codepoints that aren't valid kitty diacritics. + */ +const DIACRITIC_INDEX = new Map( + ROWCOLUMN_DIACRITICS.map((cp, i) => [cp, i]), +); + +export function diacriticToInt(cp: number): number { + return DIACRITIC_INDEX.get(cp) ?? -1; +} + +/** + * Unicode codepoint for the kitty graphics placeholder cell. + * Cells with this codepoint are substituted with an image slice at + * render time rather than rendered as text. + */ +export const KITTY_PLACEHOLDER = 0x10eeee; diff --git a/lib/renderer.ts b/lib/renderer.ts index 92fc056..b303422 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -11,6 +11,7 @@ */ import type { ITheme } from './interfaces'; +import { diacriticToInt, KITTY_PLACEHOLDER } from './kitty_diacritics'; import type { SelectionManager } from './selection-manager'; import type { GhosttyCell, ILink, KittyImagePixels, KittyPlacementInfo } from './types'; import { CellFlags, KittyImageFormat } from './types'; @@ -37,6 +38,13 @@ export interface IRenderable { getKittyGraphics?(): number | null; iterPlacements?(graphics: number, onlyVisible?: boolean): Iterable; getKittyImagePixels?(graphics: number, imageId: number): KittyImagePixels | null; + /** + * Returns the full codepoint sequence for the cell at (row, col) in + * the active screen — the base codepoint followed by any combining + * marks. Used to decode unicode-placeholder cells (U+10EEEE plus + * combining diacritics that encode row/column slice positions). + */ + getGrapheme?(row: number, col: number): number[] | null; } export interface IScrollbackProvider { @@ -133,6 +141,22 @@ export class CanvasRenderer { { canvas: HTMLCanvasElement; dataLen: number } >(); + /** + * Per-frame index of virtual placements keyed by image id. Populated + * once at the start of each render() pass (cheap — typically zero or + * a handful of entries). Looked up by U+10EEEE placeholder cells in + * renderPlaceholderCell to find the placement's grid dimensions. + */ + private kittyVirtualPlacements = new Map(); + + /** + * Cached IRenderable on the current render() call so renderCellText + * can call into it (e.g. getGrapheme) without us threading the buffer + * through every helper. Set at the top of render(), cleared at the end. + */ + private currentRenderBuffer: IRenderable | null = null; + private currentKittyGraphics: number | null = null; + // Selection manager (for rendering selection) private selectionManager?: SelectionManager; // Cached selection coordinates for current render pass (viewport-relative) @@ -292,6 +316,13 @@ export class CanvasRenderer { ): void { // Store buffer reference for grapheme lookups in renderCell this.currentBuffer = buffer; + // Pre-frame: build the virtual-placement index so unicode-placeholder + // cells can look up their target image's grid layout in O(1) during + // the per-cell text pass. Also stash buffer + graphics handle for + // renderPlaceholderCell, which needs access to getGrapheme + + // getKittyImagePixels. + this.precomputeKittyState(buffer); + this.currentRenderBuffer = buffer; // getCursor() calls update() internally to ensure fresh state. // Multiple update() calls are safe - dirty state persists until clearDirty(). @@ -620,6 +651,16 @@ export class CanvasRenderer { const cellY = y * this.metrics.height; const cellWidth = this.metrics.width * cell.width; + // Kitty unicode placeholder: cells with codepoint U+10EEEE represent + // a slice of a virtually-placed image. Substitute the slice draw for + // text rendering. If it's not a valid placeholder (e.g., the image + // hasn't been transmitted yet), fall through and render as text — + // typically the system "missing glyph" box, which is the expected + // behavior for a stray U+10EEEE. + if (cell.codepoint === KITTY_PLACEHOLDER) { + if (this.renderPlaceholderCell(cell, x, y)) return; + } + // Skip rendering if invisible if (cell.flags & CellFlags.INVISIBLE) { return; @@ -751,6 +792,119 @@ export class CanvasRenderer { * Cheap when no graphics are active (one method check, one terminal_get). * Decode work is amortized across frames via kittyImageCache. */ + /** + * Walk the placement iterator once at frame start, partitioning the + * results: virtual placements go into kittyVirtualPlacements (keyed + * by image id) for placeholder-cell lookup; direct visible placements + * stay implicit and get re-iterated by renderKittyImages later. + * + * Also caches the storage handle for renderPlaceholderCell so the + * per-cell hot path doesn't have to re-resolve it. + */ + private precomputeKittyState(buffer: IRenderable): void { + this.kittyVirtualPlacements.clear(); + this.currentKittyGraphics = null; + if (!buffer.getKittyGraphics || !buffer.iterPlacements) return; + const graphics = buffer.getKittyGraphics(); + if (graphics === null) return; + this.currentKittyGraphics = graphics; + // onlyVisible=false so we receive virtual placements too. Direct + // placements are still iterated (and cached implicitly via + // renderKittyImages); we just collect virtuals here. + for (const p of buffer.iterPlacements(graphics, false)) { + if (p.isVirtual) { + this.kittyVirtualPlacements.set(p.imageId, p); + } + } + } + + /** + * Get (or decode + cache) the canvas-ready bitmap for a kitty image. + * Returns null if the image isn't stored or decode fails. Shared by + * renderKittyImages (direct placements) and renderPlaceholderCell + * (unicode-placeholder cells). + */ + private getOrDecodeKittyImage( + buffer: IRenderable, + graphics: number, + imageId: number, + ): HTMLCanvasElement | null { + const cached = this.kittyImageCache.get(imageId); + const pixels = buffer.getKittyImagePixels?.(graphics, imageId); + if (!pixels) return cached?.canvas ?? null; + if (cached && cached.dataLen === pixels.data.length) return cached.canvas; + const canvas = this.decodeKittyImageToCanvas(pixels); + if (!canvas) return null; + this.kittyImageCache.set(imageId, { canvas, dataLen: pixels.data.length }); + return canvas; + } + + /** + * Substitute a cell's text rendering with a slice of a kitty graphics + * image. Called from renderCellText when the cell's codepoint is + * U+10EEEE. + * + * Decodes the image_id from cell.fg_* (low 24 bits; high byte from + * an optional third combining diacritic) and the row/col-of-image + * from the first two combining diacritics on the cell. Looks up the + * virtual placement (from precomputeKittyState) for grid dims, then + * draws the matching slice scaled to one terminal cell. + * + * Returns true if the cell was handled as a placeholder; false to + * fall through to normal text rendering (e.g., unknown image, no + * matching virtual placement, or malformed diacritics). + */ + private renderPlaceholderCell(cell: GhosttyCell, x: number, y: number): boolean { + const buffer = this.currentRenderBuffer; + const graphics = this.currentKittyGraphics; + if (!buffer || graphics === null || !buffer.getGrapheme) return false; + + // Image id from fg color (low 24 bits) + optional 3rd diacritic + // (high byte). The base codepoint at index 0 is U+10EEEE itself; + // [1]=row, [2]=col, [3]=image_id_msb (optional). + const codepoints = buffer.getGrapheme(y, x); + if (!codepoints || codepoints.length < 3) return false; + const rowD = diacriticToInt(codepoints[1]!); + const colD = diacriticToInt(codepoints[2]!); + if (rowD < 0 || colD < 0) return false; + const fgRgb = (cell.fg_r << 16) | (cell.fg_g << 8) | cell.fg_b; + let imageId = fgRgb; + if (codepoints.length >= 4) { + const msb = diacriticToInt(codepoints[3]!); + if (msb >= 0) imageId = (msb << 24) | fgRgb; + } + + const placement = this.kittyVirtualPlacements.get(imageId); + if (!placement) return false; + + const pixels = buffer.getKittyImagePixels?.(graphics, imageId); + if (!pixels) return false; + const canvas = this.getOrDecodeKittyImage(buffer, graphics, imageId); + if (!canvas) return false; + + // Slice geometry: image is conceptually scaled to fit + // gridCols × gridRows cells; this cell shows one of those cells. + const srcW = pixels.width / placement.gridCols; + const srcH = pixels.height / placement.gridRows; + const srcX = colD * srcW; + const srcY = rowD * srcH; + const destX = x * this.metrics.width; + const destY = y * this.metrics.height; + + this.ctx.drawImage( + canvas, + srcX, + srcY, + srcW, + srcH, + destX, + destY, + this.metrics.width, + this.metrics.height, + ); + return true; + } + private renderKittyImages(buffer: IRenderable): void { if (!buffer.getKittyGraphics || !buffer.iterPlacements || !buffer.getKittyImagePixels) { return; diff --git a/lib/types.ts b/lib/types.ts index 68c449d..9aca49f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -839,6 +839,13 @@ export interface KittyPlacementInfo { sourceY: number; sourceWidth: number; sourceHeight: number; + /** + * Virtual placements have no fixed viewport position; their image is + * drawn into U+10EEEE placeholder cells written to the grid by the + * application. The renderer picks them up by image_id rather than + * iterating through them for direct compositing. + */ + isVirtual: boolean; } /** Size in bytes of GhosttyKittyGraphicsPlacementRenderInfo on wasm32. */ From 074b2a13800a7340d6db34dcdb3f32c8624bdb75 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 29 Apr 2026 14:44:52 -0400 Subject: [PATCH 17/29] =?UTF-8?q?Restore=20scrollback=20cell=20styling=20?= =?UTF-8?q?=E2=80=94=20fg/bg/flags/width?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/ghostty.ts | 121 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 6f3e413..35311a8 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -1578,9 +1578,32 @@ export class GhosttyTerminal { return null; } + // Pre-fetch the terminal's effective palette (256 RGB triples = + // 768 bytes) so we can resolve PALETTE-tagged style colors per + // cell without a round-trip per resolution. Cells with style + // colors of tag NONE leave fg_r/g/b at 0; the renderer's + // isDefaultFg path treats that as "use theme default." + const PAL_SIZE = 768; + const palettePtr = this.exports.ghostty_wasm_alloc_u8_array(PAL_SIZE); + const palOk = + this.exports.ghostty_terminal_get( + this.handle, + TerminalData.COLOR_PALETTE, + palettePtr + ) === 0; + const palette = palOk + ? new Uint8Array(this.memory.buffer, palettePtr, PAL_SIZE).slice() + : null; + const cells: GhosttyCell[] = new Array(this._cols); const cellPtr = this.exports.ghostty_wasm_alloc_u8_array(8); const u32Ptr = this.exports.ghostty_wasm_alloc_u8_array(4); + const widePtr = this.exports.ghostty_wasm_alloc_u8_array(4); + // Style is the 72-byte GhosttyStyle sized struct. Initialize the + // size discriminator once; the populator overwrites the rest. + const STYLE_SIZE = 72; + const stylePtr = this.exports.ghostty_wasm_alloc_u8_array(STYLE_SIZE); + new DataView(this.memory.buffer).setUint32(stylePtr, STYLE_SIZE, true); try { for (let col = 0; col < this._cols; col++) { // Step along the row by mutating ref.x in place. @@ -1589,14 +1612,69 @@ export class GhosttyTerminal { cells[col] = this.makeEmptyCell(); continue; } - const cellU64 = new DataView(this.memory.buffer).getBigUint64(cellPtr, true); + const memView = new DataView(this.memory.buffer); + const cellU64 = memView.getBigUint64(cellPtr, true); + + // Codepoint. this.exports.ghostty_cell_get(cellU64, CellData.CODEPOINT, u32Ptr); const cp = new DataView(this.memory.buffer).getUint32(u32Ptr, true); - cells[col] = { ...this.makeEmptyCell(), codepoint: cp }; + + // Width: same NARROW/WIDE/SPACER mapping as getViewport. + this.exports.ghostty_cell_get(cellU64, CellData.WIDE, widePtr); + const wide = new DataView(this.memory.buffer).getUint32(widePtr, true); + const width = + wide === CellWide.WIDE + ? 2 + : wide === CellWide.SPACER_TAIL || wide === CellWide.SPACER_HEAD + ? 0 + : 1; + + // Style: per-position via grid_ref_style (not via cell — + // styles aren't stored in the cell value, they're attached + // to the row's pin position). + new DataView(this.memory.buffer).setUint32( + stylePtr, + STYLE_SIZE, + true + ); + const styleOk = + this.exports.ghostty_grid_ref_style(refPtr, stylePtr) === 0; + + const cell = this.makeEmptyCell(); + cell.codepoint = cp; + cell.width = width; + + if (styleOk) { + const u8 = new Uint8Array(this.memory.buffer, stylePtr, STYLE_SIZE); + const v = new DataView(this.memory.buffer); + // Flag bytes 56..63; underline (i32) at 64. + let f = 0; + if (u8[56]) f |= CellFlags.BOLD; + if (u8[57]) f |= CellFlags.ITALIC; + if (u8[58]) f |= CellFlags.FAINT; + if (u8[59]) f |= CellFlags.BLINK; + if (u8[60]) f |= CellFlags.INVERSE; + if (u8[61]) f |= CellFlags.INVISIBLE; + if (u8[62]) f |= CellFlags.STRIKETHROUGH; + if (v.getInt32(stylePtr + 64, true) !== 0) f |= CellFlags.UNDERLINE; + cell.flags = f; + + // fg_color at offset 8, bg_color at offset 24. + // Each is 16 bytes: tag@0:u32, padding to 8, value@8:union. + // Value union: palette index at first byte; or rgb (r,g,b) + // in first 3 bytes; or u64 padding for ABI stability. + this.resolveStyleColor(stylePtr + 8, palette, cell, /*isFg=*/ true); + this.resolveStyleColor(stylePtr + 24, palette, cell, /*isFg=*/ false); + } + + cells[col] = cell; } } finally { this.exports.ghostty_wasm_free_u8_array(cellPtr, 8); this.exports.ghostty_wasm_free_u8_array(u32Ptr, 4); + this.exports.ghostty_wasm_free_u8_array(widePtr, 4); + this.exports.ghostty_wasm_free_u8_array(stylePtr, STYLE_SIZE); + this.exports.ghostty_wasm_free_u8_array(palettePtr, PAL_SIZE); } return cells; } finally { @@ -1605,6 +1683,45 @@ export class GhosttyTerminal { } } + /** + * Decode a GhosttyStyleColor (16 bytes at colorPtr — tag@0:u32, + * value@8:union) and write the resolved RGB into the cell's fg_* + * or bg_* triple. Tag values: NONE=0 (leaves zeros so the renderer's + * theme fallback kicks in), PALETTE=1 (looks up the terminal's + * effective palette), RGB=2 (direct read). + */ + private resolveStyleColor( + colorPtr: number, + palette: Uint8Array | null, + cell: GhosttyCell, + isFg: boolean + ): void { + const view = new DataView(this.memory.buffer); + const tag = view.getUint32(colorPtr + 0, true); + let r = 0; + let g = 0; + let b = 0; + if (tag === 1 /* PALETTE */ && palette) { + const idx = view.getUint8(colorPtr + 8); + r = palette[idx * 3 + 0]!; + g = palette[idx * 3 + 1]!; + b = palette[idx * 3 + 2]!; + } else if (tag === 2 /* RGB */) { + r = view.getUint8(colorPtr + 8); + g = view.getUint8(colorPtr + 9); + b = view.getUint8(colorPtr + 10); + } + if (isFg) { + cell.fg_r = r; + cell.fg_g = g; + cell.fg_b = b; + } else { + cell.bg_r = r; + cell.bg_g = g; + cell.bg_b = b; + } + } + private readHyperlinkUri(tag: PointTag, y: number, col: number): string | null { const pointPtr = this.allocPoint(tag, col, y); const refPtr = this.exports.ghostty_wasm_alloc_u8_array(12); From 642b936abda122a339d2e283919ff21dac2e0dbe Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 29 Apr 2026 14:53:16 -0400 Subject: [PATCH 18/29] Encoding choice: 0/1, not a unique id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- lib/ghostty.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 35311a8..16e0b85 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -1385,8 +1385,17 @@ export class GhosttyTerminal { ? 0 : 1; - // TODO: hyperlink_id from RAW + cell_get(HAS_HYPERLINK). - cell.hyperlink_id = 0; + // OSC 8 hyperlink presence. Coder's old packed cell struct + // exposed this as effectively a 0/1 boolean (despite the + // u16-sized field) — the renderer compares + // hyperlink_id === hoveredId to mean "this cell is part of + // some hyperlink, same as the hovered one" rather than + // "the *same* hyperlink instance," with link-detector + // identifying actual links via URI + position range. We + // preserve that contract here. + this.exports.ghostty_cell_get(cellU64, CellData.HAS_HYPERLINK, widePtr); + cell.hyperlink_id = + new DataView(this.memory.buffer).getUint8(widePtr) !== 0 ? 1 : 0; col++; } @@ -1629,6 +1638,13 @@ export class GhosttyTerminal { ? 0 : 1; + // Hyperlink presence as 0/1 — same approximation getViewport + // uses (link-detector identifies actual links by URI + + // position range; the renderer just needs the indicator). + this.exports.ghostty_cell_get(cellU64, CellData.HAS_HYPERLINK, widePtr); + const hasHyperlink = + new DataView(this.memory.buffer).getUint8(widePtr) !== 0; + // Style: per-position via grid_ref_style (not via cell — // styles aren't stored in the cell value, they're attached // to the row's pin position). @@ -1643,6 +1659,7 @@ export class GhosttyTerminal { const cell = this.makeEmptyCell(); cell.codepoint = cp; cell.width = width; + cell.hyperlink_id = hasHyperlink ? 1 : 0; if (styleOk) { const u8 = new Uint8Array(this.memory.buffer, stylePtr, STYLE_SIZE); From d3714d12fa3afff60f76c3411da70b0ced490b9e Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 29 Apr 2026 15:40:11 -0400 Subject: [PATCH 19/29] fix: Replace banned `Function` types in trampoline exports Signed-off-by: Evan Wies --- lib/write_pty_trampoline.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/write_pty_trampoline.ts b/lib/write_pty_trampoline.ts index 882bc1e..d230194 100644 --- a/lib/write_pty_trampoline.ts +++ b/lib/write_pty_trampoline.ts @@ -90,9 +90,13 @@ export type DecodePngCallback = ( let compiled: WebAssembly.Module | null = null; export interface TrampolineExports { - writePtyFwd: Function; - sizeFwd: Function; - decodePngFwd: Function; + // Funcrefs for installation into the main module's + // __indirect_function_table. Their JS-side type matches their + // corresponding callback signatures since the trampoline body just + // forwards arguments through. + writePtyFwd: WritePtyCallback; + sizeFwd: SizeCallback; + decodePngFwd: DecodePngCallback; } export function makeCallbackTrampolines( @@ -109,8 +113,8 @@ export function makeCallbackTrampolines( }, }); return { - writePtyFwd: inst.exports.write_pty_fwd as Function, - sizeFwd: inst.exports.size_fwd as Function, - decodePngFwd: inst.exports.decode_png_fwd as Function, + writePtyFwd: inst.exports.write_pty_fwd as unknown as WritePtyCallback, + sizeFwd: inst.exports.size_fwd as unknown as SizeCallback, + decodePngFwd: inst.exports.decode_png_fwd as unknown as DecodePngCallback, }; } From 6d00a1d496a7c15968edcce4b1b45d217ff50f07 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 29 Apr 2026 15:46:37 -0400 Subject: [PATCH 20/29] Damage-track kitty placements + plumb pixel dims in npx demo 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 --- demo/bin/demo.js | 29 ++++++++- lib/renderer.ts | 154 +++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 156 insertions(+), 27 deletions(-) diff --git a/demo/bin/demo.js b/demo/bin/demo.js index 5255f1d..06975f0 100644 --- a/demo/bin/demo.js +++ b/demo/bin/demo.js @@ -248,12 +248,32 @@ const HTML_TEMPLATE = ` const wsUrl = protocol + '//' + window.location.host + '/ws?cols=' + term.cols + '&rows=' + term.rows; let ws; + // Read total canvas pixel dims (CSS pixels). The server stuffs these + // into ws_xpixel / ws_ypixel via node-pty's resize(cols, rows, pixelSize) + // so kittens like icat see non-zero TIOCGWINSZ pixel fields. + function getPixelSize() { + const canvas = container.querySelector('canvas'); + return canvas + ? { xpixel: canvas.clientWidth, ypixel: canvas.clientHeight } + : { xpixel: 0, ypixel: 0 }; + } + function connect() { setStatus('connecting', 'Connecting...'); ws = new WebSocket(wsUrl); ws.onopen = () => { setStatus('connected', 'Connected'); + // Push initial pixel dims so TIOCGWINSZ-gated tools see them + // before the first resize event. + const px = getPixelSize(); + ws.send(JSON.stringify({ + type: 'resize', + cols: term.cols, + rows: term.rows, + xpixel: px.xpixel, + ypixel: px.ypixel, + })); }; ws.onmessage = (event) => { @@ -283,7 +303,14 @@ const HTML_TEMPLATE = ` // Handle resize - notify PTY when terminal dimensions change term.onResize(({ cols, rows }) => { if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'resize', cols, rows })); + const px = getPixelSize(); + ws.send(JSON.stringify({ + type: 'resize', + cols, + rows, + xpixel: px.xpixel, + ypixel: px.ypixel, + })); } }); diff --git a/lib/renderer.ts b/lib/renderer.ts index b303422..e9f258f 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -149,6 +149,43 @@ export class CanvasRenderer { */ private kittyVirtualPlacements = new Map(); + /** + * Direct (non-virtual) placements that need compositing this frame. + * Built once per render() in precomputeKittyState so renderKittyImages + * doesn't re-walk the iterator. Empty when no kitty graphics are active. + */ + private currentDirectPlacements: KittyPlacementInfo[] = []; + + /** + * Last frame's direct-placement signatures, keyed by image id. Used to + * detect placement add/remove/move/redecode so we can mark the affected + * rows for repaint (clearing stale image pixels) and skip the composite + * pass entirely when nothing has changed. dataLen is the same staleness + * discriminator used by kittyImageCache. + */ + private lastKittyDirectSigs = new Map< + number, + { + viewportCol: number; + viewportRow: number; + pixelWidth: number; + pixelHeight: number; + sourceX: number; + sourceY: number; + sourceWidth: number; + sourceHeight: number; + dataLen: number; + } + >(); + + /** + * Rows whose image footprint changed since last frame (placement added, + * removed, moved, resized, or re-decoded under the same id). Added to + * rowsToRender so the underlying text repaints — which clears stale + * image pixels — before we composite the current placements on top. + */ + private kittyDamagedRows = new Set(); + /** * Cached IRenderable on the current render() call so renderCellText * can call into it (e.g. getGrapheme) without us threading the buffer @@ -316,18 +353,20 @@ export class CanvasRenderer { ): void { // Store buffer reference for grapheme lookups in renderCell this.currentBuffer = buffer; - // Pre-frame: build the virtual-placement index so unicode-placeholder - // cells can look up their target image's grid layout in O(1) during - // the per-cell text pass. Also stash buffer + graphics handle for - // renderPlaceholderCell, which needs access to getGrapheme + - // getKittyImagePixels. - this.precomputeKittyState(buffer); this.currentRenderBuffer = buffer; // getCursor() calls update() internally to ensure fresh state. // Multiple update() calls are safe - dirty state persists until clearDirty(). const cursor = buffer.getCursor(); const dims = buffer.getDimensions(); + + // Pre-frame: build the virtual-placement index so unicode-placeholder + // cells can look up their target image's grid layout in O(1) during + // the per-cell text pass. Also collects direct placements + computes + // kittyDamagedRows (rows where a placement was added/removed/moved/ + // re-decoded, so the text underneath needs repainting to clear stale + // image pixels). + this.precomputeKittyState(buffer, dims.rows); const scrollbackLength = scrollbackProvider ? scrollbackProvider.getScrollbackLength() : 0; // Check if buffer needs full redraw (e.g., screen change between normal/alternate) @@ -481,7 +520,11 @@ export class CanvasRenderer { const needsRender = viewportY > 0 ? true - : forceAll || buffer.isRowDirty(y) || selectionRows.has(y) || hyperlinkRows.has(y); + : forceAll || + buffer.isRowDirty(y) || + selectionRows.has(y) || + hyperlinkRows.has(y) || + this.kittyDamagedRows.has(y); if (needsRender) { rowsToRender.add(y); @@ -538,7 +581,16 @@ export class CanvasRenderer { // first, so there's nothing meaningful underneath. A future commit can // split into below/above-text passes via PlacementLayer if real apps // need it. - this.renderKittyImages(buffer); + // + // Skip when no rows were repainted: the previous frame's image pixels + // are still on the canvas and unchanged, and re-issuing drawImage with + // source-over compositing onto translucent images would accumulate + // alpha. Placement adds/removes/moves seed kittyDamagedRows in + // precomputeKittyState, which forces those rows into rowsToRender and + // flips anyLinesRendered to true. + if (this.currentDirectPlacements.length > 0 && anyLinesRendered) { + this.renderKittyImages(); + } // Render cursor (only if we're at the bottom, not scrolled) if (viewportY === 0 && cursor.visible && this.cursorVisible) { @@ -801,21 +853,73 @@ export class CanvasRenderer { * Also caches the storage handle for renderPlaceholderCell so the * per-cell hot path doesn't have to re-resolve it. */ - private precomputeKittyState(buffer: IRenderable): void { + private precomputeKittyState(buffer: IRenderable, dimsRows: number): void { this.kittyVirtualPlacements.clear(); + this.currentDirectPlacements = []; + this.kittyDamagedRows.clear(); this.currentKittyGraphics = null; - if (!buffer.getKittyGraphics || !buffer.iterPlacements) return; - const graphics = buffer.getKittyGraphics(); - if (graphics === null) return; - this.currentKittyGraphics = graphics; - // onlyVisible=false so we receive virtual placements too. Direct - // placements are still iterated (and cached implicitly via - // renderKittyImages); we just collect virtuals here. - for (const p of buffer.iterPlacements(graphics, false)) { - if (p.isVirtual) { - this.kittyVirtualPlacements.set(p.imageId, p); + + const newSigs: typeof this.lastKittyDirectSigs = new Map(); + const cellH = this.metrics.height; + const markRows = (viewportRow: number, pixelHeight: number): void => { + const rowStart = Math.max(0, Math.floor(viewportRow)); + const rowEnd = Math.min(dimsRows, Math.ceil(viewportRow + pixelHeight / cellH)); + for (let r = rowStart; r < rowEnd; r++) this.kittyDamagedRows.add(r); + }; + + if (buffer.getKittyGraphics && buffer.iterPlacements) { + const graphics = buffer.getKittyGraphics(); + if (graphics !== null) { + this.currentKittyGraphics = graphics; + // onlyVisible=false so virtual placements come through too. We + // partition: virtuals into kittyVirtualPlacements (placeholder-cell + // lookup), directs into currentDirectPlacements (composite pass). + for (const p of buffer.iterPlacements(graphics, false)) { + if (p.isVirtual) { + this.kittyVirtualPlacements.set(p.imageId, p); + continue; + } + this.currentDirectPlacements.push(p); + const pixels = buffer.getKittyImagePixels?.(graphics, p.imageId); + const dataLen = pixels?.data.length ?? 0; + const sig = { + viewportCol: p.viewportCol, + viewportRow: p.viewportRow, + pixelWidth: p.pixelWidth, + pixelHeight: p.pixelHeight, + sourceX: p.sourceX, + sourceY: p.sourceY, + sourceWidth: p.sourceWidth, + sourceHeight: p.sourceHeight, + dataLen, + }; + newSigs.set(p.imageId, sig); + const prev = this.lastKittyDirectSigs.get(p.imageId); + const changed = + !prev || + prev.viewportCol !== sig.viewportCol || + prev.viewportRow !== sig.viewportRow || + prev.pixelWidth !== sig.pixelWidth || + prev.pixelHeight !== sig.pixelHeight || + prev.sourceX !== sig.sourceX || + prev.sourceY !== sig.sourceY || + prev.sourceWidth !== sig.sourceWidth || + prev.sourceHeight !== sig.sourceHeight || + prev.dataLen !== sig.dataLen; + if (changed) { + markRows(sig.viewportRow, sig.pixelHeight); + if (prev) markRows(prev.viewportRow, prev.pixelHeight); + } + } } } + + // Removed placements (were drawn last frame, gone now): mark their + // rows so text repaint clears stale image pixels. + for (const [id, prev] of this.lastKittyDirectSigs) { + if (!newSigs.has(id)) markRows(prev.viewportRow, prev.pixelHeight); + } + this.lastKittyDirectSigs = newSigs; } /** @@ -905,14 +1009,12 @@ export class CanvasRenderer { return true; } - private renderKittyImages(buffer: IRenderable): void { - if (!buffer.getKittyGraphics || !buffer.iterPlacements || !buffer.getKittyImagePixels) { - return; - } - const graphics = buffer.getKittyGraphics(); - if (graphics === null) return; + private renderKittyImages(): void { + const buffer = this.currentRenderBuffer; + const graphics = this.currentKittyGraphics; + if (!buffer || graphics === null || !buffer.getKittyImagePixels) return; - for (const p of buffer.iterPlacements(graphics)) { + for (const p of this.currentDirectPlacements) { let cached = this.kittyImageCache.get(p.imageId); const pixels = buffer.getKittyImagePixels(graphics, p.imageId); if (!pixels) continue; From 319eeba49ead71049b75b356bc42324977b2ec32 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 29 Apr 2026 15:51:15 -0400 Subject: [PATCH 21/29] Strengthen kitty cache key + reapply pixel size on reset 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 --- lib/renderer.ts | 87 +++++++++++++++++++++++++++++++++++++++++-------- lib/terminal.ts | 5 +++ 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/lib/renderer.ts b/lib/renderer.ts index e9f258f..58f022d 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -106,6 +106,32 @@ export const DEFAULT_THEME: Required = { // CanvasRenderer Class // ============================================================================ +/** + * Staleness check for kittyImageCache: an entry is reusable iff every + * identity field matches the just-fetched KittyImagePixels. Width/height/ + * format catch geometry/format changes (which can keep dataLen identical — + * e.g., 100×50 RGBA and 50×100 RGBA both serialize to 20000 bytes), and + * dataPtr (the WASM byteOffset) catches re-allocations from retransmits. + */ +function cachedMatchesPixels( + cached: { + width: number; + height: number; + format: KittyImageFormat; + dataPtr: number; + dataLen: number; + }, + pixels: KittyImagePixels, +): boolean { + return ( + cached.width === pixels.width && + cached.height === pixels.height && + cached.format === pixels.format && + cached.dataPtr === pixels.data.byteOffset && + cached.dataLen === pixels.data.length + ); +} + export class CanvasRenderer { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; @@ -132,13 +158,24 @@ export class CanvasRenderer { /** * Decoded kitty graphics images, keyed by image id. Each entry caches * a canvas painted from the WASM-side RGBA bytes so per-frame compositing - * is just a drawImage call. We track dataLen to invalidate on - * re-transmission (the kitty protocol allows reusing an id with new - * bytes); a length mismatch is a cheap, correct staleness signal. + * is just a drawImage call. + * + * Staleness key combines width/height/format/dataPtr/dataLen — the + * kitty protocol allows reusing an id with new bytes, and dataLen alone + * is too weak (transposed dims or format change can keep byte count + * identical). dataPtr is the WASM byteOffset, which changes whenever + * ghostty frees + re-allocates the image bytes (i.e., on retransmit). */ private kittyImageCache = new Map< number, - { canvas: HTMLCanvasElement; dataLen: number } + { + canvas: HTMLCanvasElement; + width: number; + height: number; + format: KittyImageFormat; + dataPtr: number; + dataLen: number; + } >(); /** @@ -174,6 +211,10 @@ export class CanvasRenderer { sourceY: number; sourceWidth: number; sourceHeight: number; + imgWidth: number; + imgHeight: number; + imgFormat: KittyImageFormat; + dataPtr: number; dataLen: number; } >(); @@ -881,7 +922,6 @@ export class CanvasRenderer { } this.currentDirectPlacements.push(p); const pixels = buffer.getKittyImagePixels?.(graphics, p.imageId); - const dataLen = pixels?.data.length ?? 0; const sig = { viewportCol: p.viewportCol, viewportRow: p.viewportRow, @@ -891,7 +931,11 @@ export class CanvasRenderer { sourceY: p.sourceY, sourceWidth: p.sourceWidth, sourceHeight: p.sourceHeight, - dataLen, + imgWidth: pixels?.width ?? 0, + imgHeight: pixels?.height ?? 0, + imgFormat: pixels?.format ?? (0 as KittyImageFormat), + dataPtr: pixels?.data.byteOffset ?? 0, + dataLen: pixels?.data.length ?? 0, }; newSigs.set(p.imageId, sig); const prev = this.lastKittyDirectSigs.get(p.imageId); @@ -905,6 +949,10 @@ export class CanvasRenderer { prev.sourceY !== sig.sourceY || prev.sourceWidth !== sig.sourceWidth || prev.sourceHeight !== sig.sourceHeight || + prev.imgWidth !== sig.imgWidth || + prev.imgHeight !== sig.imgHeight || + prev.imgFormat !== sig.imgFormat || + prev.dataPtr !== sig.dataPtr || prev.dataLen !== sig.dataLen; if (changed) { markRows(sig.viewportRow, sig.pixelHeight); @@ -936,10 +984,17 @@ export class CanvasRenderer { const cached = this.kittyImageCache.get(imageId); const pixels = buffer.getKittyImagePixels?.(graphics, imageId); if (!pixels) return cached?.canvas ?? null; - if (cached && cached.dataLen === pixels.data.length) return cached.canvas; + if (cached && cachedMatchesPixels(cached, pixels)) return cached.canvas; const canvas = this.decodeKittyImageToCanvas(pixels); if (!canvas) return null; - this.kittyImageCache.set(imageId, { canvas, dataLen: pixels.data.length }); + this.kittyImageCache.set(imageId, { + canvas, + width: pixels.width, + height: pixels.height, + format: pixels.format, + dataPtr: pixels.data.byteOffset, + dataLen: pixels.data.length, + }); return canvas; } @@ -1019,13 +1074,19 @@ export class CanvasRenderer { const pixels = buffer.getKittyImagePixels(graphics, p.imageId); if (!pixels) continue; - // Cache miss or stale (image was re-transmitted with new bytes - // under the same id). dataLen is a cheap discriminator that catches - // the common cases without hashing. - if (!cached || cached.dataLen !== pixels.data.length) { + // Cache miss or stale (image was re-transmitted under the same id). + // See kittyImageCache docstring for staleness-key rationale. + if (!cached || !cachedMatchesPixels(cached, pixels)) { const canvas = this.decodeKittyImageToCanvas(pixels); if (!canvas) continue; - cached = { canvas, dataLen: pixels.data.length }; + cached = { + canvas, + width: pixels.width, + height: pixels.height, + format: pixels.format, + dataPtr: pixels.data.byteOffset, + dataLen: pixels.data.length, + }; this.kittyImageCache.set(p.imageId, cached); } diff --git a/lib/terminal.ts b/lib/terminal.ts index b4dc154..031ab6a 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -738,6 +738,11 @@ export class Terminal implements ITerminalCore { const config = this.buildWasmConfig(); this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config); + // The fresh WASM terminal starts with zero cell pixel dims, so CSI + // 14/16/18 t and kitty graphics sizing would silently report zeros + // until a font/resize event re-pushed them. Reapply now. + this.updateWasmPixelSize(); + // Clear renderer this.renderer!.clear(); From 62228d72feca537fd010c67f6cc8bc17e744da0e Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 29 Apr 2026 15:52:06 -0400 Subject: [PATCH 22/29] Update GitHub Action versions and Node to 24 Signed-off-by: Evan Wies --- .github/actions/setup-zig/action.yml | 2 +- .github/workflows/ci.yml | 20 ++++++++++---------- .github/workflows/publish.yml | 14 +++++++------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/actions/setup-zig/action.yml b/.github/actions/setup-zig/action.yml index 0673078..11d2c5d 100644 --- a/.github/actions/setup-zig/action.yml +++ b/.github/actions/setup-zig/action.yml @@ -9,7 +9,7 @@ runs: using: 'composite' steps: - name: Cache Zig - uses: actions/cache@v4 + uses: actions/cache@v5 id: cache-zig with: path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c86f429..ff6e9b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,9 @@ jobs: name: fmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v1 + - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -27,9 +27,9 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v1 + - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -43,9 +43,9 @@ jobs: name: type check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v1 + - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -59,11 +59,11 @@ jobs: name: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive - - uses: oven-sh/setup-bun@v1 + - uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -84,11 +84,11 @@ jobs: name: build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: recursive - - uses: oven-sh/setup-bun@v1 + - uses: oven-sh/setup-bun@v2 with: bun-version: latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 07a82d4..df1d283 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -23,14 +23,14 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'push' }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.ref }} fetch-depth: 0 # Required for git describe to find tags submodules: recursive - name: Setup Bun - uses: oven-sh/setup-bun@v1 + uses: oven-sh/setup-bun@v2 with: bun-version: latest @@ -61,9 +61,9 @@ jobs: run: bun run build - name: Setup Node.js for npm - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' registry-url: 'https://registry.npmjs.org' # Ensure npm 11.5.1 or later for trusted publishing @@ -159,15 +159,15 @@ jobs: if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'push' }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ github.ref }} fetch-depth: 0 - name: Setup Node.js for npm - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '24' registry-url: 'https://registry.npmjs.org' - run: npm install -g npm@latest From 1004aed111916b7e68622818a0d8626c13ff6347 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 29 Apr 2026 16:14:13 -0400 Subject: [PATCH 23/29] chore: bun run lint:fix Signed-off-by: Evan Wies --- lib/ghostty.ts | 36 ++++++++++++++++++------------------ lib/renderer.ts | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 16e0b85..998d7d2 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -8,23 +8,26 @@ import { decode as decodePng } from 'fast-png'; import { - type DecodePngCallback, - makeCallbackTrampolines, - type SizeCallback, - type WritePtyCallback, -} from './write_pty_trampoline'; -import { + CellData, CellFlags, - CursorVisualStyle, + CellWide, type Cursor, + CursorVisualStyle, DirtyState, type GhosttyCell, type GhosttyTerminalConfig, type GhosttyWasmExports, + KITTY_PLACEMENT_RENDER_INFO_SIZE, KeyEncoderOption, type KeyEvent, + KittyGraphicsData, + KittyGraphicsImageData, + KittyGraphicsPlacementData, + type KittyImageFormat, + type KittyImagePixels, type KittyKeyFlags, - packMode, + type KittyPlacementInfo, + PointTag, type RGB, type RenderStateColors, type RenderStateCursor, @@ -32,24 +35,21 @@ import { RenderStateOption, RenderStateRowData, RenderStateRowOption, - PointTag, RowCellsData, RowData, - CellData, - CellWide, - KITTY_PLACEMENT_RENDER_INFO_SIZE, - KittyGraphicsData, - KittyGraphicsImageData, - type KittyImagePixels, - KittyGraphicsPlacementData, - KittyImageFormat, - type KittyPlacementInfo, SysOption, TerminalData, type TerminalHandle, TerminalOption, TerminalScreen, + packMode, } from './types'; +import { + type DecodePngCallback, + type SizeCallback, + type WritePtyCallback, + makeCallbackTrampolines, +} from './write_pty_trampoline'; // Re-export types for convenience export { diff --git a/lib/renderer.ts b/lib/renderer.ts index 58f022d..d0d232e 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -11,7 +11,7 @@ */ import type { ITheme } from './interfaces'; -import { diacriticToInt, KITTY_PLACEHOLDER } from './kitty_diacritics'; +import { KITTY_PLACEHOLDER, diacriticToInt } from './kitty_diacritics'; import type { SelectionManager } from './selection-manager'; import type { GhosttyCell, ILink, KittyImagePixels, KittyPlacementInfo } from './types'; import { CellFlags, KittyImageFormat } from './types'; From d308f56cf072a499a7c595edb8972c89fd8e2a32 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 29 Apr 2026 18:29:42 -0400 Subject: [PATCH 24/29] chore: run prettier Signed-off-by: Evan Wies --- lib/ghostty.ts | 209 +++++------------------ lib/kitty_diacritics.ts | 326 +++--------------------------------- lib/renderer.ts | 16 +- lib/types.ts | 18 +- lib/write_pty_trampoline.ts | 40 ++--- 5 files changed, 97 insertions(+), 512 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 998d7d2..a615479 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -289,7 +289,6 @@ export class GhosttyTerminal { private _cols: number; private _rows: number; - /** Cell pool for zero-allocation rendering */ private cellPool: GhosttyCell[] = []; @@ -437,21 +436,18 @@ export class GhosttyTerminal { // getCursor/getColors/getViewport. Render state is updated explicitly via // update() rather than implicitly per read, since it's relatively cheap // when the terminal hasn't changed but still costs a WASM crossing. - this.renderHandle = this.allocOpaqueOrFail( - 'ghostty_render_state_new', - (out) => this.exports.ghostty_render_state_new(0, out) + this.renderHandle = this.allocOpaqueOrFail('ghostty_render_state_new', (out) => + this.exports.ghostty_render_state_new(0, out) ); // Pre-allocate the row iterator and row-cells iterators once and reuse // them across frames. They're populated from the render state in // getViewport via _get(ROW_ITERATOR) and _row_get(ROW_DATA_CELLS); the // handles themselves stay live for the terminal's lifetime. - this.rowIter = this.allocOpaqueOrFail( - 'ghostty_render_state_row_iterator_new', - (out) => this.exports.ghostty_render_state_row_iterator_new(0, out) + this.rowIter = this.allocOpaqueOrFail('ghostty_render_state_row_iterator_new', (out) => + this.exports.ghostty_render_state_row_iterator_new(0, out) ); - this.rowCells = this.allocOpaqueOrFail( - 'ghostty_render_state_row_cells_new', - (out) => this.exports.ghostty_render_state_row_cells_new(0, out) + this.rowCells = this.allocOpaqueOrFail('ghostty_render_state_row_cells_new', (out) => + this.exports.ghostty_render_state_row_cells_new(0, out) ); this.initCellPool(); @@ -466,10 +462,7 @@ export class GhosttyTerminal { * resources so the caller-throwing flow doesn't leak across the partially * constructed object. */ - private allocOpaqueOrFail( - name: string, - factory: (outPtr: number) => number - ): number { + private allocOpaqueOrFail(name: string, factory: (outPtr: number) => number): number { const outPtr = this.exports.ghostty_wasm_alloc_opaque(); if (outPtr === 0) { this.cleanupOnConstructorFailure(); @@ -692,11 +685,7 @@ export class GhosttyTerminal { const hi = Math.floor(bytes / 0x100000000) >>> 0; view.setUint32(ptr + 0, lo, true); view.setUint32(ptr + 4, hi, true); - this.exports.ghostty_terminal_set( - this.handle, - TerminalOption.KITTY_IMAGE_STORAGE_LIMIT, - ptr - ); + this.exports.ghostty_terminal_set(this.handle, TerminalOption.KITTY_IMAGE_STORAGE_LIMIT, ptr); this.exports.ghostty_wasm_free_u8_array(ptr, 8); } @@ -718,11 +707,7 @@ export class GhosttyTerminal { getKittyGraphics(): number | null { const out = this.exports.ghostty_wasm_alloc_u8_array(4); try { - const r = this.exports.ghostty_terminal_get( - this.handle, - TerminalData.KITTY_GRAPHICS, - out - ); + const r = this.exports.ghostty_terminal_get(this.handle, TerminalData.KITTY_GRAPHICS, out); if (r !== 0) return null; const handle = new DataView(this.memory.buffer).getUint32(out, true); return handle === 0 ? null : handle; @@ -742,10 +727,7 @@ export class GhosttyTerminal { * placement_render_info call (fills 12 fields in one WASM crossing * instead of 5 separate getters). */ - *iterPlacements( - graphics: number, - onlyVisible: boolean = true, - ): Generator { + *iterPlacements(graphics: number, onlyVisible: boolean = true): Generator { // Allocate iterator + scratch buffers once for the whole walk. const iterPP = this.exports.ghostty_wasm_alloc_opaque(); if (iterPP === 0) return; @@ -770,16 +752,10 @@ export class GhosttyTerminal { } const idPtr = this.exports.ghostty_wasm_alloc_u8_array(4); - const infoPtr = this.exports.ghostty_wasm_alloc_u8_array( - KITTY_PLACEMENT_RENDER_INFO_SIZE - ); + const infoPtr = this.exports.ghostty_wasm_alloc_u8_array(KITTY_PLACEMENT_RENDER_INFO_SIZE); // Sized struct: write the discriminator once, the populator // overwrites the rest each call. - new DataView(this.memory.buffer).setUint32( - infoPtr, - KITTY_PLACEMENT_RENDER_INFO_SIZE, - true - ); + new DataView(this.memory.buffer).setUint32(infoPtr, KITTY_PLACEMENT_RENDER_INFO_SIZE, true); try { while (this.exports.ghostty_kitty_graphics_placement_next(iter)) { // Look up image_id for this placement so we can pair it with @@ -792,10 +768,7 @@ export class GhosttyTerminal { const imageId = new DataView(this.memory.buffer).getUint32(idPtr, true); // Resolve the image handle — placement_render_info needs it. - const imageHandle = this.exports.ghostty_kitty_graphics_image( - graphics, - imageId - ); + const imageHandle = this.exports.ghostty_kitty_graphics_image(graphics, imageId); if (imageHandle === 0) continue; // Reset the size discriminator (the populator may have written @@ -822,8 +795,7 @@ export class GhosttyTerminal { KittyGraphicsPlacementData.IS_VIRTUAL, idPtr // reuse the 4-byte slot; the value is a bool but written as u8 ); - const isVirtual = - new DataView(this.memory.buffer).getUint8(idPtr) !== 0; + const isVirtual = new DataView(this.memory.buffer).getUint8(idPtr) !== 0; const v = new DataView(this.memory.buffer); const info: KittyPlacementInfo = { @@ -850,10 +822,7 @@ export class GhosttyTerminal { } } finally { this.exports.ghostty_wasm_free_u8_array(idPtr, 4); - this.exports.ghostty_wasm_free_u8_array( - infoPtr, - KITTY_PLACEMENT_RENDER_INFO_SIZE - ); + this.exports.ghostty_wasm_free_u8_array(infoPtr, KITTY_PLACEMENT_RENDER_INFO_SIZE); } } finally { if (iter !== 0) { @@ -880,9 +849,7 @@ export class GhosttyTerminal { try { const view = new DataView(this.memory.buffer); const read = (key: number): number => { - if ( - this.exports.ghostty_kitty_graphics_image_get(image, key, u32Ptr) !== 0 - ) { + if (this.exports.ghostty_kitty_graphics_image_get(image, key, u32Ptr) !== 0) { return 0; } return new DataView(this.memory.buffer).getUint32(u32Ptr, true); @@ -933,13 +900,7 @@ export class GhosttyTerminal { if (w === this.cellWidthPx && h === this.cellHeightPx) return; this.cellWidthPx = w; this.cellHeightPx = h; - this.exports.ghostty_terminal_resize( - this.handle, - this._cols, - this._rows, - w, - h - ); + this.exports.ghostty_terminal_resize(this.handle, this._cols, this._rows, w, h); } free(): void { @@ -1080,11 +1041,7 @@ export class GhosttyTerminal { const wrap = new Array(this._rows).fill(false); this.populateHandle( (out) => - this.exports.ghostty_render_state_get( - this.renderHandle, - RenderStateData.ROW_ITERATOR, - out - ), + this.exports.ghostty_render_state_get(this.renderHandle, RenderStateData.ROW_ITERATOR, out), this.rowIter ); const dirtyPtr = this.exports.ghostty_wasm_alloc_u8(); @@ -1098,18 +1055,10 @@ export class GhosttyTerminal { ) { const view = new DataView(this.memory.buffer); - this.exports.ghostty_render_state_row_get( - this.rowIter, - RenderStateRowData.DIRTY, - dirtyPtr - ); + this.exports.ghostty_render_state_row_get(this.rowIter, RenderStateRowData.DIRTY, dirtyPtr); dirty[row] = view.getUint8(dirtyPtr) !== 0; - this.exports.ghostty_render_state_row_get( - this.rowIter, - RenderStateRowData.RAW, - rawPtr - ); + this.exports.ghostty_render_state_row_get(this.rowIter, RenderStateRowData.RAW, rawPtr); const rowU64 = new DataView(this.memory.buffer).getBigUint64(rawPtr, true); this.exports.ghostty_row_get(rowU64, RowData.WRAP_CONTINUATION, wrapPtr); wrap[row] = new DataView(this.memory.buffer).getUint8(wrapPtr) !== 0; @@ -1143,21 +1092,13 @@ export class GhosttyTerminal { // Re-bind the iterator to the current state and clear each row's dirty. this.populateHandle( (out) => - this.exports.ghostty_render_state_get( - this.renderHandle, - RenderStateData.ROW_ITERATOR, - out - ), + this.exports.ghostty_render_state_get(this.renderHandle, RenderStateData.ROW_ITERATOR, out), this.rowIter ); const falsePtr = this.exports.ghostty_wasm_alloc_u8(); new DataView(this.memory.buffer).setUint8(falsePtr, 0); while (this.exports.ghostty_render_state_row_iterator_next(this.rowIter)) { - this.exports.ghostty_render_state_row_set( - this.rowIter, - RenderStateRowOption.DIRTY, - falsePtr - ); + this.exports.ghostty_render_state_row_set(this.rowIter, RenderStateRowOption.DIRTY, falsePtr); } this.exports.ghostty_wasm_free_u8(falsePtr); @@ -1204,7 +1145,8 @@ export class GhosttyTerminal { // _get(state, ROW_ITERATOR, &iter) reads `*ptr` to get our pre-allocated // iterator handle, then re-binds it to the current frame's row data. this.populateHandle( - (out) => this.exports.ghostty_render_state_get(this.renderHandle, RenderStateData.ROW_ITERATOR, out), + (out) => + this.exports.ghostty_render_state_get(this.renderHandle, RenderStateData.ROW_ITERATOR, out), this.rowIter ); @@ -1239,19 +1181,10 @@ export class GhosttyTerminal { this.exports.ghostty_render_state_row_iterator_next(this.rowIter) ) { // Capture per-row dirty + wrap for the caches. - this.exports.ghostty_render_state_row_get( - this.rowIter, - RenderStateRowData.DIRTY, - dirtyPtr - ); - dirtyCache[row] = - new DataView(this.memory.buffer).getUint8(dirtyPtr) !== 0; + this.exports.ghostty_render_state_row_get(this.rowIter, RenderStateRowData.DIRTY, dirtyPtr); + dirtyCache[row] = new DataView(this.memory.buffer).getUint8(dirtyPtr) !== 0; - this.exports.ghostty_render_state_row_get( - this.rowIter, - RenderStateRowData.RAW, - rawPtr - ); + this.exports.ghostty_render_state_row_get(this.rowIter, RenderStateRowData.RAW, rawPtr); const rowU64 = new DataView(this.memory.buffer).getBigUint64(rawPtr, true); this.exports.ghostty_row_get(rowU64, RowData.WRAP_CONTINUATION, wrapPtr); wrapCache[row] = new DataView(this.memory.buffer).getUint8(wrapPtr) !== 0; @@ -1259,11 +1192,7 @@ export class GhosttyTerminal { // Bind rowCells to this row. this.populateHandle( (out) => - this.exports.ghostty_render_state_row_get( - this.rowIter, - RenderStateRowData.CELLS, - out - ), + this.exports.ghostty_render_state_row_get(this.rowIter, RenderStateRowData.CELLS, out), this.rowCells ); @@ -1372,10 +1301,7 @@ export class GhosttyTerminal { RowCellsData.RAW, cellRawPtr ); - const cellU64 = new DataView(this.memory.buffer).getBigUint64( - cellRawPtr, - true - ); + const cellU64 = new DataView(this.memory.buffer).getBigUint64(cellRawPtr, true); this.exports.ghostty_cell_get(cellU64, CellData.WIDE, widePtr); const wide = new DataView(this.memory.buffer).getUint32(widePtr, true); cell.width = @@ -1394,8 +1320,7 @@ export class GhosttyTerminal { // identifying actual links via URI + position range. We // preserve that contract here. this.exports.ghostty_cell_get(cellU64, CellData.HAS_HYPERLINK, widePtr); - cell.hyperlink_id = - new DataView(this.memory.buffer).getUint8(widePtr) !== 0 ? 1 : 0; + cell.hyperlink_id = new DataView(this.memory.buffer).getUint8(widePtr) !== 0 ? 1 : 0; col++; } @@ -1423,10 +1348,7 @@ export class GhosttyTerminal { * free the slot. The handle value itself is unchanged; the populator uses * it to find and rebind the iterator's internal data. */ - private populateHandle( - populator: (slotPtr: number) => number, - handle: number - ): void { + private populateHandle(populator: (slotPtr: number) => number, handle: number): void { const slot = this.exports.ghostty_wasm_alloc_u8_array(4); new DataView(this.memory.buffer).setUint32(slot, handle, true); populator(slot); @@ -1595,11 +1517,8 @@ export class GhosttyTerminal { const PAL_SIZE = 768; const palettePtr = this.exports.ghostty_wasm_alloc_u8_array(PAL_SIZE); const palOk = - this.exports.ghostty_terminal_get( - this.handle, - TerminalData.COLOR_PALETTE, - palettePtr - ) === 0; + this.exports.ghostty_terminal_get(this.handle, TerminalData.COLOR_PALETTE, palettePtr) === + 0; const palette = palOk ? new Uint8Array(this.memory.buffer, palettePtr, PAL_SIZE).slice() : null; @@ -1642,19 +1561,13 @@ export class GhosttyTerminal { // uses (link-detector identifies actual links by URI + // position range; the renderer just needs the indicator). this.exports.ghostty_cell_get(cellU64, CellData.HAS_HYPERLINK, widePtr); - const hasHyperlink = - new DataView(this.memory.buffer).getUint8(widePtr) !== 0; + const hasHyperlink = new DataView(this.memory.buffer).getUint8(widePtr) !== 0; // Style: per-position via grid_ref_style (not via cell — // styles aren't stored in the cell value, they're attached // to the row's pin position). - new DataView(this.memory.buffer).setUint32( - stylePtr, - STYLE_SIZE, - true - ); - const styleOk = - this.exports.ghostty_grid_ref_style(refPtr, stylePtr) === 0; + new DataView(this.memory.buffer).setUint32(stylePtr, STYLE_SIZE, true); + const styleOk = this.exports.ghostty_grid_ref_style(refPtr, stylePtr) === 0; const cell = this.makeEmptyCell(); cell.codepoint = cp; @@ -1761,12 +1674,7 @@ export class GhosttyTerminal { const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(needed); try { - const r = this.exports.ghostty_grid_ref_hyperlink_uri( - refPtr, - bufPtr, - needed, - outLenPtr - ); + const r = this.exports.ghostty_grid_ref_hyperlink_uri(refPtr, bufPtr, needed, outLenPtr); if (r !== 0) return null; const written = new DataView(this.memory.buffer).getUint32(outLenPtr, true); const bytes = new Uint8Array(this.memory.buffer, bufPtr, written); @@ -1864,20 +1772,13 @@ export class GhosttyTerminal { let registry = GhosttyTerminal.callbackRegistries.get(table); if (!registry) { const instancesByHandle = new Map(); - const writePtyDispatch: WritePtyCallback = ( - handle, - _userdata, - dataPtr, - dataLen, - ) => { + const writePtyDispatch: WritePtyCallback = (handle, _userdata, dataPtr, dataLen) => { const term = instancesByHandle.get(handle); if (!term) return; // Copy out — the underlying WASM memory may be mutated or // detached by the next allocation, and the chunk lives until // readResponse drains it. - term.pendingResponses.push( - new Uint8Array(term.memory.buffer, dataPtr, dataLen).slice(), - ); + term.pendingResponses.push(new Uint8Array(term.memory.buffer, dataPtr, dataLen).slice()); }; const sizeDispatch: SizeCallback = (handle, _userdata, outSizePtr) => { const term = instancesByHandle.get(handle); @@ -1914,7 +1815,7 @@ export class GhosttyTerminal { allocator, dataPtr, dataLen, - outImagePtr, + outImagePtr ) => { try { const pngBytes = new Uint8Array(memory.buffer, dataPtr, dataLen).slice(); @@ -1941,7 +1842,7 @@ export class GhosttyTerminal { const { writePtyFwd, sizeFwd, decodePngFwd } = makeCallbackTrampolines( writePtyDispatch, sizeDispatch, - decodePngDispatch, + decodePngDispatch ); // Grow once per slot, write each. const writePtyIndex = table.grow(1); @@ -1971,13 +1872,9 @@ export class GhosttyTerminal { this.exports.ghostty_terminal_set( this.handle, TerminalOption.WRITE_PTY, - registry.writePtyIndex, - ); - this.exports.ghostty_terminal_set( - this.handle, - TerminalOption.SIZE, - registry.sizeIndex, + registry.writePtyIndex ); + this.exports.ghostty_terminal_set(this.handle, TerminalOption.SIZE, registry.sizeIndex); } /** @@ -2034,11 +1931,7 @@ export class GhosttyTerminal { // Bind iterator to current state and walk forward to the target row. this.populateHandle( (out) => - this.exports.ghostty_render_state_get( - this.renderHandle, - RenderStateData.ROW_ITERATOR, - out - ), + this.exports.ghostty_render_state_get(this.renderHandle, RenderStateData.ROW_ITERATOR, out), this.rowIter ); for (let r = 0; r <= row; r++) { @@ -2050,11 +1943,7 @@ export class GhosttyTerminal { // Bind cells from this row, then position at the target column. this.populateHandle( (out) => - this.exports.ghostty_render_state_row_get( - this.rowIter, - RenderStateRowData.CELLS, - out - ), + this.exports.ghostty_render_state_row_get(this.rowIter, RenderStateRowData.CELLS, out), this.rowCells ); if (this.exports.ghostty_render_state_row_cells_select(this.rowCells, col) !== 0) { @@ -2128,12 +2017,7 @@ export class GhosttyTerminal { const bytes = needed * 4; // codepoints are u32 const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bytes); try { - const r = this.exports.ghostty_grid_ref_graphemes( - refPtr, - bufPtr, - needed, - outLenPtr - ); + const r = this.exports.ghostty_grid_ref_graphemes(refPtr, bufPtr, needed, outLenPtr); if (r !== 0) return null; const written = new DataView(this.memory.buffer).getUint32(outLenPtr, true); return Array.from(new Uint32Array(this.memory.buffer, bufPtr, written)); @@ -2157,7 +2041,6 @@ export class GhosttyTerminal { if (!codepoints || codepoints.length === 0) return ' '; return String.fromCodePoint(...codepoints); } - } /** diff --git a/lib/kitty_diacritics.ts b/lib/kitty_diacritics.ts index e11e75c..442be54 100644 --- a/lib/kitty_diacritics.ts +++ b/lib/kitty_diacritics.ts @@ -15,312 +15,38 @@ * see kitty's docs for the full derivation rationale). */ export const ROWCOLUMN_DIACRITICS: readonly number[] = [ - 0x0305, - 0x030D, - 0x030E, - 0x0310, - 0x0312, - 0x033D, - 0x033E, - 0x033F, - 0x0346, - 0x034A, - 0x034B, - 0x034C, - 0x0350, - 0x0351, - 0x0352, - 0x0357, - 0x035B, - 0x0363, - 0x0364, - 0x0365, - 0x0366, - 0x0367, - 0x0368, - 0x0369, - 0x036A, - 0x036B, - 0x036C, - 0x036D, - 0x036E, - 0x036F, - 0x0483, - 0x0484, - 0x0485, - 0x0486, - 0x0487, - 0x0592, - 0x0593, - 0x0594, - 0x0595, - 0x0597, - 0x0598, - 0x0599, - 0x059C, - 0x059D, - 0x059E, - 0x059F, - 0x05A0, - 0x05A1, - 0x05A8, - 0x05A9, - 0x05AB, - 0x05AC, - 0x05AF, - 0x05C4, - 0x0610, - 0x0611, - 0x0612, - 0x0613, - 0x0614, - 0x0615, - 0x0616, - 0x0617, - 0x0657, - 0x0658, - 0x0659, - 0x065A, - 0x065B, - 0x065D, - 0x065E, - 0x06D6, - 0x06D7, - 0x06D8, - 0x06D9, - 0x06DA, - 0x06DB, - 0x06DC, - 0x06DF, - 0x06E0, - 0x06E1, - 0x06E2, - 0x06E4, - 0x06E7, - 0x06E8, - 0x06EB, - 0x06EC, - 0x0730, - 0x0732, - 0x0733, - 0x0735, - 0x0736, - 0x073A, - 0x073D, - 0x073F, - 0x0740, - 0x0741, - 0x0743, - 0x0745, - 0x0747, - 0x0749, - 0x074A, - 0x07EB, - 0x07EC, - 0x07ED, - 0x07EE, - 0x07EF, - 0x07F0, - 0x07F1, - 0x07F3, - 0x0816, - 0x0817, - 0x0818, - 0x0819, - 0x081B, - 0x081C, - 0x081D, - 0x081E, - 0x081F, - 0x0820, - 0x0821, - 0x0822, - 0x0823, - 0x0825, - 0x0826, - 0x0827, - 0x0829, - 0x082A, - 0x082B, - 0x082C, - 0x082D, - 0x0951, - 0x0953, - 0x0954, - 0x0F82, - 0x0F83, - 0x0F86, - 0x0F87, - 0x135D, - 0x135E, - 0x135F, - 0x17DD, - 0x193A, - 0x1A17, - 0x1A75, - 0x1A76, - 0x1A77, - 0x1A78, - 0x1A79, - 0x1A7A, - 0x1A7B, - 0x1A7C, - 0x1B6B, - 0x1B6D, - 0x1B6E, - 0x1B6F, - 0x1B70, - 0x1B71, - 0x1B72, - 0x1B73, - 0x1CD0, - 0x1CD1, - 0x1CD2, - 0x1CDA, - 0x1CDB, - 0x1CE0, - 0x1DC0, - 0x1DC1, - 0x1DC3, - 0x1DC4, - 0x1DC5, - 0x1DC6, - 0x1DC7, - 0x1DC8, - 0x1DC9, - 0x1DCB, - 0x1DCC, - 0x1DD1, - 0x1DD2, - 0x1DD3, - 0x1DD4, - 0x1DD5, - 0x1DD6, - 0x1DD7, - 0x1DD8, - 0x1DD9, - 0x1DDA, - 0x1DDB, - 0x1DDC, - 0x1DDD, - 0x1DDE, - 0x1DDF, - 0x1DE0, - 0x1DE1, - 0x1DE2, - 0x1DE3, - 0x1DE4, - 0x1DE5, - 0x1DE6, - 0x1DFE, - 0x20D0, - 0x20D1, - 0x20D4, - 0x20D5, - 0x20D6, - 0x20D7, - 0x20DB, - 0x20DC, - 0x20E1, - 0x20E7, - 0x20E9, - 0x20F0, - 0x2CEF, - 0x2CF0, - 0x2CF1, - 0x2DE0, - 0x2DE1, - 0x2DE2, - 0x2DE3, - 0x2DE4, - 0x2DE5, - 0x2DE6, - 0x2DE7, - 0x2DE8, - 0x2DE9, - 0x2DEA, - 0x2DEB, - 0x2DEC, - 0x2DED, - 0x2DEE, - 0x2DEF, - 0x2DF0, - 0x2DF1, - 0x2DF2, - 0x2DF3, - 0x2DF4, - 0x2DF5, - 0x2DF6, - 0x2DF7, - 0x2DF8, - 0x2DF9, - 0x2DFA, - 0x2DFB, - 0x2DFC, - 0x2DFD, - 0x2DFE, - 0x2DFF, - 0xA66F, - 0xA67C, - 0xA67D, - 0xA6F0, - 0xA6F1, - 0xA8E0, - 0xA8E1, - 0xA8E2, - 0xA8E3, - 0xA8E4, - 0xA8E5, - 0xA8E6, - 0xA8E7, - 0xA8E8, - 0xA8E9, - 0xA8EA, - 0xA8EB, - 0xA8EC, - 0xA8ED, - 0xA8EE, - 0xA8EF, - 0xA8F0, - 0xA8F1, - 0xAAB0, - 0xAAB2, - 0xAAB3, - 0xAAB7, - 0xAAB8, - 0xAABE, - 0xAABF, - 0xAAC1, - 0xFE20, - 0xFE21, - 0xFE22, - 0xFE23, - 0xFE24, - 0xFE25, - 0xFE26, - 0x10A0F, - 0x10A38, - 0x1D185, - 0x1D186, - 0x1D187, - 0x1D188, - 0x1D189, - 0x1D1AA, - 0x1D1AB, - 0x1D1AC, - 0x1D1AD, - 0x1D242, - 0x1D243, - 0x1D244, + 0x0305, 0x030d, 0x030e, 0x0310, 0x0312, 0x033d, 0x033e, 0x033f, 0x0346, 0x034a, 0x034b, 0x034c, + 0x0350, 0x0351, 0x0352, 0x0357, 0x035b, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369, + 0x036a, 0x036b, 0x036c, 0x036d, 0x036e, 0x036f, 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, + 0x0593, 0x0594, 0x0595, 0x0597, 0x0598, 0x0599, 0x059c, 0x059d, 0x059e, 0x059f, 0x05a0, 0x05a1, + 0x05a8, 0x05a9, 0x05ab, 0x05ac, 0x05af, 0x05c4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615, + 0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065a, 0x065b, 0x065d, 0x065e, 0x06d6, 0x06d7, 0x06d8, + 0x06d9, 0x06da, 0x06db, 0x06dc, 0x06df, 0x06e0, 0x06e1, 0x06e2, 0x06e4, 0x06e7, 0x06e8, 0x06eb, + 0x06ec, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, 0x073a, 0x073d, 0x073f, 0x0740, 0x0741, 0x0743, + 0x0745, 0x0747, 0x0749, 0x074a, 0x07eb, 0x07ec, 0x07ed, 0x07ee, 0x07ef, 0x07f0, 0x07f1, 0x07f3, + 0x0816, 0x0817, 0x0818, 0x0819, 0x081b, 0x081c, 0x081d, 0x081e, 0x081f, 0x0820, 0x0821, 0x0822, + 0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082a, 0x082b, 0x082c, 0x082d, 0x0951, 0x0953, 0x0954, + 0x0f82, 0x0f83, 0x0f86, 0x0f87, 0x135d, 0x135e, 0x135f, 0x17dd, 0x193a, 0x1a17, 0x1a75, 0x1a76, + 0x1a77, 0x1a78, 0x1a79, 0x1a7a, 0x1a7b, 0x1a7c, 0x1b6b, 0x1b6d, 0x1b6e, 0x1b6f, 0x1b70, 0x1b71, + 0x1b72, 0x1b73, 0x1cd0, 0x1cd1, 0x1cd2, 0x1cda, 0x1cdb, 0x1ce0, 0x1dc0, 0x1dc1, 0x1dc3, 0x1dc4, + 0x1dc5, 0x1dc6, 0x1dc7, 0x1dc8, 0x1dc9, 0x1dcb, 0x1dcc, 0x1dd1, 0x1dd2, 0x1dd3, 0x1dd4, 0x1dd5, + 0x1dd6, 0x1dd7, 0x1dd8, 0x1dd9, 0x1dda, 0x1ddb, 0x1ddc, 0x1ddd, 0x1dde, 0x1ddf, 0x1de0, 0x1de1, + 0x1de2, 0x1de3, 0x1de4, 0x1de5, 0x1de6, 0x1dfe, 0x20d0, 0x20d1, 0x20d4, 0x20d5, 0x20d6, 0x20d7, + 0x20db, 0x20dc, 0x20e1, 0x20e7, 0x20e9, 0x20f0, 0x2cef, 0x2cf0, 0x2cf1, 0x2de0, 0x2de1, 0x2de2, + 0x2de3, 0x2de4, 0x2de5, 0x2de6, 0x2de7, 0x2de8, 0x2de9, 0x2dea, 0x2deb, 0x2dec, 0x2ded, 0x2dee, + 0x2def, 0x2df0, 0x2df1, 0x2df2, 0x2df3, 0x2df4, 0x2df5, 0x2df6, 0x2df7, 0x2df8, 0x2df9, 0x2dfa, + 0x2dfb, 0x2dfc, 0x2dfd, 0x2dfe, 0x2dff, 0xa66f, 0xa67c, 0xa67d, 0xa6f0, 0xa6f1, 0xa8e0, 0xa8e1, + 0xa8e2, 0xa8e3, 0xa8e4, 0xa8e5, 0xa8e6, 0xa8e7, 0xa8e8, 0xa8e9, 0xa8ea, 0xa8eb, 0xa8ec, 0xa8ed, + 0xa8ee, 0xa8ef, 0xa8f0, 0xa8f1, 0xaab0, 0xaab2, 0xaab3, 0xaab7, 0xaab8, 0xaabe, 0xaabf, 0xaac1, + 0xfe20, 0xfe21, 0xfe22, 0xfe23, 0xfe24, 0xfe25, 0xfe26, 0x10a0f, 0x10a38, 0x1d185, 0x1d186, + 0x1d187, 0x1d188, 0x1d189, 0x1d1aa, 0x1d1ab, 0x1d1ac, 0x1d1ad, 0x1d242, 0x1d243, 0x1d244, ]; /** * Reverse lookup: codepoint → integer index. Built once at module load. * Returns -1 for codepoints that aren't valid kitty diacritics. */ -const DIACRITIC_INDEX = new Map( - ROWCOLUMN_DIACRITICS.map((cp, i) => [cp, i]), -); +const DIACRITIC_INDEX = new Map(ROWCOLUMN_DIACRITICS.map((cp, i) => [cp, i])); export function diacriticToInt(cp: number): number { return DIACRITIC_INDEX.get(cp) ?? -1; diff --git a/lib/renderer.ts b/lib/renderer.ts index d0d232e..cce919a 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -121,7 +121,7 @@ function cachedMatchesPixels( dataPtr: number; dataLen: number; }, - pixels: KittyImagePixels, + pixels: KittyImagePixels ): boolean { return ( cached.width === pixels.width && @@ -791,9 +791,7 @@ export class CanvasRenderer { } const fgIsDefault = fg_r === 0 && fg_g === 0 && fg_b === 0; - this.ctx.fillStyle = fgIsDefault - ? this.theme.foreground - : this.rgbToCSS(fg_r, fg_g, fg_b); + this.ctx.fillStyle = fgIsDefault ? this.theme.foreground : this.rgbToCSS(fg_r, fg_g, fg_b); } // Apply faint effect @@ -979,7 +977,7 @@ export class CanvasRenderer { private getOrDecodeKittyImage( buffer: IRenderable, graphics: number, - imageId: number, + imageId: number ): HTMLCanvasElement | null { const cached = this.kittyImageCache.get(imageId); const pixels = buffer.getKittyImagePixels?.(graphics, imageId); @@ -1059,7 +1057,7 @@ export class CanvasRenderer { destX, destY, this.metrics.width, - this.metrics.height, + this.metrics.height ); return true; } @@ -1103,7 +1101,7 @@ export class CanvasRenderer { p.viewportCol * this.metrics.width, p.viewportRow * this.metrics.height, p.pixelWidth, - p.pixelHeight, + p.pixelHeight ); } } @@ -1114,9 +1112,7 @@ export class CanvasRenderer { * (which require a JS-side decoder set up via ghostty_sys_set) are * not supported in this MVP and return null. */ - private decodeKittyImageToCanvas( - pixels: KittyImagePixels, - ): HTMLCanvasElement | null { + private decodeKittyImageToCanvas(pixels: KittyImagePixels): HTMLCanvasElement | null { const { width, height, format, data } = pixels; if (width === 0 || height === 0) return null; diff --git a/lib/types.ts b/lib/types.ts index 9aca49f..014820c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -408,11 +408,7 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { ghostty_key_event_set_utf8(event: number, ptr: number, len: number): void; // Terminal lifecycle - ghostty_terminal_new( - allocatorPtr: number, - terminalPtrPtr: number, - optionsPtr: number - ): number; // GhosttyResult (0 = success) + ghostty_terminal_new(allocatorPtr: number, terminalPtrPtr: number, optionsPtr: number): number; // GhosttyResult (0 = success) ghostty_terminal_free(terminal: TerminalHandle): void; ghostty_terminal_resize( terminal: TerminalHandle, @@ -421,11 +417,7 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { cellWidthPx: number, cellHeightPx: number ): number; - ghostty_terminal_vt_write( - terminal: TerminalHandle, - dataPtr: number, - dataLen: number - ): void; + ghostty_terminal_vt_write(terminal: TerminalHandle, dataPtr: number, dataLen: number): void; // RenderState API — render state is a separate object created from a terminal. // Read fields via the generic _get(state, key, *out) interface keyed by @@ -473,11 +465,7 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { // access where the render-state row iterator doesn't reach. // Note: refs are invalidated by ANY terminal mutation — read and copy out // before the next vt_write. - ghostty_terminal_grid_ref( - terminal: TerminalHandle, - pointPtr: number, - outRefPtr: number - ): number; + ghostty_terminal_grid_ref(terminal: TerminalHandle, pointPtr: number, outRefPtr: number): number; ghostty_grid_ref_cell(refPtr: number, outCellPtr: number): number; ghostty_grid_ref_row(refPtr: number, outRowPtr: number): number; ghostty_grid_ref_graphemes( diff --git a/lib/write_pty_trampoline.ts b/lib/write_pty_trampoline.ts index d230194..f0adee2 100644 --- a/lib/write_pty_trampoline.ts +++ b/lib/write_pty_trampoline.ts @@ -30,29 +30,25 @@ * Source is in write_pty_trampoline.wat — keep both in sync if you edit. */ const TRAMPOLINE_BYTES = new Uint8Array([ - 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x18, 0x03, 0x60, - 0x04, 0x7f, 0x7f, 0x7f, 0x7f, 0x00, 0x60, 0x03, 0x7f, 0x7f, 0x7f, 0x01, - 0x7f, 0x60, 0x05, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, 0x01, 0x7f, 0x02, 0x36, - 0x03, 0x03, 0x65, 0x6e, 0x76, 0x0c, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, - 0x70, 0x74, 0x79, 0x5f, 0x63, 0x62, 0x00, 0x00, 0x03, 0x65, 0x6e, 0x76, - 0x07, 0x73, 0x69, 0x7a, 0x65, 0x5f, 0x63, 0x62, 0x00, 0x01, 0x03, 0x65, - 0x6e, 0x76, 0x0d, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x5f, 0x70, 0x6e, - 0x67, 0x5f, 0x63, 0x62, 0x00, 0x02, 0x03, 0x04, 0x03, 0x00, 0x01, 0x02, - 0x07, 0x2d, 0x03, 0x0d, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x70, 0x74, - 0x79, 0x5f, 0x66, 0x77, 0x64, 0x00, 0x03, 0x08, 0x73, 0x69, 0x7a, 0x65, - 0x5f, 0x66, 0x77, 0x64, 0x00, 0x04, 0x0e, 0x64, 0x65, 0x63, 0x6f, 0x64, - 0x65, 0x5f, 0x70, 0x6e, 0x67, 0x5f, 0x66, 0x77, 0x64, 0x00, 0x05, 0x0a, - 0x28, 0x03, 0x0c, 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x20, 0x03, - 0x10, 0x00, 0x0b, 0x0a, 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x10, - 0x01, 0x0b, 0x0e, 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x20, 0x03, - 0x20, 0x04, 0x10, 0x02, 0x0b, + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x18, 0x03, 0x60, 0x04, 0x7f, 0x7f, 0x7f, + 0x7f, 0x00, 0x60, 0x03, 0x7f, 0x7f, 0x7f, 0x01, 0x7f, 0x60, 0x05, 0x7f, 0x7f, 0x7f, 0x7f, 0x7f, + 0x01, 0x7f, 0x02, 0x36, 0x03, 0x03, 0x65, 0x6e, 0x76, 0x0c, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, + 0x70, 0x74, 0x79, 0x5f, 0x63, 0x62, 0x00, 0x00, 0x03, 0x65, 0x6e, 0x76, 0x07, 0x73, 0x69, 0x7a, + 0x65, 0x5f, 0x63, 0x62, 0x00, 0x01, 0x03, 0x65, 0x6e, 0x76, 0x0d, 0x64, 0x65, 0x63, 0x6f, 0x64, + 0x65, 0x5f, 0x70, 0x6e, 0x67, 0x5f, 0x63, 0x62, 0x00, 0x02, 0x03, 0x04, 0x03, 0x00, 0x01, 0x02, + 0x07, 0x2d, 0x03, 0x0d, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x70, 0x74, 0x79, 0x5f, 0x66, 0x77, + 0x64, 0x00, 0x03, 0x08, 0x73, 0x69, 0x7a, 0x65, 0x5f, 0x66, 0x77, 0x64, 0x00, 0x04, 0x0e, 0x64, + 0x65, 0x63, 0x6f, 0x64, 0x65, 0x5f, 0x70, 0x6e, 0x67, 0x5f, 0x66, 0x77, 0x64, 0x00, 0x05, 0x0a, + 0x28, 0x03, 0x0c, 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x20, 0x03, 0x10, 0x00, 0x0b, 0x0a, + 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x10, 0x01, 0x0b, 0x0e, 0x00, 0x20, 0x00, 0x20, 0x01, + 0x20, 0x02, 0x20, 0x03, 0x20, 0x04, 0x10, 0x02, 0x0b, ]); export type WritePtyCallback = ( terminal: number, userdata: number, dataPtr: number, - dataLen: number, + dataLen: number ) => void; /** @@ -61,11 +57,7 @@ export type WritePtyCallback = ( * cell_h@8:u32) and returns 1 to indicate "responded" or 0 to drop the * query. */ -export type SizeCallback = ( - terminal: number, - userdata: number, - outSizePtr: number, -) => number; +export type SizeCallback = (terminal: number, userdata: number, outSizePtr: number) => number; /** * DECODE_PNG callback: receives PNG bytes at dataPtr / dataLen, decodes @@ -79,7 +71,7 @@ export type DecodePngCallback = ( allocator: number, dataPtr: number, dataLen: number, - outImagePtr: number, + outImagePtr: number ) => number; /** @@ -102,7 +94,7 @@ export interface TrampolineExports { export function makeCallbackTrampolines( writePtyCb: WritePtyCallback, sizeCb: SizeCallback, - decodePngCb: DecodePngCallback, + decodePngCb: DecodePngCallback ): TrampolineExports { if (!compiled) compiled = new WebAssembly.Module(TRAMPOLINE_BYTES); const inst = new WebAssembly.Instance(compiled, { From e7814195dfdf8d8dda6739f6d59c04a30171d15e Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 29 Apr 2026 18:32:37 -0400 Subject: [PATCH 25/29] ci: raise WASM size limit to 768 KB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff6e9b9..fddec90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,8 +103,8 @@ jobs: run: | SIZE=$(stat -c%s ghostty-vt.wasm) echo "WASM size: $SIZE bytes" - if [ "$SIZE" -gt 524288 ]; then - echo "❌ Error: WASM exceeds 512 KB limit" + if [ "$SIZE" -gt 786432 ]; then + echo "❌ Error: WASM exceeds 768 KB limit" exit 1 fi From 1569b9f844433d0212b0f67c4b10f76ecafe48d2 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 6 May 2026 18:34:34 -0400 Subject: [PATCH 26/29] fix: Distinguish default fg/bg from explicit RGB(0,0,0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/buffer.ts | 16 ++++++++++------ lib/ghostty.ts | 26 +++++++++++++++++++++----- lib/renderer.ts | 20 ++++++++++++++------ lib/types.ts | 10 ++++++++-- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/lib/buffer.ts b/lib/buffer.ts index 031a493..bc52741 100644 --- a/lib/buffer.ts +++ b/lib/buffer.ts @@ -105,12 +105,14 @@ export class Buffer implements IBuffer { // Create a null cell (codepoint=0, default colors, no flags) const nullCellData: GhosttyCell = { codepoint: 0, - fg_r: 204, - fg_g: 204, - fg_b: 204, + fg_r: 0, + fg_g: 0, + fg_b: 0, bg_r: 0, bg_g: 0, bg_b: 0, + fgIsDefault: true, + bgIsDefault: true, flags: 0, width: 1, hyperlink_id: 0, @@ -245,12 +247,14 @@ export class BufferLine implements IBufferLine { return new BufferCell( { codepoint: 0, - fg_r: 204, - fg_g: 204, - fg_b: 204, + fg_r: 0, + fg_g: 0, + fg_b: 0, bg_r: 0, bg_g: 0, bg_b: 0, + fgIsDefault: true, + bgIsDefault: true, flags: 0, width: 1, hyperlink_id: 0, diff --git a/lib/ghostty.ts b/lib/ghostty.ts index a615479..29e92ff 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -1234,10 +1234,13 @@ export class GhosttyTerminal { } // Resolved fg/bg. Returns INVALID_VALUE (non-zero) when the cell - // has no explicit color; leave the pool default (0,0,0) which the - // renderer interprets as "use terminal default." + // has no explicit color; mark fg/bgIsDefault so the renderer + // applies the theme default rather than rendering literal black + // (the rgb triple stays zeroed but is meaningless when isDefault). cell.fg_r = cell.fg_g = cell.fg_b = 0; cell.bg_r = cell.bg_g = cell.bg_b = 0; + cell.fgIsDefault = true; + cell.bgIsDefault = true; if ( this.exports.ghostty_render_state_row_cells_get( this.rowCells, @@ -1249,6 +1252,7 @@ export class GhosttyTerminal { cell.fg_r = u8[0]!; cell.fg_g = u8[1]!; cell.fg_b = u8[2]!; + cell.fgIsDefault = false; } if ( this.exports.ghostty_render_state_row_cells_get( @@ -1261,6 +1265,7 @@ export class GhosttyTerminal { cell.bg_r = u8[0]!; cell.bg_g = u8[1]!; cell.bg_b = u8[2]!; + cell.bgIsDefault = false; } // Read the per-cell style and pack the booleans into the flags @@ -1366,6 +1371,8 @@ export class GhosttyTerminal { cell.codepoint = 0; cell.fg_r = cell.fg_g = cell.fg_b = 0; cell.bg_r = cell.bg_g = cell.bg_b = 0; + cell.fgIsDefault = true; + cell.bgIsDefault = true; cell.flags = 0; cell.width = 1; cell.hyperlink_id = 0; @@ -1631,6 +1638,9 @@ export class GhosttyTerminal { let r = 0; let g = 0; let b = 0; + // tag === 0 (NONE): no explicit color — the cell uses the terminal's + // default fg/bg. PALETTE / RGB are explicit; record the resolved RGB. + const isDefault = tag === 0; if (tag === 1 /* PALETTE */ && palette) { const idx = view.getUint8(colorPtr + 8); r = palette[idx * 3 + 0]!; @@ -1645,10 +1655,12 @@ export class GhosttyTerminal { cell.fg_r = r; cell.fg_g = g; cell.fg_b = b; + cell.fgIsDefault = isDefault; } else { cell.bg_r = r; cell.bg_g = g; cell.bg_b = b; + cell.bgIsDefault = isDefault; } } @@ -1712,6 +1724,8 @@ export class GhosttyTerminal { bg_r: 0, bg_g: 0, bg_b: 0, + fgIsDefault: true, + bgIsDefault: true, flags: 0, width: 1, hyperlink_id: 0, @@ -1901,12 +1915,14 @@ export class GhosttyTerminal { for (let i = this.cellPool.length; i < total; i++) { this.cellPool.push({ codepoint: 0, - fg_r: 204, - fg_g: 204, - fg_b: 204, + fg_r: 0, + fg_g: 0, + fg_b: 0, bg_r: 0, bg_g: 0, bg_b: 0, + fgIsDefault: true, + bgIsDefault: true, flags: 0, width: 1, hyperlink_id: 0, diff --git a/lib/renderer.ts b/lib/renderer.ts index cce919a..177f83d 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -726,10 +726,15 @@ export class CanvasRenderer { bg_b = cell.fg_b; } - // Only draw cell background if it's different from the default (black) - // This lets the theme background (drawn earlier) show through for default cells - const isDefaultBg = bg_r === 0 && bg_g === 0 && bg_b === 0; - if (!isDefaultBg) { + // Cells with the default bg let the line-level theme.background fill + // (drawn earlier in renderLine) show through. Cells with an explicit + // bg — including literal RGB(0,0,0) — get painted here. The cell's + // bgIsDefault flag carries the GhosttyStyleColor tag from upstream; + // we cannot infer it from the RGB triple because (0,0,0) is a valid + // explicit color (programs emit it for "true black" backgrounds, e.g. + // letterboxed image renderings). + const useThemeBg = (cell.flags & CellFlags.INVERSE) ? cell.fgIsDefault : cell.bgIsDefault; + if (!useThemeBg) { this.ctx.fillStyle = this.rgbToCSS(bg_r, bg_g, bg_b); this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); } @@ -790,8 +795,11 @@ export class CanvasRenderer { fg_b = cell.bg_b; } - const fgIsDefault = fg_r === 0 && fg_g === 0 && fg_b === 0; - this.ctx.fillStyle = fgIsDefault ? this.theme.foreground : this.rgbToCSS(fg_r, fg_g, fg_b); + // Same reasoning as the bg path: only fall back to theme.foreground + // when the cell has the default fg (tag NONE), not when its explicit + // RGB happens to be (0,0,0). + const useThemeFg = (cell.flags & CellFlags.INVERSE) ? cell.bgIsDefault : cell.fgIsDefault; + this.ctx.fillStyle = useThemeFg ? this.theme.foreground : this.rgbToCSS(fg_r, fg_g, fg_b); } // Apply faint effect diff --git a/lib/types.ts b/lib/types.ts index 014820c..e8dd909 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1017,12 +1017,18 @@ export type TerminalHandle = number; */ export interface GhosttyCell { codepoint: number; // u32 (Unicode codepoint - first codepoint of grapheme) - fg_r: number; // u8 (foreground red) + fg_r: number; // u8 (foreground red, valid only when fgIsDefault is false) fg_g: number; // u8 (foreground green) fg_b: number; // u8 (foreground blue) - bg_r: number; // u8 (background red) + bg_r: number; // u8 (background red, valid only when bgIsDefault is false) bg_g: number; // u8 (background green) bg_b: number; // u8 (background blue) + // Whether the cell has an explicit fg/bg color or should use the + // terminal's default. Mirrors the GhosttyStyleColor tag (NONE = default). + // The renderer must consult these instead of treating RGB(0,0,0) as + // "default" — explicit literal black is a valid color. + fgIsDefault: boolean; + bgIsDefault: boolean; flags: number; // u8 (style flags bitfield) width: number; // u8 (character width: 1=normal, 2=wide, etc.) hyperlink_id: number; // u16 (0 = no link, >0 = hyperlink ID in set) From 0822d6ef4207632eda2320ace0061e8e51a297a9 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 6 May 2026 18:36:46 -0400 Subject: [PATCH 27/29] fix: tile-edge seams in kitty placeholder slice rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/renderer.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/renderer.ts b/lib/renderer.ts index 177f83d..51c8fea 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -1056,6 +1056,13 @@ export class CanvasRenderer { const destX = x * this.metrics.width; const destY = y * this.metrics.height; + // Source-rect coords are fractional whenever pixels.{width,height} doesn't + // divide evenly by placement.{gridCols,gridRows}. With smoothing on, each + // slice is sampled with bilinear interpolation clamped to its own source + // rect, producing visible seams between adjacent cells (the classic + // tile-edge artifact). Disable smoothing for the slice draw. + const prevSmoothing = this.ctx.imageSmoothingEnabled; + this.ctx.imageSmoothingEnabled = false; this.ctx.drawImage( canvas, srcX, @@ -1067,6 +1074,7 @@ export class CanvasRenderer { this.metrics.width, this.metrics.height ); + this.ctx.imageSmoothingEnabled = prevSmoothing; return true; } From 4a0ae7c9623381be87421dfdf118b0a04d57977b Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 6 May 2026 22:00:23 -0400 Subject: [PATCH 28/29] feat(render): Replace perpetual rAF loop with event-driven render scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/renderer.ts | 28 +++++++++++- lib/terminal.ts | 112 ++++++++++++++++++++++++++++++++++-------------- 2 files changed, 107 insertions(+), 33 deletions(-) diff --git a/lib/renderer.ts b/lib/renderer.ts index 51c8fea..f852c2b 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -149,6 +149,13 @@ export class CanvasRenderer { private cursorBlinkInterval?: number; private lastCursorPosition: { x: number; y: number } = { x: 0, y: 0 }; + // Hook called whenever the renderer's own internal state (today: cursor + // blink toggle) changes such that the next frame would look different. + // Set by Terminal so it can wake its render scheduler. Without this, an + // event-driven Terminal that has gone idle would never repaint the + // blinking cursor. + private onRequestRender: (() => void) | null = null; + // Viewport tracking (for scrolling) private lastViewportY: number = 0; @@ -1234,11 +1241,23 @@ export class CanvasRenderer { // Cursor Blinking // ========================================================================== + /** + * Set a callback the renderer invokes when its internal state changes + * outside the normal render-driven path (today: cursor-blink toggles). + * Lets an event-driven Terminal wake its render scheduler instead of + * polling every frame to catch the blink flip. + */ + public setOnRequestRender(fn: (() => void) | null): void { + this.onRequestRender = fn; + } + private startCursorBlink(): void { // xterm.js uses ~530ms blink interval this.cursorBlinkInterval = window.setInterval(() => { this.cursorVisible = !this.cursorVisible; - // Note: Render loop should redraw cursor line automatically + // Wake the render scheduler so the cursor cell is actually + // repainted with the new visibility state. + this.onRequestRender?.(); }, 530); } @@ -1420,7 +1439,9 @@ export class CanvasRenderer { * Set the currently hovered hyperlink ID for rendering underlines */ public setHoveredHyperlinkId(hyperlinkId: number): void { + if (this.hoveredHyperlinkId === hyperlinkId) return; this.hoveredHyperlinkId = hyperlinkId; + this.onRequestRender?.(); } /** @@ -1435,7 +1456,12 @@ export class CanvasRenderer { endY: number; } | null ): void { + // Coarse change check — link-detection is rate-limited upstream and + // these setters are only called on hover transitions, so identity + // comparison is enough to dedupe back-to-back clears. + if (this.hoveredLinkRange === range) return; this.hoveredLinkRange = range; + this.onRequestRender?.(); } /** diff --git a/lib/terminal.ts b/lib/terminal.ts index 031ab6a..d913de3 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -502,6 +502,8 @@ export class Terminal implements ITerminalCore { // Forward selection change events this.selectionManager.onSelectionChange(() => { this.selectionChangeEmitter.fire(); + // Selection rows need to repaint with the highlight overlay. + this.requestRender(); }); // Initialize link detection system @@ -530,8 +532,16 @@ export class Terminal implements ITerminalCore { // Render initial blank screen (force full redraw) this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity); - // Start render loop - this.startRenderLoop(); + // Wire the renderer back to the render scheduler so internal + // state changes (cursor blink) wake the loop on demand. + this.renderer.setOnRequestRender(() => this.requestRender()); + + // Run one synchronous render+cursor-poll to mirror the prior + // loop's first iteration. Some downstream callers + // (notably refreshRowMetaCache for isRowWrapped) walk the WASM + // row iterator immediately after open() and rely on the second + // update() / clearDirty pair to settle WASM state. + this.renderTick(); // Focus input (auto-focus so user can start typing immediately) this.focus(); @@ -600,7 +610,9 @@ export class Terminal implements ITerminalCore { requestAnimationFrame(callback); } - // Render will happen on next animation frame + // Wake the render scheduler — the write almost certainly mutated + // visible state. Idempotent if a render is already pending. + this.requestRender(); } /** @@ -711,9 +723,10 @@ export class Terminal implements ITerminalCore { console.error('Terminal resize failed:', e); } - // Flush any writes that were queued during resize, then restart render loop + // Flush any writes that were queued during resize, then schedule a + // render to pick up the new dimensions / flushed writes. this.flushWriteQueue(); - this.startRenderLoop(); + this.requestRender(); } /** @@ -939,6 +952,8 @@ export class Terminal implements ITerminalCore { if (scrollbackLength > 0) { this.showScrollbar(); } + + this.requestRender(); } } @@ -959,6 +974,7 @@ export class Terminal implements ITerminalCore { this.viewportY = scrollbackLength; this.scrollEmitter.fire(this.viewportY); this.showScrollbar(); + this.requestRender(); } } @@ -973,6 +989,7 @@ export class Terminal implements ITerminalCore { if (this.getScrollbackLength() > 0) { this.showScrollbar(); } + this.requestRender(); } } @@ -992,6 +1009,8 @@ export class Terminal implements ITerminalCore { if (scrollbackLength > 0) { this.showScrollbar(); } + + this.requestRender(); } } @@ -1018,6 +1037,7 @@ export class Terminal implements ITerminalCore { if (scrollbackLength > 0) { this.showScrollbar(); } + this.requestRender(); return; } @@ -1066,6 +1086,8 @@ export class Terminal implements ITerminalCore { this.scrollAnimationFrame = undefined; this.scrollAnimationStartTime = undefined; this.scrollAnimationStartY = undefined; + // Final-position render + this.requestRender(); return; } @@ -1086,6 +1108,11 @@ export class Terminal implements ITerminalCore { this.showScrollbar(); } + // Each tick mutates viewportY, so the main render path needs to + // catch up. The animateScroll rAF below only advances the scroll + // state; rendering is the renderTick's job. + this.requestRender(); + // Continue animation this.scrollAnimationFrame = requestAnimationFrame(this.animateScroll); }; @@ -1185,36 +1212,57 @@ export class Terminal implements ITerminalCore { } /** - * Start the render loop + * Schedule a single render on the next animation frame. No-op if one + * is already pending or the terminal is closed/disposed. + * + * Replaces the previous perpetual rAF chain, which kept a CPU core + * hot at ~60Hz even on a static screen because every frame paid for a + * render() entry/exit and a getCursor() round-trip into WASM. With + * this design, the terminal goes idle (zero JS work, zero WASM calls) + * once the last event-driven render is done, until the next event + * wakes it via requestRender(). + * + * Wake points are added on every event source that mutates renderable + * state: writes from the PTY, scrolls, resizes, mouse motion (link + * hover), selection changes, the cursor-blink interval (via the + * renderer's onRequestRender callback), and each smooth-scroll tick. + * + * Alternative design we considered: leave the rAF chain in place but + * have it short-circuit when no work is pending and self-cancel after + * N idle frames, with the same wake points re-arming it. End-state + * CPU is identical; the difference is purely code shape (a perpetual + * loop with self-cancel logic vs. ad-hoc rAF scheduling). We picked + * this shape for simplicity. */ - private startRenderLoop(): void { - if (this.animationFrameId) return; // already running - const loop = () => { - if (!this.isDisposed && this.isOpen) { - // Render using WASM's native dirty tracking - // The render() method: - // 1. Calls update() once to sync state and check dirty flags - // 2. Only redraws dirty rows when forceAll=false - // 3. Always calls clearDirty() at the end - this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity); - - // Check for cursor movement (Phase 2: onCursorMove event) - // Note: getCursor() reads from already-updated render state (from render() above) - const cursor = this.wasmTerm!.getCursor(); - if (cursor.y !== this.lastCursorY) { - this.lastCursorY = cursor.y; - this.cursorMoveEmitter.fire(); - } + private requestRender(): void { + if (this.animationFrameId !== undefined) return; + if (this.isDisposed || !this.isOpen) return; + this.animationFrameId = requestAnimationFrame(this.renderTick); + } - // Note: onRender event is intentionally not fired in the render loop - // to avoid performance issues. For now, consumers can use requestAnimationFrame - // if they need frame-by-frame updates. + private renderTick = (): void => { + this.animationFrameId = undefined; + if (this.isDisposed || !this.isOpen) return; - this.animationFrameId = requestAnimationFrame(loop); - } - }; - loop(); - } + // Render using WASM's native dirty tracking + // The render() method: + // 1. Calls update() once to sync state and check dirty flags + // 2. Only redraws dirty rows when forceAll=false + // 3. Always calls clearDirty() at the end + this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity); + + // Check for cursor movement (Phase 2: onCursorMove event) + // Note: getCursor() reads from already-updated render state (from render() above) + const cursor = this.wasmTerm!.getCursor(); + if (cursor.y !== this.lastCursorY) { + this.lastCursorY = cursor.y; + this.cursorMoveEmitter.fire(); + } + + // Note: onRender event is intentionally not fired here to avoid + // performance issues. Consumers can use requestAnimationFrame if + // they need frame-by-frame updates. + }; /** * Get a line from native WASM scrollback buffer From b6ef3533bbfcdb8839e8c9dc3329cdc37db1cd19 Mon Sep 17 00:00:00 2001 From: Evan Wies Date: Wed, 6 May 2026 22:01:59 -0400 Subject: [PATCH 29/29] feat(render): Render block elements as fillRect instead of font glyphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/renderer.ts | 126 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/lib/renderer.ts b/lib/renderer.ts index f852c2b..676a45d 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -827,7 +827,21 @@ export class CanvasRenderer { // Simple cell - single codepoint char = String.fromCodePoint(cell.codepoint || 32); // Default to space if null } - this.ctx.fillText(char, textX, textY); + + // Block elements (U+2580..U+259F) draw as fillRect using the same + // fillStyle. The browser's font rasterization of these glyphs leaves + // sub-pixel gaps that line up into a visible cell grid in half-block + // image renderings (ansimage, pixterm) at dpr=1; native terminals + // (Ghostty, kitty, alacritty) draw them programmatically for the same + // reason. Only takes the fast path for simple (non-grapheme) cells. + if ( + cell.grapheme_len === 0 && + this.renderBlockElement(cell.codepoint, cellX, cellY, cellWidth) + ) { + // handled by renderBlockElement + } else { + this.ctx.fillText(char, textX, textY); + } // Reset alpha if (cell.flags & CellFlags.FAINT) { @@ -1011,6 +1025,116 @@ export class CanvasRenderer { return canvas; } + /** + * Render a Block Elements codepoint (U+2580..U+259F) as fillRect(s) in + * the current fillStyle. Returns true if the codepoint is a handled + * block element; false to fall through to fillText. + * + * Drawing block elements through the font produces ~1-device-px gaps + * at cell edges at integer dpr because the rasterized glyph doesn't + * exactly fill the cell box. In half-block image renderings (ansimage, + * pixterm) those gaps line up into a visible cell grid. Native + * terminals draw block elements programmatically for the same reason. + * + * The eighths blocks (U+2581..U+2587 lower; U+2589..U+258F left) and + * full block (U+2588) are stripes of n/8 of the cell. Shading blocks + * (U+2591..U+2593) modulate globalAlpha for 25/50/75% fill. Quadrant + * blocks (U+2596..U+259F) split the cell into a 2x2 grid and fill + * some subset. + */ + private renderBlockElement( + codepoint: number, + cellX: number, + cellY: number, + cellWidth: number + ): boolean { + if (codepoint < 0x2580 || codepoint > 0x259f) return false; + + const w = cellWidth; + const h = this.metrics.height; + + // Upper half ▀ + if (codepoint === 0x2580) { + this.ctx.fillRect(cellX, cellY, w, Math.round(h / 2)); + return true; + } + + // Lower n/8 blocks ▁▂▃▄▅▆▇ + full block █ (= 8/8) + if (codepoint >= 0x2581 && codepoint <= 0x2588) { + const eighths = codepoint - 0x2580; + const blockH = Math.round((h * eighths) / 8); + this.ctx.fillRect(cellX, cellY + h - blockH, w, blockH); + return true; + } + + // Left n/8 blocks ▉▊▋▌▍▎▏ — eighths decreases as codepoint increases + if (codepoint >= 0x2589 && codepoint <= 0x258f) { + const eighths = 0x2590 - codepoint; + const blockW = Math.round((w * eighths) / 8); + this.ctx.fillRect(cellX, cellY, blockW, h); + return true; + } + + // Right half ▐ + if (codepoint === 0x2590) { + const left = Math.round(w / 2); + this.ctx.fillRect(cellX + left, cellY, w - left, h); + return true; + } + + // Shading ░▒▓ — modulate globalAlpha against current fillStyle + if (codepoint >= 0x2591 && codepoint <= 0x2593) { + const alphaForShade = [0.25, 0.5, 0.75][codepoint - 0x2591]; + const prev = this.ctx.globalAlpha; + this.ctx.globalAlpha = prev * alphaForShade; + this.ctx.fillRect(cellX, cellY, w, h); + this.ctx.globalAlpha = prev; + return true; + } + + // Upper 1/8 ▔ + if (codepoint === 0x2594) { + this.ctx.fillRect(cellX, cellY, w, Math.round(h / 8)); + return true; + } + + // Right 1/8 ▕ + if (codepoint === 0x2595) { + const left = Math.round((w * 7) / 8); + this.ctx.fillRect(cellX + left, cellY, w - left, h); + return true; + } + + // Quadrants ▖▗▘▙▚▛▜▝▞▟ at U+2596..U+259F. Bitmap of which corners + // (UL, UR, LL, LR) are filled per codepoint. + const QUAD_UL = 0b1000; + const QUAD_UR = 0b0100; + const QUAD_LL = 0b0010; + const QUAD_LR = 0b0001; + const quadMap: Record = { + 0x2596: QUAD_LL, + 0x2597: QUAD_LR, + 0x2598: QUAD_UL, + 0x2599: QUAD_UL | QUAD_LL | QUAD_LR, + 0x259a: QUAD_UL | QUAD_LR, + 0x259b: QUAD_UL | QUAD_UR | QUAD_LL, + 0x259c: QUAD_UL | QUAD_UR | QUAD_LR, + 0x259d: QUAD_UR, + 0x259e: QUAD_UR | QUAD_LL, + 0x259f: QUAD_UR | QUAD_LL | QUAD_LR, + }; + const quads = quadMap[codepoint]; + if (quads === undefined) return false; + const halfW = Math.round(w / 2); + const halfH = Math.round(h / 2); + if (quads & QUAD_UL) this.ctx.fillRect(cellX, cellY, halfW, halfH); + if (quads & QUAD_UR) this.ctx.fillRect(cellX + halfW, cellY, w - halfW, halfH); + if (quads & QUAD_LL) this.ctx.fillRect(cellX, cellY + halfH, halfW, h - halfH); + if (quads & QUAD_LR) + this.ctx.fillRect(cellX + halfW, cellY + halfH, w - halfW, h - halfH); + return true; + } + /** * Substitute a cell's text rendering with a slice of a kitty graphics * image. Called from renderCellText when the cell's codepoint is