Skip to content

PTY input handling gaps: cursor shape (DECSCUSR), Ctrl+V forwarding, and mouse scroll #145

@jesse23

Description

@jesse23

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 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.

// 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions