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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions