Skip to content

🐛 Rocket Scroll Fix for xterm.js Web Terminals — 4-Layer Solution with Reference Implementation #1805

@clubanderson

Description

@clubanderson

Problem

When running AI CLI tools (GitHub Copilot CLI, Claude Code) inside browser-based xterm.js terminals, users experience uncontrollable "rocket scroll" — the terminal scrolls at extreme speed, making the session unusable. This affects every xterm.js-based web terminal (VS Code terminal, web IDEs, custom terminal UIs) on macOS and other platforms with trackpad momentum scrolling.

Root Causes (3 independent issues that compound):

  1. macOS trackpad momentum scrolling: After a user lifts their finger from the trackpad, macOS continues generating WheelEvents with decaying deltaY values (inertial scrolling). When combined with rapid AI output, these momentum events accumulate and create a runaway feedback loop where the terminal oscillates between top and bottom of the scrollback.

  2. Data flood from AI output: AI CLI tools use ANSI control sequences (\x1b[H, \x1b[2J, cursor movements, synchronized output blocks) to render rich TUI interfaces. These tools emit hundreds of small data chunks per second during streaming responses. Each term.write() call triggers xterm.js to re-render, which reflows the viewport and interacts badly with pending scroll events.

  3. No flow control between PTY and browser: The server-side PTY (node-pty) reads data as fast as the AI process produces it and forwards it over WebSocket. The browser's xterm.js rendering cannot keep up. Without backpressure, the write buffer grows unboundedly, causing jank, dropped frames, and scroll position resets.

Related Bug Reports

Solution (4-Layer Fix)

We developed a multi-layer solution over 15+ iterations (PRs #150-#165 in kubestellar/copilot-remote). Each layer addresses a different root cause. All layers work together but are independently useful.


Layer 1: Wheel Event Interception with Momentum Detection

Strategy: Block ALL native wheel events on .xterm elements at the document capture phase, detect macOS momentum scrolling via delta decay patterns, and re-dispatch controlled synthetic events with clamped deltaY.

document.addEventListener('wheel', handler, { capture: true, passive: false });

const handler = (e: WheelEvent) => {
  if (isSynthetic) return;  // let our synthetic events through
  const target = e.target as HTMLElement;
  if (!target?.closest?.('.xterm')) return;

  e.preventDefault();
  e.stopImmediatePropagation();

  const now = performance.now();
  const elapsed = now - lastWheel;
  const absDelta = Math.abs(e.deltaY);

  // Momentum detection: rapid events with decaying or tiny deltas
  if (elapsed < 80 && absDelta > 0) {
    if (absDelta <= lastAbsDelta || absDelta < 4) momentumCount++;
    else momentumCount = 0;
  } else if (elapsed >= 300) {
    momentumCount = 0;  // long pause = finger lifted
  }

  if (momentumCount >= 3) return;  // momentum detected, block
  if (elapsed < 120) return;       // throttle to ~8/sec

  lastWheel = now;
  lastAbsDelta = absDelta;

  // Re-dispatch with clamped deltaY (max ±3px)
  const clampedDelta = Math.sign(e.deltaY) * Math.min(absDelta, 3);
  isSynthetic = true;
  xtermViewport.dispatchEvent(new WheelEvent('wheel', {
    deltaX: 0, deltaY: clampedDelta, deltaMode: e.deltaMode,
    bubbles: true, cancelable: true, clientX: e.clientX, clientY: e.clientY,
  }));
  isSynthetic = false;
};

Layer 2: RAF-Based Write Batching (TerminalWriter)

Strategy: Accumulate all WebSocket data within a single animation frame and flush once per requestAnimationFrame. Reduces term.write() calls from hundreds/sec to ~60/sec.

class TerminalWriter {
  private queue = ''
  private rafId: number | null = null;
  constructor(private term: Terminal) {}

  write(data: string) {
    this.queue += data;
    if (this.rafId === null)
      this.rafId = requestAnimationFrame(() => this.flush());
  }
  private flush() {
    this.rafId = null;
    if (!this.queue) return;
    const data = this.queue;
    this.queue = ''
    this.term.write(data);
    if (this.queue) this.scheduleFlush();
  }
}

Layer 3: DEC 2026 Synchronized Output

Strategy: Detect ESC[?2026h / ESC[?2026l sequences. Buffer all data during sync mode and flush atomically when sync ends, preventing xterm.js from rendering intermediate TUI states.

write(data: string) {
  if (data.includes('\\x1b[?2026h') && !this.syncMode) {
    this.syncMode = true;
    // buffer everything until sync end
  }
  if (this.syncMode && data.includes('\\x1b[?2026l')) {
    this.syncMode = false;
    this.enqueue(this.syncBuffer);  // one atomic write
    this.syncBuffer = ''
  }
}

Layer 4: Write Backpressure with Watermarks

Strategy: Track bytes pending in xterm.js write buffer. At 128KB (high water), send { type: 'pause' } over WebSocket to stop PTY reads. At 16KB (low water), send { type: 'resume' }. Server suppresses ws.send() while paused.

// Frontend: in TerminalWriter.flush()
this.term.write(data, () => {
  this.watermark -= data.length;
  if (this.paused && this.watermark < LOW_WATER) {
    this.paused = false;
    ws.send(JSON.stringify({ type: 'resume' }));
  }
});
if (!this.paused && this.watermark > HIGH_WATER) {
  this.paused = true;
  ws.send(JSON.stringify({ type: 'pause' }));
}

// Server: suppress PTY forwarding while paused
let ptyPaused = false;
const onData = (id, data) => {
  if (!ptyPaused) ws.send(data);
};
ws.on('message', (raw) => {
  const msg = JSON.parse(raw);
  if (msg.type === 'pause') ptyPaused = true;
  if (msg.type === 'resume') ptyPaused = false;
});

Results

Applicability

This fix applies to any xterm.js-based web terminal: VS Code terminal, Codespaces, Gitpod, JupyterLab, custom deployments. The wheel handler (Layer 1) and TerminalWriter (Layers 2-4) are self-contained and portable.

Reference Implementation

Full source: https://github.com/kubestellar/copilot-remote?target=https://github.com

  • Wheel handler: web/src/components/TerminalView.tsx L980-1063
  • TerminalWriter: web/src/components/TerminalView.tsx L184-290
  • Server backpressure: server/src/index.ts L665-712
  • Tests: web/src/test/terminal-scroll.test.ts, web/src/test/terminal-writer.test.ts

Happy to contribute this as a PR if there's a suitable place in the codebase. cc @pelikhan

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions