-
Notifications
You must be signed in to change notification settings - Fork 104
PTY input handling gaps: cursor shape (DECSCUSR), Ctrl+V forwarding, and mouse scroll #145
Description
We hit three input handling gaps while building webtty — a browser terminal that uses ghostty-web as its renderer. All three are places where ghostty-web behaves differently from xterm.js, and all three have clean fixes in the JS layer.
Bug 1: DECSCUSR cursor shape sequences are silently dropped
Symptom: Applications like vim, neovim, and fish emit DECSCUSR (ESC [ Ps SP q) to switch cursor shape at runtime (bar in insert mode, block in normal mode). With ghostty-web, the cursor shape never changes — it stays fixed at whatever was set at construction time.
Root cause: GhosttyTerminal.getCursor() in lib/ghostty.ts hardcodes style: 'block' with a TODO comment instead of reading cursor_visual_style from the WASM render state. The WASM binary processes DECSCUSR correctly and updates RenderState.Cursor.visual_style — the JS wrapper just never reads it back.
Workaround we ship in webtty: We intercept DECSCUSR sequences client-side before term.write() and set term.options.cursorStyle directly. It works, but it is the wrong layer.
Proposed fix: In getCursor(), read cursor_visual_style (data key 10) via ghostty_render_state_get and map it to the renderer style strings. The render loop already calls getCursor() after each write() and passes the result to renderer.setCursorStyle() — so this one change is sufficient.
// lib/ghostty.ts — GhosttyTerminal.getCursor()
// data key 10 = cursor_visual_style (0=block, 1=underline, 2=bar)
const visualStyle = this.exports.ghostty_render_state_get(this.handle, 10);
const style = visualStyle === 2 ? 'bar' : visualStyle === 1 ? 'underline' : 'block';
return { x: ..., y: ..., visible: ..., blinking: ..., style };Full analysis and context: ADR 013 — Fix to ghostty-web
Bug 2: Ctrl+V keydown is not forwarded to the PTY
Symptom: Pasting non-text content (e.g. an image) into a TUI application running under ghostty-web does nothing. The same setup works correctly under ttyd (xterm.js). Specifically, opencode's image paste flow — which relies on receiving \x16 in the PTY stream and then reading the clipboard natively via osascript / powershell / wl-paste — never triggers.
Root cause: InputHandler.handleKeyDown in lib/input-handler.ts returns early on Ctrl+V / Cmd+V without emitting \x16 to onDataCallback:
if ((A.ctrlKey || A.metaKey) && A.code === "KeyV")
return; // \x16 never reaches the PTYxterm.js forwards the keydown unconditionally; ghostty-web does not. For text paste this is invisible (the paste event fires and handlePaste covers it), but when the clipboard has no text/plain, handlePaste drops the event silently and the PTY sees nothing.
Workaround we ship in webtty: We add a capture-phase paste listener that sends \x16 when clipboardData has no text/plain. It works, but intercepting the paste event to infer a missed keydown is a hack.
Proposed fix: Emit the encoded keydown before the early return, so \x16 reaches the PTY. The paste event still fires afterwards, so text paste via handlePaste is completely unaffected.
// lib/input-handler.ts — InputHandler.handleKeyDown
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') {
const encoded = this.encoder.encode({ key: Key.V, mods: Mods.CTRL, action: KeyAction.PRESS });
if (encoded.length > 0) {
this.onDataCallback(new TextDecoder().decode(encoded)); // \x16 → PTY
}
return; // browser paste event fires next → handlePaste covers text
}Full analysis and context: ADR 014 — Fix to ghostty-web
Bug 3: Mouse wheel sends arrow keys instead of SGR scroll sequences when mouse tracking is active
Symptom: When a TUI app (e.g. vim with set mouse=a) enables mouse tracking, scrolling the mouse wheel moves the cursor up/down instead of scrolling the buffer. The same vim in xterm.js-based terminals (VSCode, iTerm2) scrolls correctly.
Root cause: Terminal.handleWheel in src/Terminal.ts is registered on the canvas with capture: true and calls stopPropagation() unconditionally — which prevents InputHandler.handleWheel (the handler that correctly checks hasMouseTracking() and sends SGR sequences) from ever running. It then sends arrow keys regardless of whether the app has requested mouse events:
// src/Terminal.ts — Terminal.handleWheel (current, broken)
this.handleWheel = (e: WheelEvent) => {
e.preventDefault();
e.stopPropagation(); // ← blocks InputHandler
if (this.customWheelEventHandler?.(e)) return;
if (this.wasmTerm?.isAlternateScreen()) {
const dir = e.deltaY > 0 ? 'down' : 'up';
const lines = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5);
for (let i = 0; i < lines; i++)
this.dataEmitter.fire(dir === 'up' ? '\x1B[A' : '\x1B[B'); // ← always, ignores mouse tracking
} else {
// scroll viewport
}
};xterm.js checks ctx.requestedEvents.wheel before falling back to arrow keys — ghostty-web skips this check entirely.
Workaround we ship in webtty: We use attachCustomWheelEventHandler to intercept wheel events when hasMouseTracking() is true and send SGR scroll sequences (\x1b[<64;col;rowM / \x1b[<65;col;rowM) directly to the PTY, returning true to prevent the arrow-key path from running.
Proposed fix: In the isAlternateScreen() branch, guard the arrow-key loop with hasMouseTracking(). When mouse tracking is active, emit the SGR scroll sequence via dataEmitter instead. The canvas and renderer are already available on this:
// src/Terminal.ts — Terminal.handleWheel (fixed)
if (this.wasmTerm?.isAlternateScreen()) {
if (this.wasmTerm.hasMouseTracking()) {
// App negotiated mouse tracking — send SGR scroll sequence, not arrow keys.
const metrics = this.renderer?.getMetrics();
if (metrics && this.canvas) {
const rect = this.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 dir = e.deltaY > 0 ? 'down' : 'up';
const lines = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5);
for (let i = 0; i < lines; i++)
this.dataEmitter.fire(dir === 'up' ? '\x1B[A' : '\x1B[B');
} else {
// scroll viewport (unchanged)
}Full analysis and context: ADR 017 — Fix to ghostty-web
Contribution
All three fixes are small and self-contained. We are happy to submit PRs for any or all of them if the approach looks right to you — just say the word.