🐛 Rocket Scroll Fix for xterm.js Web Terminals — 4-Layer Solution with Reference Implementation
- 主要語言
- Shell
- 星號
- 11.2k
- 分支
- 1.9k
- 平均合併
- 14 小時 16 分鐘
- 30 天內合併 PR
- 6
描述
## 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 `WheelEvent`s 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
- anthropics/claude-code#826 — Console scrolling to top of history
- anthropics/claude-code#3648 — Terminal Scrolling Uncontrollably
- anthropics/claude-code#10304 — Terminal jumps to top of chat
- anthropics/claude-code#10835 — Terminal scrolling infinite loop
- anthropics/claude-code#11719 — Bash output scroll regression
- anthropics/claude-code#11801 — Terminal scrolls to top after response
- anthropics/claude-code#17938 — Header layout updates cause auto scroll up
- anthropics/claude-code#1913 — Terminal Flickering
- xtermjs/xterm.js#1140 — Smooth scrolling
## Solution (4-Layer Fix)
We developed a multi-layer solution over 15+ iterations (PRs github/copilot-cli#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.
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
// 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 github/copilot-agent-runtime#5805, github/copilot-cli#150-#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
- 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
貢獻指南
評估
這個 Issue 還沒有評估資料。