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));