-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Description
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):
-
macOS trackpad momentum scrolling: After a user lifts their finger from the trackpad, macOS continues generating
WheelEvents with decayingdeltaYvalues (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. -
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. Eachterm.write()call triggers xterm.js to re-render, which reflows the viewport and interacts badly with pending scroll events. -
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
- [BUG] Console scrolling top of history when claude add text to the console anthropics/claude-code#826 — Console scrolling to top of history
- Terminal Scrolling Uncontrollably During Claude Code Interaction anthropics/claude-code#3648 — Terminal Scrolling Uncontrollably
- [BUG] Claude code terminal jumps to the top of the chat when reviewing a plan anthropics/claude-code#10304 — Terminal jumps to top of chat
- [BUG] "terminal scrolling infinite loop" or "uninterruptible high speed scrolling bug" anthropics/claude-code#10835 — Terminal scrolling infinite loop
- [BUG] 01-BUG-bash-output-scroll-regression anthropics/claude-code#11719 — Bash output scroll regression
- [BUG] Terminal scrolls to the top after each response anthropics/claude-code#11801 — Terminal scrolls to top after response
- [BUG] Header layout updates cause terminals to auto scroll up anthropics/claude-code#17938 — Header layout updates cause auto scroll up
- Terminal Flickering anthropics/claude-code#1913 — Terminal Flickering
- Smooth scrolling xtermjs/xterm.js#1140 — Smooth scrolling
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
- 15+ iterations (PRs Custom Models with Copilot CLI #142, PowerShell invocations are added to user's PowerShell history #150-Azure AI Foundry Support #165 in kubestellar/copilot-remote)
- 135 automated tests covering all layers
- Zero rocket scroll reports since deploying the combined fix
- Works with both Copilot CLI and Claude Code inside tmux + xterm.js
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.tsxL980-1063 - TerminalWriter:
web/src/components/TerminalView.tsxL184-290 - Server backpressure:
server/src/index.tsL665-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