Skip to content

Commit 4011a35

Browse files
jesse23sisyphus-dev-ai
andcommitted
fix: three PTY input handling gaps reported in issue #145
Bug 1 — DECSCUSR cursor shape silently dropped: Add ghostty_render_state_get_cursor_style and ghostty_render_state_get_cursor_blinking WASM exports to the patch. GhosttyTerminal.getCursor() now reads both from the terminal state instead of hardcoding style:'block' and blinking:false. Bug 2 — Ctrl+V not forwarded to PTY: InputHandler.handleKeyDown emits the encoded \x16 byte to onDataCallback before returning, so apps that read Ctrl+V natively (e.g. image paste flows) receive it. The browser paste event still fires afterwards so handlePaste continues to cover text paste unchanged. Bug 3 — Mouse wheel sends arrow keys when mouse tracking is active: Terminal.handleWheel checks hasMouseTracking() in the isAlternateScreen branch. When active, it emits an SGR scroll sequence (\x1b[<64/65;col;rowM) via dataEmitter instead of arrow keys, matching xterm.js behaviour. The arrow-key fallback is preserved for apps without mouse tracking (less, man, etc.). Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 6a1a50d commit 4011a35

File tree

5 files changed

+85
-35
lines changed

5 files changed

+85
-35
lines changed

lib/ghostty.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ export {
3131
type GhosttyCell,
3232
type GhosttyTerminalConfig,
3333
KeyEncoderOption,
34-
type RGB,
3534
type RenderStateColors,
3635
type RenderStateCursor,
36+
type RGB,
3737
};
3838

3939
/**
@@ -55,7 +55,7 @@ export class Ghostty {
5555
createTerminal(
5656
cols: number = 80,
5757
rows: number = 24,
58-
config?: GhosttyTerminalConfig
58+
config?: GhosttyTerminalConfig,
5959
): GhosttyTerminal {
6060
return new GhosttyTerminal(this.exports, this.memory, cols, rows, config);
6161
}
@@ -145,7 +145,7 @@ export class Ghostty {
145145
const bytes = new Uint8Array(
146146
(wasmInstance.exports as GhosttyWasmExports).memory.buffer,
147147
ptr,
148-
len
148+
len,
149149
);
150150
console.log('[ghostty-vt]', new TextDecoder().decode(bytes));
151151
},
@@ -215,7 +215,7 @@ export class KeyEncoder {
215215
eventPtr,
216216
bufPtr,
217217
bufferSize,
218-
writtenPtr
218+
writtenPtr,
219219
);
220220

221221
if (encodeResult !== 0) {
@@ -273,7 +273,7 @@ export class GhosttyTerminal {
273273
memory: WebAssembly.Memory,
274274
cols: number = 80,
275275
rows: number = 24,
276-
config?: GhosttyTerminalConfig
276+
config?: GhosttyTerminalConfig,
277277
) {
278278
this.exports = exports;
279279
this.memory = memory;
@@ -399,8 +399,11 @@ export class GhosttyTerminal {
399399
viewportX: this.exports.ghostty_render_state_get_cursor_x(this.handle),
400400
viewportY: this.exports.ghostty_render_state_get_cursor_y(this.handle),
401401
visible: this.exports.ghostty_render_state_get_cursor_visible(this.handle),
402-
blinking: false, // TODO: Add blinking support
403-
style: 'block', // TODO: Add style support
402+
blinking: this.exports.ghostty_render_state_get_cursor_blinking(this.handle),
403+
style:
404+
(['block', 'bar', 'underline'] as const)[
405+
this.exports.ghostty_render_state_get_cursor_style(this.handle)
406+
] ?? 'block',
404407
};
405408
}
406409

@@ -460,7 +463,7 @@ export class GhosttyTerminal {
460463
const count = this.exports.ghostty_render_state_get_viewport(
461464
this.handle,
462465
this.viewportBufferPtr,
463-
totalCells
466+
totalCells,
464467
);
465468

466469
if (count < 0) return this.cellPool;
@@ -569,7 +572,7 @@ export class GhosttyTerminal {
569572
this.handle,
570573
offset,
571574
this.viewportBufferPtr,
572-
this._cols
575+
this._cols,
573576
);
574577

575578
if (count < 0) return null;
@@ -629,7 +632,7 @@ export class GhosttyTerminal {
629632
row,
630633
col,
631634
bufPtr,
632-
bufSize
635+
bufSize,
633636
);
634637

635638
// 0 means no hyperlink at this position
@@ -676,7 +679,7 @@ export class GhosttyTerminal {
676679
offset,
677680
col,
678681
bufPtr,
679-
bufSize
682+
bufSize,
680683
);
681684

682685
// 0 means no hyperlink at this position
@@ -811,7 +814,7 @@ export class GhosttyTerminal {
811814
row,
812815
col,
813816
this.graphemeBufferPtr,
814-
16
817+
16,
815818
);
816819

817820
if (count < 0) return null;
@@ -849,7 +852,7 @@ export class GhosttyTerminal {
849852
offset,
850853
col,
851854
this.graphemeBufferPtr,
852-
16
855+
16,
853856
);
854857

855858
if (count < 0) return null;

lib/input-handler.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313
* - Captures all keyboard input (preventDefault on everything)
1414
*/
1515

16-
import type { Ghostty } from './ghostty';
17-
import type { KeyEncoder } from './ghostty';
16+
import type { Ghostty, KeyEncoder } from './ghostty';
1817
import type { IKeyEvent } from './interfaces';
1918
import { Key, KeyAction, KeyEncoderOption, Mods } from './types';
2019

@@ -231,7 +230,7 @@ export class InputHandler {
231230
getMode?: (mode: number) => boolean,
232231
onCopy?: () => boolean,
233232
inputElement?: HTMLElement,
234-
mouseConfig?: MouseTrackingConfig
233+
mouseConfig?: MouseTrackingConfig,
235234
) {
236235
this.encoder = ghostty.createKeyEncoder();
237236
this.container = container;
@@ -384,9 +383,18 @@ export class InputHandler {
384383
}
385384
}
386385

387-
// Allow Ctrl+V and Cmd+V to trigger paste event (don't preventDefault)
386+
// Ctrl+V / Cmd+V: emit \x16 to the PTY so apps that read it natively
387+
// (e.g. opencode image paste via osascript) receive the signal, then let
388+
// the browser paste event fire so handlePaste covers text content.
388389
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') {
389-
// Let the browser's native paste event fire
390+
const encoded = this.encoder.encode({
391+
key: Key.V,
392+
mods: event.ctrlKey ? Mods.CTRL : Mods.SUPER,
393+
action: KeyAction.PRESS,
394+
});
395+
if (encoded.length > 0) {
396+
this.onDataCallback(new TextDecoder().decode(encoded));
397+
}
390398
return;
391399
}
392400

@@ -765,7 +773,7 @@ export class InputHandler {
765773
col: number,
766774
row: number,
767775
isRelease: boolean,
768-
modifiers: number
776+
modifiers: number,
769777
): string {
770778
const btn = button + modifiers;
771779
const suffix = isRelease ? 'm' : 'M';
@@ -793,7 +801,7 @@ export class InputHandler {
793801
col: number,
794802
row: number,
795803
isRelease: boolean,
796-
event: MouseEvent
804+
event: MouseEvent,
797805
): void {
798806
const modifiers = this.getMouseModifiers(event);
799807

lib/terminal.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -477,15 +477,15 @@ export class Terminal implements ITerminalCore {
477477
return this.copySelection();
478478
},
479479
this.textarea,
480-
mouseConfig
480+
mouseConfig,
481481
);
482482

483483
// Create selection manager (pass textarea for context menu positioning)
484484
this.selectionManager = new SelectionManager(
485485
this,
486486
this.renderer,
487487
this.wasmTerm,
488-
this.textarea
488+
this.textarea,
489489
);
490490

491491
// Connect selection manager to renderer
@@ -845,7 +845,7 @@ export class Terminal implements ITerminalCore {
845845
* Returns true to prevent default handling
846846
*/
847847
public attachCustomKeyEventHandler(
848-
customKeyEventHandler: (event: KeyboardEvent) => boolean
848+
customKeyEventHandler: (event: KeyboardEvent) => boolean,
849849
): void {
850850
this.customKeyEventHandler = customKeyEventHandler;
851851
// Update input handler if already created
@@ -859,7 +859,7 @@ export class Terminal implements ITerminalCore {
859859
* Returns true to prevent default handling
860860
*/
861861
public attachCustomWheelEventHandler(
862-
customWheelEventHandler?: (event: WheelEvent) => boolean
862+
customWheelEventHandler?: (event: WheelEvent) => boolean,
863863
): void {
864864
this.customWheelEventHandler = customWheelEventHandler;
865865
}
@@ -1556,9 +1556,21 @@ export class Terminal implements ITerminalCore {
15561556
const isAltScreen = this.wasmTerm?.isAlternateScreen() ?? false;
15571557

15581558
if (isAltScreen) {
1559-
// Alternate screen: send arrow keys to the application
1560-
// Applications like vim handle scrolling internally
1561-
// Standard: ~3 arrow presses per wheel "click"
1559+
if (this.wasmTerm?.hasMouseTracking()) {
1560+
// App negotiated mouse tracking (e.g. vim `set mouse=a`): send SGR
1561+
// scroll sequence so the app scrolls its buffer, not the cursor.
1562+
const metrics = this.renderer?.getMetrics();
1563+
const canvas = this.canvas;
1564+
if (metrics && canvas) {
1565+
const rect = canvas.getBoundingClientRect();
1566+
const col = Math.max(1, Math.floor((e.clientX - rect.left) / metrics.width) + 1);
1567+
const row = Math.max(1, Math.floor((e.clientY - rect.top) / metrics.height) + 1);
1568+
const btn = e.deltaY < 0 ? 64 : 65;
1569+
this.dataEmitter.fire(`\x1b[<${btn};${col};${row}M`);
1570+
}
1571+
return;
1572+
}
1573+
// No mouse tracking: arrow-key fallback for apps like `less`.
15621574
const direction = e.deltaY > 0 ? 'down' : 'up';
15631575
const count = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5); // Cap at 5
15641576

lib/types.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ export interface GhosttyWasmExports extends WebAssembly.Exports {
379379
parser: number,
380380
paramsPtr: number,
381381
subsPtr: number,
382-
paramsLen: number
382+
paramsLen: number,
383383
): number;
384384
ghostty_sgr_next(parser: number, attrPtr: number): boolean;
385385
ghostty_sgr_attribute_tag(attrPtr: number): number;
@@ -396,7 +396,7 @@ export interface GhosttyWasmExports extends WebAssembly.Exports {
396396
eventPtr: number,
397397
bufPtr: number,
398398
bufLen: number,
399-
writtenPtr: number
399+
writtenPtr: number,
400400
): number;
401401

402402
// Key event
@@ -421,21 +421,24 @@ export interface GhosttyWasmExports extends WebAssembly.Exports {
421421
ghostty_render_state_get_cursor_x(terminal: TerminalHandle): number;
422422
ghostty_render_state_get_cursor_y(terminal: TerminalHandle): number;
423423
ghostty_render_state_get_cursor_visible(terminal: TerminalHandle): boolean;
424+
/** Returns 0=block, 1=bar, 2=underline */
425+
ghostty_render_state_get_cursor_style(terminal: TerminalHandle): number;
426+
ghostty_render_state_get_cursor_blinking(terminal: TerminalHandle): boolean;
424427
ghostty_render_state_get_bg_color(terminal: TerminalHandle): number; // 0xRRGGBB
425428
ghostty_render_state_get_fg_color(terminal: TerminalHandle): number; // 0xRRGGBB
426429
ghostty_render_state_is_row_dirty(terminal: TerminalHandle, row: number): boolean;
427430
ghostty_render_state_mark_clean(terminal: TerminalHandle): void;
428431
ghostty_render_state_get_viewport(
429432
terminal: TerminalHandle,
430433
bufPtr: number,
431-
bufLen: number
434+
bufLen: number,
432435
): number; // Returns total cells written or -1 on error
433436
ghostty_render_state_get_grapheme(
434437
terminal: TerminalHandle,
435438
row: number,
436439
col: number,
437440
bufPtr: number,
438-
bufLen: number
441+
bufLen: number,
439442
): number; // Returns count of codepoints or -1 on error
440443

441444
// Terminal modes
@@ -449,14 +452,14 @@ export interface GhosttyWasmExports extends WebAssembly.Exports {
449452
terminal: TerminalHandle,
450453
offset: number,
451454
bufPtr: number,
452-
bufLen: number
455+
bufLen: number,
453456
): number; // Returns cells written or -1 on error
454457
ghostty_terminal_get_scrollback_grapheme(
455458
terminal: TerminalHandle,
456459
offset: number,
457460
col: number,
458461
bufPtr: number,
459-
bufLen: number
462+
bufLen: number,
460463
): number; // Returns codepoint count or -1 on error
461464
ghostty_terminal_is_row_wrapped(terminal: TerminalHandle, row: number): number;
462465

@@ -466,14 +469,14 @@ export interface GhosttyWasmExports extends WebAssembly.Exports {
466469
row: number,
467470
col: number,
468471
bufPtr: number,
469-
bufLen: number
472+
bufLen: number,
470473
): number; // Returns bytes written, 0 if no hyperlink, -1 on error
471474
ghostty_terminal_get_scrollback_hyperlink_uri(
472475
terminal: TerminalHandle,
473476
offset: number,
474477
col: number,
475478
bufPtr: number,
476-
bufLen: number
479+
bufLen: number,
477480
): number; // Returns bytes written, 0 if no hyperlink, -1 on error
478481

479482
// Response API (for DSR and other terminal queries)

patches/ghostty-wasm-api.patch

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ index 000000000..c467102c3
157157
+int ghostty_render_state_get_cursor_x(GhosttyTerminal term);
158158
+int ghostty_render_state_get_cursor_y(GhosttyTerminal term);
159159
+bool ghostty_render_state_get_cursor_visible(GhosttyTerminal term);
160+
+/** Get cursor style: 0=block, 1=bar, 2=underline */
161+
+int ghostty_render_state_get_cursor_style(GhosttyTerminal term);
162+
+/** Check if cursor is blinking */
163+
+bool ghostty_render_state_get_cursor_blinking(GhosttyTerminal term);
160164
+
161165
+/** Get default colors as 0xRRGGBB */
162166
+uint32_t ghostty_render_state_get_bg_color(GhosttyTerminal term);
@@ -340,6 +344,8 @@ index 03a883e20..1336676d7 100644
340344
+ @export(&c.render_state_get_cursor_x, .{ .name = "ghostty_render_state_get_cursor_x" });
341345
+ @export(&c.render_state_get_cursor_y, .{ .name = "ghostty_render_state_get_cursor_y" });
342346
+ @export(&c.render_state_get_cursor_visible, .{ .name = "ghostty_render_state_get_cursor_visible" });
347+
+ @export(&c.render_state_get_cursor_style, .{ .name = "ghostty_render_state_get_cursor_style" });
348+
+ @export(&c.render_state_get_cursor_blinking, .{ .name = "ghostty_render_state_get_cursor_blinking" });
343349
+ @export(&c.render_state_get_bg_color, .{ .name = "ghostty_render_state_get_bg_color" });
344350
+ @export(&c.render_state_get_fg_color, .{ .name = "ghostty_render_state_get_fg_color" });
345351
+ @export(&c.render_state_is_row_dirty, .{ .name = "ghostty_render_state_is_row_dirty" });
@@ -398,6 +404,8 @@ index bc92597f5..d0ee49c1b 100644
398404
+pub const render_state_get_cursor_x = terminal.renderStateGetCursorX;
399405
+pub const render_state_get_cursor_y = terminal.renderStateGetCursorY;
400406
+pub const render_state_get_cursor_visible = terminal.renderStateGetCursorVisible;
407+
+pub const render_state_get_cursor_style = terminal.renderStateGetCursorStyle;
408+
+pub const render_state_get_cursor_blinking = terminal.renderStateGetCursorBlinking;
401409
+pub const render_state_get_bg_color = terminal.renderStateGetBgColor;
402410
+pub const render_state_get_fg_color = terminal.renderStateGetFgColor;
403411
+pub const render_state_is_row_dirty = terminal.renderStateIsRowDirty;
@@ -991,6 +999,22 @@ index 000000000..73ae2e6fa
991999
+ return wrapper.render_state.cursor.visible;
9921000
+}
9931001
+
1002+
+/// Get cursor style: 0=block, 1=bar, 2=underline
1003+
+pub fn renderStateGetCursorStyle(ptr: ?*anyopaque) callconv(.c) c_int {
1004+
+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0));
1005+
+ return switch (wrapper.terminal.screens.active.cursor.cursor_style) {
1006+
+ .bar => 1,
1007+
+ .underline => 2,
1008+
+ else => 0,
1009+
+ };
1010+
+}
1011+
+
1012+
+/// Check if cursor is blinking
1013+
+pub fn renderStateGetCursorBlinking(ptr: ?*anyopaque) callconv(.c) bool {
1014+
+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false));
1015+
+ return wrapper.terminal.modes.get(.cursor_blinking);
1016+
+}
1017+
+
9941018
+/// Get default background color as 0xRRGGBB
9951019
+pub fn renderStateGetBgColor(ptr: ?*anyopaque) callconv(.c) u32 {
9961020
+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0));

0 commit comments

Comments
 (0)