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() +}