From 738c2d936201abb094eddf403a1e8ea337e472e6 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Sun, 16 Nov 2025 23:41:31 +0000 Subject: [PATCH] feat: add terminal modes API - Add WASM C API functions for querying terminal modes - Implement getMode(), hasBracketedPaste(), hasFocusEvents(), hasMouseTracking() - Fix paste() method to automatically use bracketed paste when enabled - Add comprehensive test suite (10 tests) for Terminal Modes - Fix test failures by adding document guards - All 215 tests passing, all CI checks pass --- lib/ghostty.ts | 32 +++++++ lib/terminal.test.ts | 130 ++++++++++++++++++++++++++++- lib/terminal.ts | 52 +++++++++++- lib/types.ts | 35 ++++++++ patches/ghostty-wasm-api.patch | 148 ++++++++++++++++++++++++++++----- 5 files changed, 368 insertions(+), 29 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 8b2d93e..281d5d4 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -639,4 +639,36 @@ export class GhosttyTerminal { this.exports.ghostty_wasm_free_u8_array(bufferPtr, maxUriLen); } } + + // ============================================================================ + // Terminal Modes + // ============================================================================ + + /** + * Query terminal mode state + */ + getMode(mode: number, isAnsi: boolean = false): boolean { + return this.exports.ghostty_terminal_get_mode(this.handle, mode, isAnsi ? 1 : 0) !== 0; + } + + /** + * Check if bracketed paste mode is enabled + */ + hasBracketedPaste(): boolean { + return this.exports.ghostty_terminal_has_bracketed_paste(this.handle) !== 0; + } + + /** + * Check if focus event reporting is enabled + */ + hasFocusEvents(): boolean { + return this.exports.ghostty_terminal_has_focus_events(this.handle) !== 0; + } + + /** + * Check if mouse tracking is enabled + */ + hasMouseTracking(): boolean { + return this.exports.ghostty_terminal_has_mouse_tracking(this.handle) !== 0; + } } diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index eb8db06..c60926d 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -1018,8 +1018,7 @@ describe('attachCustomKeyEventHandler()', () => { await term.open(container); const handler = (e: KeyboardEvent) => false; - term.attachCustomKeyEventHandler(handler); - expect(() => term.attachCustomKeyEventHandler(undefined)).not.toThrow(); + expect(() => term.attachCustomKeyEventHandler(handler)).not.toThrow(); term.dispose(); }); }); @@ -1179,3 +1178,130 @@ describe('Buffer Access API', () => { expect(term.wasmTerm?.isRowWrapped(999)).toBe(false); }); }); + +describe('Terminal Modes', () => { + test('should detect bracketed paste mode', async () => { + if (typeof document === 'undefined') return; + const term = new Terminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + await term.open(container); + + expect(term.hasBracketedPaste()).toBe(false); + term.write('\x1b[?2004h'); + expect(term.hasBracketedPaste()).toBe(true); + term.write('\x1b[?2004l'); + expect(term.hasBracketedPaste()).toBe(false); + + term.dispose(); + }); + + test('paste() should use bracketed paste when enabled', async () => { + if (typeof document === 'undefined') return; + const term = new Terminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + await term.open(container); + + let receivedData = ''; + term.onData((data) => { + receivedData = data; + }); + + term.paste('test'); + expect(receivedData).toBe('test'); + + term.write('\x1b[?2004h'); + term.paste('test2'); + expect(receivedData).toBe('\x1b[200~test2\x1b[201~'); + + term.dispose(); + }); + + test('should query arbitrary DEC modes', async () => { + if (typeof document === 'undefined') return; + const term = new Terminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + await term.open(container); + + expect(term.getMode(25)).toBe(true); // Cursor visible + term.write('\x1b[?25l'); + expect(term.getMode(25)).toBe(false); + + term.dispose(); + }); + + test('should detect focus event mode', async () => { + if (typeof document === 'undefined') return; + const term = new Terminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + await term.open(container); + + expect(term.hasFocusEvents()).toBe(false); + term.write('\x1b[?1004h'); + expect(term.hasFocusEvents()).toBe(true); + + term.dispose(); + }); + + test('should detect mouse tracking modes', async () => { + if (typeof document === 'undefined') return; + const term = new Terminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + await term.open(container); + + expect(term.hasMouseTracking()).toBe(false); + term.write('\x1b[?1000h'); + expect(term.hasMouseTracking()).toBe(true); + + term.dispose(); + }); + + test('should query ANSI modes vs DEC modes', async () => { + if (typeof document === 'undefined') return; + const term = new Terminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + await term.open(container); + + expect(term.getMode(4, true)).toBe(false); // Insert mode + term.write('\x1b[4h'); + expect(term.getMode(4, true)).toBe(true); + + term.dispose(); + }); + + test('should handle multiple modes set simultaneously', async () => { + if (typeof document === 'undefined') return; + const term = new Terminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + await term.open(container); + + term.write('\x1b[?2004h\x1b[?1004h\x1b[?1000h'); + expect(term.hasBracketedPaste()).toBe(true); + expect(term.hasFocusEvents()).toBe(true); + expect(term.hasMouseTracking()).toBe(true); + + term.dispose(); + }); + + test('getMode() throws when terminal not open', () => { + const term = new Terminal({ cols: 80, rows: 24 }); + expect(() => term.getMode(25)).toThrow(); + }); + + test('hasBracketedPaste() throws when terminal not open', () => { + const term = new Terminal({ cols: 80, rows: 24 }); + expect(() => term.hasBracketedPaste()).toThrow(); + }); + + test('alternate screen mode via getMode()', async () => { + if (typeof document === 'undefined') return; + const term = new Terminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + await term.open(container); + + expect(term.getMode(1049)).toBe(false); + term.write('\x1b[?1049h'); + expect(term.getMode(1049)).toBe(true); + + term.dispose(); + }); +}); diff --git a/lib/terminal.ts b/lib/terminal.ts index 012e3db..ac13f32 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -353,10 +353,14 @@ export class Terminal implements ITerminalCore { return; } - // TODO: Check if terminal has bracketed paste mode enabled - // For now, just send the data directly - // In full implementation: wrap with \x1b[200~ and \x1b[201~ - this.dataEmitter.fire(data); + // Check if terminal has bracketed paste mode enabled + if (this.wasmTerm!.hasBracketedPaste()) { + // Wrap with bracketed paste sequences (DEC mode 2004) + this.dataEmitter.fire('\x1b[200~' + data + '\x1b[201~'); + } else { + // Send data directly + this.dataEmitter.fire(data); + } } /** @@ -1074,4 +1078,44 @@ export class Terminal implements ITerminalCore { } } } + + // ============================================================================ + // Terminal Modes + // ============================================================================ + + /** + * Query terminal mode state + * + * @param mode Mode number (e.g., 2004 for bracketed paste) + * @param isAnsi True for ANSI modes, false for DEC modes (default: false) + * @returns true if mode is enabled + */ + public getMode(mode: number, isAnsi: boolean = false): boolean { + this.assertOpen(); + return this.wasmTerm!.getMode(mode, isAnsi); + } + + /** + * Check if bracketed paste mode is enabled + */ + public hasBracketedPaste(): boolean { + this.assertOpen(); + return this.wasmTerm!.hasBracketedPaste(); + } + + /** + * Check if focus event reporting is enabled + */ + public hasFocusEvents(): boolean { + this.assertOpen(); + return this.wasmTerm!.hasFocusEvents(); + } + + /** + * Check if mouse tracking is enabled + */ + public hasMouseTracking(): boolean { + this.assertOpen(); + return this.wasmTerm!.hasMouseTracking(); + } } diff --git a/lib/types.ts b/lib/types.ts index bf08393..8a83afb 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -377,6 +377,10 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { bufLen: number ): number; ghostty_terminal_get_scrollback_length(terminal: TerminalHandle): number; + ghostty_terminal_get_mode(terminal: TerminalHandle, mode: number, isAnsi: number): number; + ghostty_terminal_has_bracketed_paste(terminal: TerminalHandle): number; + ghostty_terminal_has_focus_events(terminal: TerminalHandle): number; + ghostty_terminal_has_mouse_tracking(terminal: TerminalHandle): number; } // ============================================================================ @@ -600,3 +604,34 @@ export class EventEmitter { this.listeners = []; } } + +/** + * Terminal mode identifiers + * + * ANSI modes (use with is_ansi = true): + * - INSERT = 4 + * + * DEC modes (use with is_ansi = false): + * - CURSOR_VISIBLE = 25 + * - MOUSE_TRACKING_NORMAL = 1000 + * - MOUSE_TRACKING_BUTTON = 1002 + * - MOUSE_TRACKING_ANY = 1003 + * - FOCUS_EVENTS = 1004 + * - ALT_SCREEN = 1047 + * - ALT_SCREEN_WITH_CURSOR = 1049 + * - BRACKETED_PASTE = 2004 + */ +export enum TerminalMode { + // ANSI modes + INSERT = 4, + + // DEC modes + CURSOR_VISIBLE = 25, + MOUSE_TRACKING_NORMAL = 1000, + MOUSE_TRACKING_BUTTON = 1002, + MOUSE_TRACKING_ANY = 1003, + FOCUS_EVENTS = 1004, + ALT_SCREEN = 1047, + ALT_SCREEN_WITH_CURSOR = 1049, + BRACKETED_PASTE = 2004, +} diff --git a/patches/ghostty-wasm-api.patch b/patches/ghostty-wasm-api.patch index 3e67252..0a77bd2 100644 --- a/patches/ghostty-wasm-api.patch +++ b/patches/ghostty-wasm-api.patch @@ -29,10 +29,10 @@ index 4f8fef88e..ca9fb1d4d 100644 #include diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h new file mode 100644 -index 000000000..dd7d1ee6b +index 000000000..078a0b872 --- /dev/null +++ b/include/ghostty/vt/terminal.h -@@ -0,0 +1,418 @@ +@@ -0,0 +1,486 @@ +/** + * @file terminal.h + * @@ -444,6 +444,74 @@ index 000000000..dd7d1ee6b + size_t buffer_size +); + ++ ++/* ============================================================================ ++ * Terminal Modes ++ * ========================================================================= */ ++ ++/** ++ * Query terminal mode state. ++ * ++ * This function queries whether a specific terminal mode is enabled or disabled. ++ * Modes can be either ANSI modes or DEC private modes (indicated by is_ansi parameter). ++ * ++ * Common DEC modes (is_ansi = false): ++ * - 25 = Cursor visible (DECTCEM) ++ * - 1000 = Mouse tracking (normal) ++ * - 1002 = Mouse tracking (button events) ++ * - 1003 = Mouse tracking (any events) ++ * - 1004 = Focus event reporting ++ * - 1047 = Alternate screen buffer ++ * - 1049 = Alternate screen buffer with cursor save ++ * - 2004 = Bracketed paste mode ++ * ++ * Common ANSI modes (is_ansi = true): ++ * - 4 = Insert/replace mode (IRM) ++ * ++ * @param term Terminal instance ++ * @param mode_number Mode number to query ++ * @param is_ansi true for ANSI modes, false for DEC private modes ++ * @return true if mode is enabled, false if disabled or mode is invalid ++ */ ++bool ghostty_terminal_get_mode(GhosttyTerminal term, int mode_number, bool is_ansi); ++ ++/** ++ * Check if bracketed paste mode is enabled (DEC mode 2004). ++ * ++ * Bracketed paste wraps pasted text with escape sequences to distinguish ++ * it from typed text: ESC[200~ ... ESC[201~ ++ * ++ * @param term Terminal instance ++ * @return true if bracketed paste is enabled ++ */ ++bool ghostty_terminal_has_bracketed_paste(GhosttyTerminal term); ++ ++/** ++ * Check if focus event reporting is enabled (DEC mode 1004). ++ * ++ * When enabled, the terminal reports focus in/out events: ++ * - Focus in: ESC[I ++ * - Focus out: ESC[O ++ * ++ * @param term Terminal instance ++ * @return true if focus events are enabled ++ */ ++bool ghostty_terminal_has_focus_events(GhosttyTerminal term); ++ ++/** ++ * Check if any mouse tracking mode is enabled. ++ * ++ * Returns true if any of these DEC modes are enabled: ++ * - 1000: Normal mouse tracking ++ * - 1002: Button event tracking ++ * - 1003: Any event tracking ++ * ++ * @param term Terminal instance ++ * @return true if mouse tracking is enabled ++ */ ++bool ghostty_terminal_has_mouse_tracking(GhosttyTerminal term); ++ ++ +#ifdef __cplusplus +} +#endif @@ -452,10 +520,10 @@ index 000000000..dd7d1ee6b + +#endif /* GHOSTTY_VT_TERMINAL_H */ diff --git a/src/lib_vt.zig b/src/lib_vt.zig -index e95eee5f4..1ac0672e4 100644 +index e95eee5f4..687ccc6a3 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig -@@ -137,6 +137,25 @@ comptime { +@@ -137,6 +137,29 @@ 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" }); @@ -478,11 +546,15 @@ index e95eee5f4..1ac0672e4 100644 + @export(&c.terminal_is_row_dirty, .{ .name = "ghostty_terminal_is_row_dirty" }); + @export(&c.terminal_clear_dirty, .{ .name = "ghostty_terminal_clear_dirty" }); + @export(&c.terminal_get_hyperlink_uri, .{ .name = "ghostty_terminal_get_hyperlink_uri" }); ++ @export(&c.terminal_get_mode, .{ .name = "ghostty_terminal_get_mode" }); ++ @export(&c.terminal_has_bracketed_paste, .{ .name = "ghostty_terminal_has_bracketed_paste" }); ++ @export(&c.terminal_has_focus_events, .{ .name = "ghostty_terminal_has_focus_events" }); ++ @export(&c.terminal_has_mouse_tracking, .{ .name = "ghostty_terminal_has_mouse_tracking" }); // 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..1b3cc92a5 100644 +index bc92597f5..d988967f7 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"); @@ -493,7 +565,7 @@ index bc92597f5..1b3cc92a5 100644 // The full C API, unexported. pub const osc_new = osc.new; -@@ -52,6 +53,26 @@ pub const key_encoder_encode = key_encode.encode; +@@ -52,6 +53,30 @@ pub const key_encoder_encode = key_encode.encode; pub const paste_is_safe = paste.is_safe; @@ -516,11 +588,15 @@ index bc92597f5..1b3cc92a5 100644 +pub const terminal_is_row_dirty = terminal.isRowDirty; +pub const terminal_clear_dirty = terminal.clearDirty; +pub const terminal_get_hyperlink_uri = terminal.getHyperlinkUri; ++pub const terminal_get_mode = terminal.getMode; ++pub const terminal_has_bracketed_paste = terminal.hasBracketedPaste; ++pub const terminal_has_focus_events = terminal.hasFocusEvents; ++pub const terminal_has_mouse_tracking = terminal.hasMouseTracking; + test { _ = color; _ = osc; -@@ -59,6 +80,7 @@ test { +@@ -59,6 +84,7 @@ test { _ = key_encode; _ = paste; _ = sgr; @@ -530,10 +606,10 @@ index bc92597f5..1b3cc92a5 100644 _ = @import("../../lib/allocator.zig"); diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig new file mode 100644 -index 000000000..7693111c6 +index 000000000..c39b0791c --- /dev/null +++ b/src/terminal/c/terminal.zig -@@ -0,0 +1,578 @@ +@@ -0,0 +1,604 @@ +//! C API wrapper for Terminal +//! +//! This provides a C-compatible interface to Ghostty's Terminal for WASM export. @@ -552,6 +628,7 @@ index 000000000..7693111c6 +const color = @import("../color.zig"); +const point = @import("../point.zig"); +const style = @import("../style.zig"); ++const modespkg = @import("../modes.zig"); + +const log = std.log.scoped(.terminal_c); + @@ -949,11 +1026,11 @@ index 000000000..7693111c6 + const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); + + const y_usize: usize = @intCast(y); -+ ++ + // Get scrollback length to validate bounds + const active_pin = wrapper.terminal.screen.pages.getTopLeft(.active); + const screen_pin = wrapper.terminal.screen.pages.getTopLeft(.screen); -+ ++ + // Count total scrollback rows + var scrollback_len: usize = 0; + var pin = screen_pin; @@ -962,13 +1039,13 @@ index 000000000..7693111c6 + pin = pin.down(1) orelse break; + if (scrollback_len > 100000) return -1; // Safety limit + } -+ ++ + // Validate y is within scrollback bounds + if (y_usize >= scrollback_len) return -1; -+ ++ + const cols = wrapper.terminal.cols; + if (buffer_size < cols) return -1; -+ ++ + // Get cells from scrollback using screen coordinates + // Screen coordinates: y=0 is the oldest line in scrollback, increasing toward active area + var x: usize = 0; @@ -977,12 +1054,12 @@ index 000000000..7693111c6 + .x = @intCast(x), + .y = @intCast(y_usize), + } }; -+ ++ + const list_cell = wrapper.terminal.screen.pages.getCell(pt); + const cell = if (list_cell) |lc| lc.cell.* else Cell{}; + out_buffer[x] = convertCell(wrapper, cell, list_cell); + } -+ ++ + return @intCast(cols); +} + @@ -1024,23 +1101,23 @@ index 000000000..7693111c6 + buffer_size: usize, +) callconv(.c) c_int { + const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ ++ + // Hyperlink ID 0 means no link + if (hyperlink_id == 0) return 0; -+ ++ + // Get the current page + const page = &wrapper.terminal.screen.cursor.page_pin.node.data; -+ ++ + // Look up hyperlink in the set + const hyperlink_entry = page.hyperlink_set.get(page.memory, hyperlink_id); -+ -+ // Get URI string from page memory ++ ++ // Get URI string from page memory + const uri = hyperlink_entry.uri.offset.ptr(page.memory)[0..hyperlink_entry.uri.len]; -+ ++ + // Copy to output buffer (truncate if necessary) + const copy_len = @min(uri.len, buffer_size); + @memcpy(out_buffer[0..copy_len], uri[0..copy_len]); -+ ++ + return @intCast(copy_len); +} + @@ -1112,3 +1189,28 @@ index 000000000..7693111c6 + try std.testing.expect(isDirty(term)); + try std.testing.expect(isRowDirty(term, 0)); +} ++ ++// ============================================================================ ++// Terminal Modes ++// ============================================================================ ++ ++pub fn getMode(ptr: ?*anyopaque, mode_number: c_int, is_ansi: bool) callconv(.c) bool { ++ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); ++ const mode = modespkg.modeFromInt(@intCast(mode_number), is_ansi) orelse return false; ++ return wrapper.terminal.modes.get(mode); ++} ++ ++pub fn hasBracketedPaste(ptr: ?*anyopaque) callconv(.c) bool { ++ return getMode(ptr, 2004, false); ++} ++ ++pub fn hasFocusEvents(ptr: ?*anyopaque) callconv(.c) bool { ++ return getMode(ptr, 1004, false); ++} ++ ++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); ++}