diff --git a/docs/superpowers/plans/2026-09-05-terminal-wheel-boundary.md b/docs/superpowers/plans/2026-09-05-terminal-wheel-boundary.md
new file mode 100644
index 00000000..fe74fc39
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-05-terminal-wheel-boundary.md
@@ -0,0 +1,26 @@
+# Terminal wheel boundary — #791
+
+The real Chromium wheel probe reproduces vertical wheel input escaping a nested
+terminal at its scrollback boundary. Keep xterm's normal wheel handling first;
+only prevent the browser's ancestor-scroll default when vertical wheel input
+reaches the terminal host unconsumed. Preserve modifier and horizontal gestures.
+
+1. Add a disposable, bubble-phase boundary helper to all three xterm hosts.
+2. Cover ordinary scrollback ownership, boundary cancellation, modifier/horizontal
+ escape, and cleanup without scheduling React, repaint, or PTY work.
+3. Verify with real Chromium wheel input, including alternate-screen and mouse
+ reporting. Re-run affected host tests and type-checking.
+4. Review and open a separate PR; no merge without final user confirmation.
+
+This is separate from #789's atlas repair. It does not claim to reproduce every
+reported scrolling problem or change providers' alternate-screen behavior.
+
+## Verification
+
+- Shared bubbling helper attached/disposed by all three terminal hosts.
+- After integrating the approved batch, 43 affected renderer/GPU-helper tests
+ pass, including all host lifetimes; type-check and the test contract pass.
+- Real Electron wheel probe: control moves the outer panel 120px at the boundary;
+ patched keeps it at 0px. Normal scrollback, output anchoring, alternate-screen
+ arrows, and SGR mouse reporting pass in both modes.
+- Probe retained as scripts/smoke-terminal-wheel.mjs, including --control.
diff --git a/scripts/smoke-terminal-wheel.mjs b/scripts/smoke-terminal-wheel.mjs
new file mode 100644
index 00000000..0a17657c
--- /dev/null
+++ b/scripts/smoke-terminal-wheel.mjs
@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+// Run: node scripts/smoke-terminal-wheel.mjs [--control]
+// Real Chromium default scrolling cannot be simulated by happy-dom dispatch.
+import { createRequire } from 'node:module'
+import { mkdtemp, writeFile, copyFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { spawn } from 'node:child_process'
+import assert from 'node:assert/strict'
+
+const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
+const require = createRequire(import.meta.url)
+if (!process.versions.electron) {
+ const dir = await mkdtemp(join(tmpdir(), 'agent-code-terminal-wheel-'))
+ console.log('Terminal wheel artifacts: ' + dir)
+ const { build } = await import('vite')
+ await build({
+ configFile: false, root, logLevel: 'warn',
+ build: { outDir: dir, emptyOutDir: false, lib: {
+ entry: join(root, 'testing/fixtures/terminal-wheel/smoke.ts'),
+ name: 'probe', formats: ['iife'], fileName: () => 'renderer.js',
+ } },
+ })
+ await copyFile(join(root, 'node_modules/@xterm/xterm/css/xterm.css'), join(dir, 'xterm.css'))
+ await writeFile(join(dir, 'index.html'), `
+
+
+
+
`)
+ const env = { ...process.env }
+ delete env.ELECTRON_RUN_AS_NODE
+ const child = spawn(require('electron'), [fileURLToPath(import.meta.url), dir, ...process.argv.slice(2)], { env, stdio: 'inherit' })
+ const interrupt = () => child.kill('SIGINT')
+ const terminate = () => child.kill('SIGTERM')
+ process.once('SIGINT', interrupt)
+ process.once('SIGTERM', terminate)
+ try {
+ process.exitCode = await new Promise((resolve, reject) => {
+ child.once('error', reject)
+ child.once('exit', code => resolve(code ?? 1))
+ })
+ } finally {
+ process.removeListener('SIGINT', interrupt)
+ process.removeListener('SIGTERM', terminate)
+ }
+} else {
+ const { app, BrowserWindow } = require('electron')
+ const dir = process.argv[2]
+ app.setPath('userData', join(dir, 'profile'))
+ // Top-level await app.whenReady would deadlock Electron's ESM entry startup.
+ void app.whenReady().then(async () => {
+ const window = new BrowserWindow({
+ width: 800, height: 600, show: false,
+ webPreferences: { sandbox: true, backgroundThrottling: false },
+ })
+ const timeout = setTimeout(() => { window.destroy(); app.exit(1) }, 30_000)
+ const js = source => window.webContents.executeJavaScript(source)
+ let status = 1
+ try {
+ await window.loadFile(join(dir, 'index.html'))
+ const control = process.argv.includes('--control')
+ const report = { control, initial: await js(`probe.setup(${control})`) }
+ const wheel = async deltaY => {
+ window.webContents.sendInputEvent({ type: 'mouseMove', x: 200, y: 100 })
+ // Electron does not infer legacy wheel ticks from pixel deltas. xterm's
+ // upstream normalizer reads wheelDeltaY first; omitting wheelTicksY
+ // creates an impossible zero-tick event and falsely makes scrollback fail.
+ window.webContents.sendInputEvent({
+ type: 'mouseWheel', x: 200, y: 100, deltaY, deltaX: 0,
+ wheelTicksY: deltaY / 120, wheelTicksX: 0,
+ hasPreciseScrollingDeltas: true, canScroll: true,
+ })
+ await new Promise(resolve => setTimeout(resolve, 100))
+ await js('probe.settle()')
+ return js('probe.state()')
+ }
+ report.up = await wheel(120)
+ report.outputWhileScrolled = await js('probe.append()')
+ report.down = await wheel(-120)
+ await js('probe.bottom()')
+ report.boundaryDown = await wheel(-120)
+ await js('probe.alternate()')
+ report.alternateUp = await wheel(120)
+ await js('probe.alternate(true)')
+ report.mouseUp = await wheel(120)
+ await writeFile(join(dir, 'report.json'), JSON.stringify(report, null, 2))
+ await writeFile(join(dir, 'terminal.png'), (await window.webContents.capturePage()).toPNG())
+ console.log(JSON.stringify(report))
+ assert(report.up.top < report.initial.top, 'Wheel must move terminal scrollback')
+ assert.equal(report.outputWhileScrolled.top, report.up.top, 'New output must preserve the scrolled position')
+ assert.equal(report.outputWhileScrolled.first, report.up.first)
+ assert(report.outputWhileScrolled.base > report.initial.base)
+ assert(report.down.top > report.up.top)
+ for (const state of [report.up, report.outputWhileScrolled, report.down]) {
+ assert.equal(state.outer, 0, 'Normal scrollback must not move the parent')
+ assert.deepEqual(state.writes, [], 'Normal scrollback must not generate PTY input')
+ }
+ if (control) assert(report.boundaryDown.outer > 0, 'Control must expose boundary scroll chaining')
+ else assert.equal(report.boundaryDown.outer, 0, 'Boundary input must stay inside terminal')
+ assert.deepEqual(report.alternateUp.writes, ['\x1b[A'])
+ assert.equal(report.mouseUp.writes.length, 1)
+ assert.match(report.mouseUp.writes[0], /^\x1b\[<64;\d+;\d+M$/)
+ assert.equal(report.alternateUp.outer, report.boundaryDown.outer)
+ assert.equal(report.mouseUp.outer, report.boundaryDown.outer)
+ status = 0
+ } catch (error) {
+ console.error(error)
+ } finally {
+ clearTimeout(timeout)
+ window.destroy()
+ app.exit(status)
+ }
+ }).catch(error => { console.error(error); app.exit(1) })
+}
diff --git a/src/renderer/src/features/debug/ui/AgentInlineTerminal.tsx b/src/renderer/src/features/debug/ui/AgentInlineTerminal.tsx
index fbfc1900..c690a94b 100644
--- a/src/renderer/src/features/debug/ui/AgentInlineTerminal.tsx
+++ b/src/renderer/src/features/debug/ui/AgentInlineTerminal.tsx
@@ -10,6 +10,7 @@ import { readXtermTheme, syncXtermTheme } from '@renderer/workspace/tile-tree/xt
import { createTerminalInputForwarder } from '@renderer/workspace/tile-tree/terminalInputForwarder'
import { subscribeToAgentPtyData } from '@renderer/workspace/terminal/sessionDataDispatcher'
import { attachXtermWebglRenderer } from '@renderer/workspace/terminal/xtermWebglRenderer'
+import { attachTerminalWheelBoundary } from '@renderer/workspace/terminal/terminalWheelBoundary'
type Props = {
sessionId: string
@@ -48,6 +49,7 @@ export const AgentInlineTerminal = memo(function AgentInlineTerminal({ sessionId
let term: Terminal | null = null
let fit: FitAddon | null = null
let webglRenderer: ReturnType | null = null
+ let wheelBoundary: ReturnType | null = null
let onDataDisposable: { dispose(): void } | null = null
let offPtyData: (() => void) | null = null
let resizeObserver: ResizeObserver | null = null
@@ -73,6 +75,7 @@ export const AgentInlineTerminal = memo(function AgentInlineTerminal({ sessionId
fit = new FitAddon()
term.loadAddon(fit)
term.open(container)
+ wheelBoundary = attachTerminalWheelBoundary(container)
webglRenderer = attachXtermWebglRenderer(term)
termRef.current = term
@@ -161,6 +164,7 @@ export const AgentInlineTerminal = memo(function AgentInlineTerminal({ sessionId
onDataDisposable?.dispose()
offPtyData?.()
webglRenderer?.dispose()
+ wheelBoundary?.dispose()
if (onThemeChangedListener) {
window.removeEventListener(THEME_CHANGED_EVENT, onThemeChangedListener)
}
diff --git a/src/renderer/src/workspace/terminal/terminalWheelBoundary.renderer.test.ts b/src/renderer/src/workspace/terminal/terminalWheelBoundary.renderer.test.ts
new file mode 100644
index 00000000..a8489f77
--- /dev/null
+++ b/src/renderer/src/workspace/terminal/terminalWheelBoundary.renderer.test.ts
@@ -0,0 +1,93 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { attachTerminalWheelBoundary } from './terminalWheelBoundary'
+
+const cleanups: (() => void)[] = []
+afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup() })
+
+function mount() {
+ const parent = document.createElement('div')
+ const host = document.createElement('div')
+ const screen = document.createElement('div')
+ parent.appendChild(host)
+ host.appendChild(screen)
+ document.body.appendChild(parent)
+ const boundary = attachTerminalWheelBoundary(host)
+ cleanups.push(() => { boundary.dispose(); parent.remove() })
+ return { parent, screen, boundary }
+}
+
+function wheel(options: WheelEventInit = {}): WheelEvent {
+ const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120, ...options })
+ // happy-dom's WheelEvent omits MouseEvent modifier initialization. Model the
+ // native event fields explicitly; the real Chromium probe separately checks
+ // default scrolling so this DOM shim cannot bless broken browser behavior.
+ for (const modifier of ['ctrlKey', 'metaKey', 'altKey', 'shiftKey'] as const) {
+ Object.defineProperty(event, modifier, { value: options[modifier] ?? false })
+ }
+ return event
+}
+
+describe('terminal wheel boundary', () => {
+ it.each([-120, 120])('cancels exhausted vertical scrolling (%s) without suppressing engagement', deltaY => {
+ const { parent, screen } = mount()
+ const engagement = vi.fn()
+ parent.addEventListener('wheel', engagement)
+ const event = wheel({ deltaY })
+ screen.dispatchEvent(event)
+ expect(event.defaultPrevented).toBe(true)
+ expect(engagement).toHaveBeenCalledOnce()
+ })
+
+ it('lets xterm consume normal scrollback or provider mouse input first', () => {
+ const { screen } = mount()
+ const provider = vi.fn((event: Event) => {
+ expect(event.defaultPrevented).toBe(false)
+ event.preventDefault()
+ event.stopPropagation()
+ })
+ screen.addEventListener('wheel', provider)
+ screen.dispatchEvent(wheel())
+ expect(provider).toHaveBeenCalledOnce()
+ })
+
+ it.each(['ctrlKey', 'metaKey', 'altKey', 'shiftKey'] as const)('preserves modified gestures: %s', modifier => {
+ const { screen } = mount()
+ const event = wheel({ [modifier]: true })
+ screen.dispatchEvent(event)
+ expect(event.defaultPrevented).toBe(false)
+ })
+
+ it.each([{ deltaY: 0, deltaX: 120 }, { deltaY: 10, deltaX: -120 }, { deltaY: 0, deltaX: 0 }])(
+ 'does not claim horizontal or empty gestures (%j)', options => {
+ const { screen } = mount()
+ const event = wheel(options)
+ screen.dispatchEvent(event)
+ expect(event.defaultPrevented).toBe(false)
+ },
+ )
+
+ it('does not interfere with an already consumed or non-cancelable event', () => {
+ const { screen } = mount()
+ const consumed = wheel()
+ consumed.preventDefault()
+ const prevent = vi.spyOn(consumed, 'preventDefault')
+ screen.dispatchEvent(consumed)
+ expect(prevent).not.toHaveBeenCalled()
+ const nonCancelable = wheel({ cancelable: false })
+ screen.dispatchEvent(nonCancelable)
+ expect(nonCancelable.defaultPrevented).toBe(false)
+ })
+
+ it('releases the old host without detaching another terminal', () => {
+ const old = mount()
+ const current = mount()
+ old.boundary.dispose()
+ old.boundary.dispose()
+ const oldWheel = wheel()
+ old.screen.dispatchEvent(oldWheel)
+ expect(oldWheel.defaultPrevented).toBe(false)
+ const currentWheel = wheel()
+ current.screen.dispatchEvent(currentWheel)
+ expect(currentWheel.defaultPrevented).toBe(true)
+ })
+})
diff --git a/src/renderer/src/workspace/terminal/terminalWheelBoundary.ts b/src/renderer/src/workspace/terminal/terminalWheelBoundary.ts
new file mode 100644
index 00000000..f0223b59
--- /dev/null
+++ b/src/renderer/src/workspace/terminal/terminalWheelBoundary.ts
@@ -0,0 +1,27 @@
+/**
+ * Keep an exhausted terminal scroll gesture from moving its surrounding panel.
+ *
+ * WHY a bubbling native listener, not capture or a custom xterm wheel handler:
+ * xterm must get first refusal. Normal scrollback consumes the gesture itself;
+ * alternate-screen programs may translate it into arrows or mouse reports.
+ * Intercepting before xterm would break those protocols. At a scroll boundary,
+ * however, xterm's custom scrollbar leaves the event unconsumed and Chromium
+ * scrolls the nearest native ancestor (notably the inline terminal's debug
+ * panel). CSS overscroll rules on the host do not govern that custom scrollbar.
+ *
+ * Cancel only that remaining browser default. Do not synthesize input, move the
+ * viewport, refresh the renderer, stop application engagement listeners, or
+ * schedule React work. Horizontal and modified gestures remain available for
+ * browser/platform navigation and zoom; this boundary owns ordinary vertical
+ * terminal scrolling, not every gesture made over the pane.
+ */
+export function attachTerminalWheelBoundary(container: HTMLElement): { dispose(): void } {
+ const onWheel = (event: WheelEvent): void => {
+ if (event.defaultPrevented || !event.cancelable) return
+ if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return
+ if (event.deltaY === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return
+ event.preventDefault()
+ }
+ container.addEventListener('wheel', onWheel, { passive: false })
+ return { dispose: () => container.removeEventListener('wheel', onWheel) }
+}
diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.dimensionOwnership.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.dimensionOwnership.renderer.test.tsx
index 294e0e91..cb21f956 100644
--- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.dimensionOwnership.renderer.test.tsx
+++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.dimensionOwnership.renderer.test.tsx
@@ -499,6 +499,25 @@ describe('AgentTerminalLeaf dimension ownership', () => {
},
)
+ it.each(['agent', 'shell', 'inline'] as const)('owns boundary wheel input only while the %s terminal is mounted', async kind => {
+ const view = render(kind === 'agent' ? agentPane('wheel-agent') : kind === 'shell'
+ ? shellPane('wheel-shell') : )
+ await act(async () => {
+ attach.resolve('')
+ await attach.promise
+ })
+ const container = xtermHarness.instances[0]!.container!
+ const wheel = () => new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true })
+ const mounted = wheel()
+ container.dispatchEvent(mounted)
+ expect(mounted.defaultPrevented).toBe(true)
+ expect(api.sendInput).not.toHaveBeenCalled()
+ view.unmount()
+ const unmounted = wheel()
+ container.dispatchEvent(unmounted)
+ expect(unmounted.defaultPrevented).toBe(false)
+ })
+
it('coalesces inline-terminal layout bursts and only resizes the backend when grid dimensions change', async () => {
const view = render()
await act(async () => {
diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx
index fbb99b29..7ad59e1c 100644
--- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx
+++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx
@@ -19,6 +19,7 @@ import { useComposerDictation } from '@renderer/workspace/tile-tree/TileLeaf/use
import { useAgentTerminalDimensionActive, useAgentTerminalOwnerVisible } from '@renderer/workspace/terminal/AgentTerminalOwnership'
import { subscribeToAgentPtyData } from '@renderer/workspace/terminal/sessionDataDispatcher'
import { attachXtermWebglRenderer } from '@renderer/workspace/terminal/xtermWebglRenderer'
+import { attachTerminalWheelBoundary } from '@renderer/workspace/terminal/terminalWheelBoundary'
import { AgentTitleHeader } from '@renderer/workspace/tile-tree/AgentTitleHeader'
import { createTerminalInputForwarder } from '@renderer/workspace/tile-tree/terminalInputForwarder'
@@ -112,6 +113,7 @@ export function AgentTerminalLeaf({
let term: Terminal | null = null
let fit: FitAddon | null = null
let webglRenderer: ReturnType | null = null
+ let wheelBoundary: ReturnType | null = null
let onDataDisposable: { dispose(): void } | null = null
let offPtyData: (() => void) | null = null
let resizeObserver: ResizeObserver | null = null
@@ -231,6 +233,7 @@ export function AgentTerminalLeaf({
fit = new FitAddon()
term.loadAddon(fit)
term.open(container)
+ wheelBoundary = attachTerminalWheelBoundary(container)
webglRenderer = attachXtermWebglRenderer(term)
termRef.current = term
@@ -415,6 +418,7 @@ export function AgentTerminalLeaf({
onDataDisposable?.dispose()
offPtyData?.()
webglRenderer?.dispose()
+ wheelBoundary?.dispose()
if (onThemeChangedListener) {
window.removeEventListener(THEME_CHANGED_EVENT, onThemeChangedListener)
}
diff --git a/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx
index 2db8ab3b..0bc456aa 100644
--- a/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx
+++ b/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx
@@ -16,6 +16,7 @@ import { readXtermTheme, syncXtermTheme } from '@renderer/workspace/tile-tree/xt
import { createTerminalInputForwarder } from '@renderer/workspace/tile-tree/terminalInputForwarder'
import { subscribeToTerminalData } from '@renderer/workspace/terminal/sessionDataDispatcher'
import { attachXtermWebglRenderer } from '@renderer/workspace/terminal/xtermWebglRenderer'
+import { attachTerminalWheelBoundary } from '@renderer/workspace/terminal/terminalWheelBoundary'
// TerminalLeaf — one pane that hosts a plain shell session.
//
@@ -145,6 +146,7 @@ export function TerminalLeaf({
let term: Terminal | null = null
let fit: FitAddon | null = null
let webglRenderer: ReturnType | null = null
+ let wheelBoundary: ReturnType | null = null
let onDataDisposable: { dispose(): void } | null = null
let offTerminalData: (() => void) | null = null
let resizeObserver: ResizeObserver | null = null
@@ -252,6 +254,7 @@ export function TerminalLeaf({
fit = new FitAddon()
term.loadAddon(fit)
term.open(container)
+ wheelBoundary = attachTerminalWheelBoundary(container)
webglRenderer = attachXtermWebglRenderer(term)
termRef.current = term
fitRef.current = fit
@@ -435,6 +438,7 @@ export function TerminalLeaf({
onDataDisposable?.dispose()
offTerminalData?.()
webglRenderer?.dispose()
+ wheelBoundary?.dispose()
if (onThemeChangedListenerRef) {
window.removeEventListener(THEME_CHANGED_EVENT, onThemeChangedListenerRef)
}
diff --git a/testing/fixtures/terminal-wheel/smoke.ts b/testing/fixtures/terminal-wheel/smoke.ts
new file mode 100644
index 00000000..ed2e4704
--- /dev/null
+++ b/testing/fixtures/terminal-wheel/smoke.ts
@@ -0,0 +1,51 @@
+import { Terminal } from '@xterm/xterm'
+import { attachXtermWebglRenderer } from '../../../src/renderer/src/workspace/terminal/xtermWebglRenderer'
+import { attachTerminalWheelBoundary } from '../../../src/renderer/src/workspace/terminal/terminalWheelBoundary'
+
+let terminal: Terminal
+const writes: string[] = []
+export const settle = () => new Promise(resolve =>
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve())))
+const write = (data: string) => new Promise(resolve => terminal.write(data, resolve))
+
+// Synthetic text only. The parent process owns an isolated BrowserWindow and
+// destroys it after capture; never attach this probe to an actual provider PTY.
+export async function setup(control: boolean) {
+ const host = document.getElementById('host')!
+ terminal = new Terminal({ cols: 80, rows: 20, fontSize: 13, cursorBlink: false })
+ terminal.open(host)
+ if (!control) attachTerminalWheelBoundary(host)
+ if (!await attachXtermWebglRenderer(terminal).ready) throw Error('WebGL unavailable')
+ terminal.onData(data => writes.push(data))
+ await write(Array.from({ length: 400 }, (_, i) => 'line ' + i + '\r\n').join(''))
+ terminal.focus()
+ await settle()
+ return state()
+}
+export function state() {
+ const buffer = terminal.buffer.active
+ return {
+ type: buffer.type, top: buffer.viewportY, base: buffer.baseY,
+ first: buffer.getLine(buffer.viewportY)?.translateToString(true),
+ outer: document.getElementById('outer')!.scrollTop, writes: [...writes],
+ }
+}
+
+export async function append() {
+ await write(Array.from({ length: 20 }, (_, i) => 'new ' + i + '\r\n').join(''))
+ await settle()
+ return state()
+}
+
+export async function alternate(mouse = false) {
+ await write('\x1b[?1049h' + (mouse ? '\x1b[?1000h\x1b[?1006h' : '') + 'ALTERNATE')
+ writes.length = 0
+ await settle()
+ return state()
+}
+
+export async function bottom() {
+ terminal.scrollToBottom()
+ await settle()
+ return state()
+}