PTY input handling gaps: cursor shape (DECSCUSR), Ctrl+V forwarding, and mouse scroll
- Dominant language
- TypeScript
- Stars
- 2.8k
- Forks
- 172
- PR merge metrics
- No merged PRs in 30d
Description
We hit three input handling gaps while building [webtty](https://github.com/jesse23/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.
```ts
// 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](https://github.com/jesse23/webtty/blob/main/docs/adrs/013.client.cursor-style.md#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`:
```ts
if ((A.ctrlKey || A.metaKey) && A.code === "KeyV")
return; // \x16 never reaches the PTY
```
xterm.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.
```ts
// 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](https://github.com/jesse23/webtty/blob/main/docs/adrs/014.client.image-paste.md#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:
```ts
// 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`:
```ts
// 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](https://github.com/jesse23/webtty/blob/main/docs/adrs/017.client.mouse-scroll.md#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.
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.