Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/superpowers/plans/2026-09-05-terminal-wheel-boundary.md
Original file line number Diff line number Diff line change
@@ -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.
115 changes: 115 additions & 0 deletions scripts/smoke-terminal-wheel.mjs
Original file line number Diff line number Diff line change
@@ -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'), `<!doctype html>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'">
<link rel="stylesheet" href="xterm.css">
<style>body{margin:0;background:#17191d}#outer{height:420px;overflow:auto}#host{height:350px;width:750px;position:relative;overflow:hidden}#spacer{height:800px}</style>
<div id="outer"><div id="host"></div><div id="spacer"></div></div><script src="renderer.js"></script>`)
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) })
}
4 changes: 4 additions & 0 deletions src/renderer/src/features/debug/ui/AgentInlineTerminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,6 +49,7 @@ export const AgentInlineTerminal = memo(function AgentInlineTerminal({ sessionId
let term: Terminal | null = null
let fit: FitAddon | null = null
let webglRenderer: ReturnType<typeof attachXtermWebglRenderer> | null = null
let wheelBoundary: ReturnType<typeof attachTerminalWheelBoundary> | null = null
let onDataDisposable: { dispose(): void } | null = null
let offPtyData: (() => void) | null = null
let resizeObserver: ResizeObserver | null = null
Expand All @@ -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

Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
})
27 changes: 27 additions & 0 deletions src/renderer/src/workspace/terminal/terminalWheelBoundary.ts
Original file line number Diff line number Diff line change
@@ -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) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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') : <AgentInlineTerminal sessionId="wheel-inline" active />)
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(<AgentInlineTerminal sessionId="inline-layout" active />)
await act(async () => {
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -112,6 +113,7 @@ export function AgentTerminalLeaf({
let term: Terminal | null = null
let fit: FitAddon | null = null
let webglRenderer: ReturnType<typeof attachXtermWebglRenderer> | null = null
let wheelBoundary: ReturnType<typeof attachTerminalWheelBoundary> | null = null
let onDataDisposable: { dispose(): void } | null = null
let offPtyData: (() => void) | null = null
let resizeObserver: ResizeObserver | null = null
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -415,6 +418,7 @@ export function AgentTerminalLeaf({
onDataDisposable?.dispose()
offPtyData?.()
webglRenderer?.dispose()
wheelBoundary?.dispose()
if (onThemeChangedListener) {
window.removeEventListener(THEME_CHANGED_EVENT, onThemeChangedListener)
}
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down Expand Up @@ -145,6 +146,7 @@ export function TerminalLeaf({
let term: Terminal | null = null
let fit: FitAddon | null = null
let webglRenderer: ReturnType<typeof attachXtermWebglRenderer> | null = null
let wheelBoundary: ReturnType<typeof attachTerminalWheelBoundary> | null = null
let onDataDisposable: { dispose(): void } | null = null
let offTerminalData: (() => void) | null = null
let resizeObserver: ResizeObserver | null = null
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -435,6 +438,7 @@ export function TerminalLeaf({
onDataDisposable?.dispose()
offTerminalData?.()
webglRenderer?.dispose()
wheelBoundary?.dispose()
if (onThemeChangedListenerRef) {
window.removeEventListener(THEME_CHANGED_EVENT, onThemeChangedListenerRef)
}
Expand Down
Loading