Repository navigation
Description
[Bug] Wheel input is silently dropped in the alt buffer when consumeWheelEvent() rounds to zero lines
First affected: 5.6.0-beta.119 (ships in VS Code 1.105)
Still present in: main / 6.1.0-beta.288 (ships in VS Code 1.128)
Regression window: 5.6.0-beta.118 -> 5.6.0-beta.119
I maintain a full-screen TUI that people often run inside the VS Code integrated terminal. Since VS Code 1.105 the mouse wheel does nothing there. It worked fine on 1.104 with the same app build, and it works in every other terminal. I traced it to xterm rather than the app, and I have byte-level evidence for what's happening.
What changed
VS Code 1.104 ships xterm 5.6.0-beta.118, VS Code 1.105 ships 5.6.0-beta.119. The alt-buffer wheel handler differs by one gate.
On beta.118 any non-zero wheel delta becomes exactly one ESC[A/ESC[B:
if (!this.buffer.hasScrollback) {
if (0 === e.deltaY) return false;
const t = ESC + (appCursorKeys ? "O" : "[") + (e.deltaY < 0 ? "A" : "B");
return this.coreService.triggerDataEvent(t, true), this.cancel(e, true);
}On beta.119 there's an extra consumeWheelEvent() check. When it returns 0 the event is dropped and the app never sees it:
if (!this.buffer.hasScrollback) {
if (0 === t.deltaY) return false;
if (0 === e.coreMouseService.consumeWheelEvent(t,
this._renderService?.dimensions?.device?.cell?.height,
this._coreBrowserService?.dpr)) return false; // <- swallowed here
...
}consumeWheelEvent() returns 0 in three cases, and two of them look wrong for this path:
shiftKeyis held, so shift+wheel is always swallowed.- cell height or dpr is undefined, so the event vanishes whenever the renderer isn't ready.
- pixel deltas that dampen and floor to zero. Deltas under 50px are multiplied by 0.3 before being divided by the cell size, so with a typical 17-26px cell, any single notch below roughly 50px rounds to 0 lines.
The third case is the one that bites. The sequence this path sends is always exactly one ESC[A/ESC[B, the return value is only ever used as a boolean. So the gate drops real input and gains nothing, which the comment above the code already says ("has been simplified to simply send a single up or down sequence"). It's a leftover from the older implementation that actually scrolled by lines.
How I verified this
I ran VS Code 1.104 and 1.105 side by side, each with a raw-mode pty listener that switches into the alt screen (ESC[?1049h, mouse reporting off, the same state a TUI runs in). I instrumented the shipped xterm bundle to log the arguments and result of consumeWheelEvent() per event, then sent wheel events both as synthetic DOM WheelEvents and through CDP Input.dispatchMouseEvent:
| probe | 1.104 (beta.118) | 1.105 (beta.119) |
|---|---|---|
| pixel deltaY=20 | ESC[B sent | cell 26px, dpr 1.5, consumeWheelEvent returns 0, nothing sent |
| pixel deltaY=100 | ESC[B sent | returns 6, ESC[B sent |
| line deltaY=3 | ESC[B sent | returns 3, ESC[B sent |
| line deltaY=100 | ESC[B sent | returns 100, ESC[B sent |
| CDP pixel deltaY=20 | ESC[B sent | returns 0, nothing sent |
| CDP pixel deltaY=100 | ESC[B sent | returns 6, ESC[B sent |
1.104 sent one ESC[B for every probe. 1.105 sent nothing for the two pixel probes under 50px, and the pty byte log confirms zero bytes for those. Renderer dims were healthy in both runs (26px cell at dpr 1.5), so this is the dampening path, not a sizing race.
I also checked the current main branch. The gate is still there in MouseService._handlePassiveWheel(), and it's unchanged in 6.1.0-beta.288. A normal notch mouse sends around 100px per tick so it clears the gate, which is probably why this has gone unnoticed. Trackpads, smooth wheels, low-sensitivity scroll settings, and shift+wheel all land in the dead zone.
Proposed fix
For the alt-buffer path, keep calling consumeWheelEvent() so the partial-scroll accumulator stays in sync, but don't gate on the result. deltaY === 0 remains the only reason to skip sending:
const deltaY = ev.deltaY;
if (deltaY === 0) {
return false;
}
this._consumeWheelEvent(
ev,
this._renderService?.dimensions?.device?.cell?.height,
this._coreBrowserService?.dpr
);
const sequence = C0.ESC + (this._coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B');
this._coreService.triggerDataEvent(sequence, true);
ev.preventDefault();
ev.stopPropagation();
return false;I have this change on a branch with four new unit tests covering small pixel deltas, negative deltas, zero deltas, and the app-handles-wheel-itself case. The full unit suite passes (2342 tests). PR: #6104.
If partial trackpad scrolling is wanted in the alt buffer, the accumulator is already in place to do it properly later. Swallowing the input is the one thing that can't be right.
Related: #5391 introduced the gate. The wheel-to-mouse-report path in sendEvent() has the same gate, so enabling DECSET 1000/1006 doesn't escape it either, and DECSET 1007 isn't implemented.
Activity
Xukun-Liu commentedon Aug 20, 2026
Submitted a fix in #6118 — keeps the
consumeWheelEvent()call so the partial-scroll accumulator stays in sync, but only skips sending whendeltaY === 0. Verified byte-level against VS Code 1.104 / 1.105 / 1.128 (probe tables in the PR). Happy to adjust the approach if you see a cleaner fix.