Skip to content

fix(terminal): keep wheel scrolling inside terminal panes - #792

Merged
Juliusolsson05 merged 14 commits into
mainfrom
fix/terminal-wheel-boundary
Sep 13, 2026
Merged

Juliusolsson05 merged 14 commits into
mainfrom
fix/terminal-wheel-boundary

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Sep 5, 2026 •

Copy link
Copy Markdown
Owner

Problem

At a scrollback boundary, xterm leaves the wheel event unconsumed and Chromium scrolls the nearest native ancestor. The visible case is the inline debug terminal jumping its surrounding DebugPanel (overflow-y-auto). This is separate from the GPU glyph corruption repaired in #790.

Change

  • One shared, disposable, bubble-phase helper (attachTerminalWheelBoundary). Every xterm host attaches it right after open() and disposes it before term.dispose(). The hosts:
    • TerminalLeaf: shell panes.
    • AgentTerminalLeaf: every raw agent view, including OpenCode Terminal.
    • AgentInlineTerminal: the debug rail.
  • xterm gets first refusal for:
    • normal scrollback;
    • alternate-screen arrow conversion;
    • mouse reporting.
  • Only a vertical wheel that xterm left unconsumed has its browser default canceled, whether plain or Alt fast scroll.
  • These pass through uncanceled, by policy:
    • an unconsumed Ctrl wheel (pinch/zoom);
    • an unconsumed Meta wheel (platform navigation/zoom);
    • Shift and horizontal-dominant gestures;
    • application engagement listeners.
  • No synthetic PTY input, viewport movement, React updates, or repaint loop.

Design decisions

  • Bubble phase, not capture or a custom xterm wheel handler. All of xterm's wheel listeners sit at or below .xterm, so a host listener runs only after xterm has decided. Intercepting earlier would break alternate-screen arrows and provider mouse protocols.
  • Alt is contained, not exempt. Alt+wheel is xterm's own fastScrollSensitivity gesture, not a browser gesture. At a boundary xterm leaves it unconsumed exactly like a plain wheel.
  • Ctrl, Meta and Shift are policy, not xterm behavior.
    • xterm consumes Ctrl/Meta wheels as ordinary scrollback while it can move. The helper only declines to cancel them once they are left unconsumed.
    • Shift is treated as horizontal intent. On macOS xterm does not shiftConvert, so a Shift wheel that still arrives vertical can chain at a boundary. That gap is documented and unverified; the code is unchanged without probe evidence.
  • JS helper, not CSS overscroll-behavior-y: contain (yet).
    • Since Chrome 144, overscroll-behavior also applies to non-scrollable scroll containers. Electron 43.1.1 ships Chrome 150, and all three hosts are already overflow-hidden, so CSS might contain the chaining with no JS.
    • It was never probed, and agents cannot launch Electron.
    • The helper is verified against the pinned xterm, now in CI as well. The WHY comment lists exactly what a user-run probe must show before swapping to CSS.
  • !event.cancelable bails. Per Chromium's wheel-latching explainer, a sequence whose first event went uncanceled stays with its original target. This path is reasoned from documented behavior, not probe-verified.

Tests

  • terminalWheelBoundary.renderer.test.ts pins the helper's own contract with plain DOM stand-ins:
    • bubble-phase registration;
    • the modifier, axis, consumed and non-cancelable policy;
    • disposal.
  • terminalWheelBoundary.xterm.renderer.test.ts (new in review round 2) opens the real pinned @xterm/xterm in happy-dom.
    • Shims. There are three narrow environment shims:
      • a 2D context with font/measureText for WidthCache;
      • a fixed size for the char-measure span;
      • 0px padding on .xterm-screen, which happy-dom otherwise reports as ''.
      • Each synthetic WheelEvent also gets the clientX/clientY and modifier fields that happy-dom's UIEvent-based WheelEvent lacks.
    • What xterm computes. The real xterm computes wheel normalization, Alt multiplication, clamping, the consume decision, buffer switching, and arrow and SGR encoding.
    • Assertions. All are relative or semantic:
      • plain and Alt scrollback are consumed, and Alt moves farther;
      • exhausted plain and Alt wheels at top and bottom are canceled by the helper and NOT canceled after disposal (the no-helper control);
      • an alternate-screen wheel emits ESC [ A;
      • a DECSET 1000/1006 wheel emits one SGR report.
    • Not covered. It does not claim native Chromium scroll chaining, latching or WebGL.
  • AgentTerminalLeaf.dimensionOwnership.renderer.test.tsx pins helper attach/dispose for the agent, shell and inline terminals.
  • scripts/smoke-terminal-wheel.mjs is the manual, user-run Electron probe. It remains the oracle for native scroll chaining.

Verification (final head ad6e5eec)

  • Integration with main.
    • 96cdca9f merged origin/main 115e26fc, including perf(terminal): re-enable WebGL rendering on the xterm beta line #873 (@xterm/xterm exact-pinned to 6.1.0-beta.304, WebGL re-enabled, locally patched resize()).
    • 78a3e85d merged origin/main 173a5702. That is 59 more commits, touching AgentTerminalLeaf.tsx and ARCHITECTURE.md, and the merge was clean.
    • After both merges, all three hosts still attach the boundary right after open() and dispose it. Main's attachXtermWebglRenderer(term, { onRendererChange }) stays where main put it.
  • Static perf(terminal): re-enable WebGL rendering on the xterm beta line #873 re-validation, against the installed 6.1.0-beta.304 sources and the shipped bundle:
    • The boundary leak still exists: _handleMouseWheel consumes only if it moved, and Viewport.ts sets neither consume option.
    • Every wheel listener lives at or below .xterm.
    • The alternate-screen and mouse-protocol paths consume.
    • The real-xterm test now pins these in CI.
  • Type-check. Raw tsc -p tsconfig.node.json then tsc -p tsconfig.web.json on Node 24: both clean.
  • Focused renderer tests: 10 files, 71 tests pass (terminalWheelBoundary.renderer.test.ts 13, terminalWheelBoundary.xterm.renderer.test.ts 5, plus AgentTerminalLeaf.{dimensionOwnership,follow,related,statusHeader,submit} and TerminalLeaf.{dictationOwnership,header,retention}). vitest related --run on terminalWheelBoundary.ts: 17 files, 107 tests pass.
  • Mutation checks.
    • Round 1: re-adding event.altKey || to the bail failed exactly the two helper-unit Alt cases.
    • Round 2: re-adding event.altKey || to the bail fails exactly the two real-xterm boundary cases (top and bottom), with [true, false] instead of [true, true]: 2 failed, 3 passed. The source was then restored byte-identical.
  • CI on the final head: quality-gate pass (8m50s) and minimum-node-fixture-gate pass (42s), run 34729697717.
  • Real Electron/Chromium probe: pending a user re-run.
    • The recorded pass predates both merges. It ran on xterm 6.0.0 / addon-webgl 0.19.0: the patched parent stayed at 0px, --control moved it 120px, and scrollback, output anchoring, alternate-screen arrows and SGR mouse reports all passed.
    • It has not run on the current stack (xterm 6.1.0-beta.304, addon-webgl 0.20.0-beta.300, Electron 43.1.1 / Chrome 150).
    • The Alt steps and the round-2 parent reset have never run.
    • Agents do not launch Electron. Please run both modes:
      node scripts/smoke-terminal-wheel.mjs
      node scripts/smoke-terminal-wheel.mjs --control
      
  • OpenCode Terminal is covered through AgentTerminalLeaf, which renders every raw agent surface. The OpenCode mouse-mode loss in bug(terminal): OpenCode terminal panes lose mouse tracking and cannot scroll or jump to latest #843 is pre-existing, and this PR neither causes nor fixes it.

Review round 1 (Codex + Claude reviews of 05567575)

Codex: REQUEST CHANGES

  • Major, Alt+wheel still scroll-chains out of the terminal. Fixed in 9409fce1.
    • altKey is removed from the bail, the WHY comment rewritten, and the Alt test flipped.
    • A two-direction Alt boundary regression was added, and the mutation check showed it fails when the bail is restored.
  • Minor, the "xterm first refusal" test does not exercise xterm.
    • Round 1 retitled the test and documented real-xterm coverage as infeasible (9409fce1).
    • Superseded in round 2: real-xterm coverage was added (72829efc).

Claude: APPROVE WITH COMMENTS

  • F1 major, stale CSS premise. The comment was corrected in 9409fce1. Replacing the helper with CSS is declined in this PR: nothing here can verify it without launching Electron.
  • F2 minor, the probe has not run on the merged stack. Not run by agents. The exact user commands and the stale-evidence caveat are under Verification.
  • F3 minor, stale PR body. Rewritten, and kept current by this update.
  • F4 minor, probe header, discoverability, fixture type-check.
    • A WHY header was added, and the helper's WHEN BUMPING XTERM note points at the probe (6686f3cc, 9409fce1).
    • The fixture is kept out of tsconfig.web.json for parity with the sibling terminal-renderer/smoke.ts fixture, which needs a TS2683 fix first (reworded in round 2).
    • No npm script or CI gate.
  • F5 minor, Alt escapes. Same as the Codex major; fixed in 9409fce1.
  • F6 nit, latching claim uncited. The explainer is cited, and the path is marked as reasoned, not probe-verified (9409fce1).
  • F7 nit, ARCHITECTURE.md §6.6.2. Added a sentence and a source link (84e29c92).
  • F8 nit, overstated test title. Retitled (9409fce1). Host ordering is now exercised against the real xterm in round 2 (72829efc).
  • F9 nit, merge-commit subject. No change; history is not rewritten.

Review round 2 (Codex + Claude verification of 84e29c92)

Both reviewers verified the Alt fix and found no production defect.

Reviewer Finding Disposition
Claude R1 (major) + Codex minor 1 The --control probe's two boundary leaks scroll #outer to about 240px, so the fixed (200,100) pointer misses xterm's 20-row screen during the alternate-screen and SGR steps. Fixed in 3e88158d. After both boundary states are recorded, the new fixture resetOuter() sets #outer.scrollTop = 0. The probe asserts the reset took effect, and asserts outer === 0 for alternateUp and mouseUp in both modes (replacing the old equalities). The header bullet is updated. Reasoned statically, not run: the reset is a no-op in default mode, and in control an upward wheel cannot scroll a parent already at 0.
Codex minor 2 happy-dom needs shims, but real-xterm coverage is feasible. Fixed in 72829efc. New terminalWheelBoundary.xterm.renderer.test.ts (5 tests), with shims scoped to that file and a WHY header on what they fake and why the assertions do not echo them. Two happy-dom gaps turn the report into ESC[<64;NaN;NaNM: unset padding computes to '', and WheelEvent lacks clientX/clientY. The first real run had both, and with only the padding shim the missing clientX still produced NaN. Both are shimmed. The unit test header's "infeasible" claim is replaced. Mutation check: re-adding `event.altKey
Codex nit The Ctrl/Meta WHY describes policy as xterm behavior. Fixed. The comment now says xterm consumes Ctrl/Meta wheels as scrollback while it can move, and the helper only leaves unconsumed ones to the browser (ebb36e5d). The test title is now "leaves an unconsumed %s wheel to the browser" (72829efc).
Claude R2 nit The Shift WHY does not hold on macOS: there is no shiftConvert, so a vertical Shift wheel can chain. Fixed in ebb36e5d. The comment now calls the exemption policy and names the unverified macOS gap. The code is unchanged without probe evidence.
Claude R3 nit The tsconfig decline's reason does not cover the wheel fixture. Reworded in the plan (ad6e5eec) and in this body: kept out for parity with the sibling fixture, which needs a TS2683 fix first.

Limits

Fixes #791

🤖 Generated with Claude Code

https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV

Juliusolsson05 and others added 14 commits September 4, 2026 20:11
Let xterm consume normal scrollback and provider mouse input before canceling only unhandled vertical defaults on the host. This prevents inline terminal gestures from moving their surrounding panel without adding PTY, repaint, or React work. Preserve modified and horizontal gestures and dispose ownership on every host teardown.

Refs #791
…undary

Brings the wheel-boundary fix (#791) onto current main, 379 commits ahead,
including the xterm 6.1.0-beta.304 / WebGL re-enable (#873), terminal
session parity (#872) and OpenCode Terminal (#882).

Conflicts, all at the three xterm hosts:
- AgentInlineTerminal.tsx: main moved the WebGL attach below `scheduleFit`
  (onRendererChange); keep only the boundary attach right after open().
- AgentTerminalLeaf.tsx: imports - keep the boundary import, drop
  AgentTitleHeader (removed on main in favor of PaneHeader); body - boundary
  attach after open(), then main's onRendererChange WebGL attach.
- TerminalLeaf.tsx: imports - keep both sides; body - same as above.

Re-checked against the installed beta: the scrollable element still only
consumes a wheel it scrolled (alwaysConsumeMouseWheel and
consumeMouseWheelIfScrollbarIsNeeded default false, Viewport sets neither),
and MouseService's passive wheel handler returns without preventDefault
while the buffer has scrollback, so the boundary leak persists and the
bubbling host listener remains the right place to cancel it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
Main moved xterm to exact-pinned 6.1.0-beta.304 (#873). A version bump is
exactly when a helper like this silently becomes redundant or wrong, so the
WHY now cites where the beta's wheel listeners live, when they consume, and
what to re-check (and when to delete the helper) on the next bump. Records
the re-validation and its tsc/test results in the branch plan.

Refs #791

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
Alt+wheel is xterm's own fast-scroll gesture (fastScrollSensitivity in
scrollableElement._handleMouseWheel), not a browser gesture. At a scrollback
boundary xterm leaves it unconsumed exactly like a plain wheel, so exempting
Alt let a fast scroll that hit the top or bottom move the debug panel: the
#791 symptom for Alt users. Ctrl (pinch/zoom), Meta and the deliberate
Shift/horizontal policy stay exempt. The new regression covers both
directions and fails when the altKey bail is restored.

The review also found three inaccuracies in the helper's WHY comment:
- Its CSS rejection relied on a pre-Chrome-144 rule. overscroll-behavior now
  applies to non-scrollable containers, and Electron 43.1.1 ships Chrome 150,
  so CSS might work. It was never probed, so the source-verified JS path
  stays. The comment states what a user-run probe must show before a swap.
- The !event.cancelable path now cites Chromium's wheel-latching explainer
  and is marked as reasoned, not probe-verified.
- WHEN BUMPING XTERM now points at the manual Electron probe.

Real-xterm unit coverage was attempted and is not feasible in happy-dom.
open() throws in the DOM renderer's WidthCache, because getContext('2d') is
null, before Viewport or MouseService exist. There is also no layout for
scroll geometry. The test file now says so instead of implying it models
xterm, and the bubble-phase test is retitled to what it actually pins.

Refs #791

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
The Electron probe is the only check that runs the real xterm against
Chromium's native scroll chaining, so it now also asserts the Alt fix:
- An Alt notch mid-history must fast-scroll without moving the parent.
- An Alt wheel at the bottom boundary must stay inside the terminal, and
  must still leak in --control.
The later alternate-screen and mouse comparisons now anchor on that last
boundary state.

The header now explains:
- why the probe is manual and user-run (agents never launch Electron; no npm
  script or CI gate);
- what --control proves;
- what each assertion protects;
- that both modes must be re-run on every xterm, addon-webgl or Electron bump.
The probe was not run as part of this change.

Refs #791

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
ARCHITECTURE.md 6.6.2 lists the xterm helpers that must be re-checked on
every xterm bump. The wheel boundary belongs there: it is a cross-host
invariant, attached after open() so xterm keeps first refusal.

The plan now records:
- each review finding's disposition;
- why real-xterm tests are infeasible in happy-dom;
- why the smoke fixtures stay out of tsconfig.web.json (the sibling
  terminal-renderer fixture fails TS2683);
- this round's verification;
- that the Electron probe still needs a user re-run on the current stack.

Refs #791

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
…undary

Refs #791

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
In --control the plain and Alt boundary wheels deliberately leak and scroll
#outer to about 240px. Every probe wheel is sent at a fixed window point, so
the later alternate-screen and SGR steps would land below xterm's 20-row
screen, miss xterm entirely, and report an xterm regression that is really
fixture drift (PR #792 review round 2).

After both boundary states are recorded, the probe now resets the parent
through the fixture's resetOuter(). It asserts the reset took effect and
requires outer === 0 for both protocol steps in both modes. The old
equalities against the leaked offset are gone. This is reasoned statically,
not run. The reset is a no-op in default mode. In control mode an upward
protocol wheel cannot scroll a parent already at 0, even if Chromium still
latches the earlier leak's sequence to it.

Refs #791

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
The helper-unit file uses plain DOM stand-ins, so it stays green if the
pinned xterm changes which wheels it consumes. Review round 1 recorded
real-xterm coverage as infeasible because unshimmed happy-dom makes open()
throw (null 2D context in WidthCache) and has no layout. Round 2 showed
that narrow environment shims are enough.

The new file opens the real, patched @xterm/xterm with three shims scoped to
it:
- a 2D context exposing font and measureText;
- a fixed size for the char-measure span;
- 0px screen padding.
Each synthetic WheelEvent also gets the clientX/clientY and modifier fields
that happy-dom's UIEvent-based WheelEvent lacks. Without them the SGR report
came out as ESC[<64;NaN;NaNM.

Every asserted behavior is computed by xterm: delta normalization, the Alt
multiplier, clamping, the consume decision, buffer switching, arrow
conversion and SGR encoding. Assertions are relative or semantic, never
derived from the fake sizes.

The file pins:
- plain and Alt scrollback consumed by xterm, with Alt moving farther;
- exhausted plain and Alt wheels at top and bottom canceled by the helper,
  but NOT after dispose(), which pins the leak in the pinned xterm;
- ESC [ A on the alternate screen;
- one SGR report under DECSET 1000/1006.
It claims no native scroll chaining, latching or WebGL coverage; the
Electron probe keeps that role.

The unit file's header now describes this division instead of
infeasibility. Its Ctrl/Meta/Shift case is renamed to say the helper only
leaves UNCONSUMED modified wheels to the browser, since xterm may already
have consumed them as scrollback.

Refs #791

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
The Ctrl/Meta rationale described helper policy as xterm behavior. xterm does
not filter either modifier: while the viewport can move, _handleMouseWheel
consumes a Ctrl or Meta wheel as ordinary scrollback. Only an unconsumed one
reaches the helper, which leaves it to the browser by choice (pinch/zoom,
platform navigation).

The Shift rationale leaned on shiftConvert, which xterm skips on macOS. A
macOS Shift wheel that still arrives vertical is scrolled vertically by
xterm, so at a boundary it can chain to the parent exactly as Alt did. The
comment now calls the exemption policy and names that unverified gap. The
code is unchanged: narrowing the bail needs probe evidence first.

Refs #791

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
Records in the plan:
- the round-2 dispositions: control probe reset, real-xterm test, Ctrl/Meta
  and Shift rationale, and the tsconfig decline reworded to parity with the
  sibling fixture;
- that round 1's "infeasible" verdict is superseded;
- the origin/main merge;
- this round's verification on the exact committed tree.
The Electron probe still needs a user re-run on the current stack.

Refs #791

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4AoJJUBex14FAEZZfHPsV
@Juliusolsson05
Juliusolsson05 merged commit b83faa2 into main Sep 13, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the fix/terminal-wheel-boundary branch September 13, 2026 01:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(terminal): wheel input escapes inline terminals at scrollback boundaries

1 participant