From b688df89e6823a89c33947108217cd805546f42c Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 23 May 2026 11:03:06 -0300 Subject: [PATCH] fix: cursor shape (DECSCUSR), Ctrl+V forwarding, and mouse scroll in alt screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent PTY-input gaps wrapped in a single port from upstream #147 because they share the same WASM-side patch surface. 1. **DECSCUSR (cursor shape)** — apps that send the `CSI Ps SP q` DECSCUSR sequence to change cursor shape (block/bar/underline) and blink state used to be silently ignored on the JS side. WASM now exports `render_state_get_cursor_style` and `render_state_get_cursor_blinking`; the renderer queries them each frame so vim/tmux insert-mode cursor styles take effect. 2. **Ctrl+V forwarding** — Ctrl+V used to be intercepted and dropped so the browser paste event could handle it. That broke apps that read raw \\x16 from the PTY (e.g. opencode triggering osascript image paste). Ctrl+V now emits \\x16 via the Ghostty key encoder AND still lets the paste event fire for text content. Cmd+V on macOS behaves as before (no byte emitted, paste event handles it). 3. **Mouse scroll in alt screen** — wheel events while in the alt screen buffer (vim, less, htop) used to bypass mouse-tracking. Now they go through the same mouse-tracking path as the main screen, so apps that subscribe to wheel events receive them in alt screen too. WASM-API patch updates: - New exports for cursor_style / cursor_blinking - Hunk headers in patches/ghostty-wasm-api.patch recounted to reflect the added lines (the original patch upstream had stale @@ headers that prevented `git apply` from succeeding) The two pre-existing "Ctrl+V/Cmd+V should not emit onData" tests were documenting the old (now-incorrect) behaviour and have been rewritten to assert the new contract: Ctrl+V → \\x16, Cmd+V → empty (encoder returns no bytes for Super modifier). Co-authored-by: Jesse Peng Inspired-by: https://github.com/coder/ghostty-web/pull/147 --- lib/ghostty.ts | 9 ++++++--- lib/input-handler.test.ts | 16 +++++++++++----- lib/input-handler.ts | 16 ++++++++++++---- lib/terminal.ts | 18 +++++++++++++++--- lib/types.ts | 3 +++ patches/ghostty-wasm-api.patch | 32 ++++++++++++++++++++++++++++---- 6 files changed, 75 insertions(+), 19 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index f0798857..747ab10e 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -31,9 +31,9 @@ export { type GhosttyCell, type GhosttyTerminalConfig, KeyEncoderOption, - type RGB, type RenderStateColors, type RenderStateCursor, + type RGB, }; /** @@ -399,8 +399,11 @@ export class GhosttyTerminal { 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 + blinking: this.exports.ghostty_render_state_get_cursor_blinking(this.handle), + style: + (['block', 'bar', 'underline'] as const)[ + this.exports.ghostty_render_state_get_cursor_style(this.handle) + ] ?? 'block', }; } diff --git a/lib/input-handler.test.ts b/lib/input-handler.test.ts index f64e1da1..7d36a5c6 100644 --- a/lib/input-handler.test.ts +++ b/lib/input-handler.test.ts @@ -1160,7 +1160,7 @@ describe('InputHandler', () => { expect(dataReceived.length).toBe(0); }); - test('allows Ctrl+V to trigger paste', () => { + test('Ctrl+V forwards \\x16 to the PTY and still allows the paste event', () => { const handler = new InputHandler( ghostty, container as any, @@ -1170,13 +1170,17 @@ describe('InputHandler', () => { } ); - // Ctrl+V should NOT call onData callback (lets paste event handle it) + // Ctrl+V emits \x16 (SYN) via the Ghostty key encoder so native PTY + // consumers (e.g. opencode image paste via osascript) receive the + // signal. The browser-side paste event still fires immediately + // after so handlePaste handles text-content paste as before. simulateKey(container, createKeyEvent('KeyV', 'v', { ctrl: true })); - expect(dataReceived.length).toBe(0); + expect(dataReceived.length).toBe(1); + expect(dataReceived[0]).toBe('\x16'); }); - test('allows Cmd+V to trigger paste', () => { + test('Cmd+V on macOS does not emit a byte (Super modifier has no terminal sequence) — paste event still fires', () => { const handler = new InputHandler( ghostty, container as any, @@ -1186,7 +1190,9 @@ describe('InputHandler', () => { } ); - // Cmd+V should NOT call onData callback (lets paste event handle it) + // The Ghostty encoder returns empty bytes for Super+V (no standard + // terminal sequence exists for Cmd modifier). The handler still + // returns early so the browser's paste event fires for text content. simulateKey(container, createKeyEvent('KeyV', 'v', { meta: true })); expect(dataReceived.length).toBe(0); diff --git a/lib/input-handler.ts b/lib/input-handler.ts index dc902496..8ac37cda 100644 --- a/lib/input-handler.ts +++ b/lib/input-handler.ts @@ -13,8 +13,7 @@ * - Captures all keyboard input (preventDefault on everything) */ -import type { Ghostty } from './ghostty'; -import type { KeyEncoder } from './ghostty'; +import type { Ghostty, KeyEncoder } from './ghostty'; import type { IKeyEvent } from './interfaces'; import { Key, KeyAction, KeyEncoderOption, Mods } from './types'; @@ -384,9 +383,18 @@ export class InputHandler { } } - // Allow Ctrl+V and Cmd+V to trigger paste event (don't preventDefault) + // Ctrl+V / Cmd+V: emit \x16 to the PTY so apps that read it natively + // (e.g. opencode image paste via osascript) receive the signal, then let + // the browser paste event fire so handlePaste covers text content. if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') { - // Let the browser's native paste event fire + const encoded = this.encoder.encode({ + key: Key.V, + mods: event.ctrlKey ? Mods.CTRL : Mods.SUPER, + action: KeyAction.PRESS, + }); + if (encoded.length > 0) { + this.onDataCallback(new TextDecoder().decode(encoded)); + } return; } diff --git a/lib/terminal.ts b/lib/terminal.ts index 902ea94c..0abd5578 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -1597,9 +1597,21 @@ export class Terminal implements ITerminalCore { const isAltScreen = this.wasmTerm?.isAlternateScreen() ?? false; if (isAltScreen) { - // Alternate screen: send arrow keys to the application - // Applications like vim handle scrolling internally - // Standard: ~3 arrow presses per wheel "click" + if (this.wasmTerm?.hasMouseTracking()) { + // App negotiated mouse tracking (e.g. vim `set mouse=a`): send SGR + // scroll sequence so the app scrolls its buffer, not the cursor. + const metrics = this.renderer?.getMetrics(); + const canvas = this.canvas; + if (metrics && canvas) { + const rect = canvas.getBoundingClientRect(); + const col = Math.max(1, Math.floor((e.clientX - rect.left) / metrics.width) + 1); + const row = Math.max(1, Math.floor((e.clientY - rect.top) / metrics.height) + 1); + const btn = e.deltaY < 0 ? 64 : 65; + this.dataEmitter.fire(`\x1b[<${btn};${col};${row}M`); + } + return; + } + // No mouse tracking: arrow-key fallback for apps like `less`. const direction = e.deltaY > 0 ? 'down' : 'up'; const count = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5); // Cap at 5 diff --git a/lib/types.ts b/lib/types.ts index 390f3ae3..cdc7bbdd 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -421,6 +421,9 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { 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; + /** Returns 0=block, 1=bar, 2=underline */ + ghostty_render_state_get_cursor_style(terminal: TerminalHandle): number; + ghostty_render_state_get_cursor_blinking(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; diff --git a/patches/ghostty-wasm-api.patch b/patches/ghostty-wasm-api.patch index bd3feb9b..0a877f54 100644 --- a/patches/ghostty-wasm-api.patch +++ b/patches/ghostty-wasm-api.patch @@ -32,7 +32,7 @@ new file mode 100644 index 000000000..c467102c3 --- /dev/null +++ b/include/ghostty/vt/terminal.h -@@ -0,0 +1,285 @@ +@@ -0,0 +1,289 @@ +/** + * @file terminal.h + * @@ -157,6 +157,10 @@ index 000000000..c467102c3 +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 cursor style: 0=block, 1=bar, 2=underline */ ++int ghostty_render_state_get_cursor_style(GhosttyTerminal term); ++/** Check if cursor is blinking */ ++bool ghostty_render_state_get_cursor_blinking(GhosttyTerminal term); + +/** Get default colors as 0xRRGGBB */ +uint32_t ghostty_render_state_get_bg_color(GhosttyTerminal term); @@ -322,7 +326,7 @@ 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 { +@@ -140,6 +140,47 @@ 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" }); @@ -340,6 +344,8 @@ index 03a883e20..1336676d7 100644 + @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_cursor_style, .{ .name = "ghostty_render_state_get_cursor_style" }); ++ @export(&c.render_state_get_cursor_blinking, .{ .name = "ghostty_render_state_get_cursor_blinking" }); + @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" }); @@ -380,7 +386,7 @@ index bc92597f5..d0ee49c1b 100644 // The full C API, unexported. pub const osc_new = osc.new; -@@ -52,6 +53,46 @@ pub const key_encoder_encode = key_encode.encode; +@@ -52,6 +53,48 @@ pub const key_encoder_encode = key_encode.encode; pub const paste_is_safe = paste.is_safe; @@ -398,6 +404,8 @@ index bc92597f5..d0ee49c1b 100644 +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_cursor_style = terminal.renderStateGetCursorStyle; ++pub const render_state_get_cursor_blinking = terminal.renderStateGetCursorBlinking; +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; @@ -440,7 +448,7 @@ new file mode 100644 index 000000000..73ae2e6fa --- /dev/null +++ b/src/terminal/c/terminal.zig -@@ -0,0 +1,1123 @@ +@@ -0,0 +1,1139 @@ +//! C API wrapper for Terminal +//! +//! This provides a minimal, high-performance interface to Ghostty's Terminal @@ -991,6 +999,22 @@ index 000000000..73ae2e6fa + return wrapper.render_state.cursor.visible; +} + ++/// Get cursor style: 0=block, 1=bar, 2=underline ++pub fn renderStateGetCursorStyle(ptr: ?*anyopaque) callconv(.c) c_int { ++ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); ++ return switch (wrapper.terminal.screens.active.cursor.cursor_style) { ++ .bar => 1, ++ .underline => 2, ++ else => 0, ++ }; ++} ++ ++/// Check if cursor is blinking ++pub fn renderStateGetCursorBlinking(ptr: ?*anyopaque) callconv(.c) bool { ++ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); ++ return wrapper.terminal.modes.get(.cursor_blinking); ++} ++ +/// Get default background color as 0xRRGGBB +pub fn renderStateGetBgColor(ptr: ?*anyopaque) callconv(.c) u32 { + const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0));