Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions lib/ghostty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ export {
type GhosttyCell,
type GhosttyTerminalConfig,
KeyEncoderOption,
type RGB,
type RenderStateColors,
type RenderStateCursor,
type RGB,
};

/**
Expand Down Expand Up @@ -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',
};
}

Expand Down
16 changes: 11 additions & 5 deletions lib/input-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1160,7 +1160,7 @@
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', () => {

Check warning on line 1163 in lib/input-handler.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`String.raw` should be used to avoid escaping `\`.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5VJhwdLTg-mVZuX0mZ&open=AZ5VJhwdLTg-mVZuX0mZ&pullRequest=13
const handler = new InputHandler(
ghostty,
container as any,
Expand All @@ -1170,13 +1170,17 @@
}
);

// 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,
Expand All @@ -1186,7 +1190,9 @@
}
);

// 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);
Expand Down
16 changes: 12 additions & 4 deletions lib/input-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instantiating a new TextDecoder() inside the handleKeyDown event handler is inefficient as it creates a new object on every Ctrl+V/Cmd+V press. Consider reusing a single TextDecoder instance (e.g., as a private member of the class) to improve performance and reduce garbage collection pressure, especially since this pattern is repeated elsewhere in the file.

}
return;
}

Expand Down
18 changes: 15 additions & 3 deletions lib/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +1600 to +1613

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The mouse coordinates (col, row) should be clamped to the terminal dimensions (this.cols, this.rows) to ensure that the generated escape sequence doesn't contain out-of-bounds values, which could confuse some TUI applications. Additionally, since the SGR sequence (\x1b[<...) is hardcoded, it is recommended to verify that SGR mouse mode (1006) is actually enabled before sending it. If SGR is not enabled, falling back to the arrow-key behavior is more robust for applications that don't support extended mouse tracking.

Suggested change
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;
}
if (this.wasmTerm?.hasMouseTracking() && this.wasmTerm?.getMode(1006, false)) {
// 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.min(this.cols, Math.floor((e.clientX - rect.left) / metrics.width) + 1));
const row = Math.max(1, Math.min(this.rows, 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

Expand Down
3 changes: 3 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 28 additions & 4 deletions patches/ghostty-wasm-api.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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
+ *
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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" });
Expand All @@ -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" });
Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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));
Expand Down