Skip to content

[Bug] Wheel input is silently dropped in the alt buffer when consumeWheelEvent() rounds to zero lines #6105

Open

Description

@Xukun-Liu

[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:

  • shiftKey is 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

  1. Xukun-Liu commented on Aug 20, 2026

    @Xukun-Liu
    Author

    Submitted a fix in #6118 — keeps the consumeWheelEvent() call so the partial-scroll accumulator stays in sync, but only skips sending when deltaY === 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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