From 9c406a78eb3864c45283963a94c1876171d45c7c Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 4 Sep 2026 20:11:56 -0700 Subject: [PATCH 1/3] docs(terminal): plan wheel boundary containment --- .../plans/2026-09-05-terminal-wheel-boundary.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-05-terminal-wheel-boundary.md 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..87c8eafe --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-terminal-wheel-boundary.md @@ -0,0 +1,16 @@ +# 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. From 97c838ba7a4356dbfe584bd68aaccdfe7507dab0 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 4 Sep 2026 20:15:20 -0700 Subject: [PATCH 2/3] fix(terminal): contain wheel input at scrollback boundaries 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 --- .../2026-09-05-terminal-wheel-boundary.md | 10 ++ scripts/smoke-terminal-wheel.mjs | 115 ++++++++++++++++++ .../features/debug/ui/AgentInlineTerminal.tsx | 4 + .../terminalWheelBoundary.renderer.test.ts | 93 ++++++++++++++ .../terminal/terminalWheelBoundary.ts | 27 ++++ ...lLeaf.dimensionOwnership.renderer.test.tsx | 19 +++ .../workspace/tile-tree/AgentTerminalLeaf.tsx | 4 + .../src/workspace/tile-tree/TerminalLeaf.tsx | 4 + testing/fixtures/terminal-wheel/smoke.ts | 51 ++++++++ 9 files changed, 327 insertions(+) create mode 100644 scripts/smoke-terminal-wheel.mjs create mode 100644 src/renderer/src/workspace/terminal/terminalWheelBoundary.renderer.test.ts create mode 100644 src/renderer/src/workspace/terminal/terminalWheelBoundary.ts create mode 100644 testing/fixtures/terminal-wheel/smoke.ts diff --git a/docs/superpowers/plans/2026-09-05-terminal-wheel-boundary.md b/docs/superpowers/plans/2026-09-05-terminal-wheel-boundary.md index 87c8eafe..8898858f 100644 --- a/docs/superpowers/plans/2026-09-05-terminal-wheel-boundary.md +++ b/docs/superpowers/plans/2026-09-05-terminal-wheel-boundary.md @@ -14,3 +14,13 @@ reaches the terminal host unconsumed. Preserve modifier and horizontal gestures. 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. +- 25 affected renderer 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() +} From a5c8ed5d4ddf58c972203c1cfad76545838193aa Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 4 Sep 2026 20:16:51 -0700 Subject: [PATCH 3/3] docs(terminal): record integrated wheel verification --- docs/superpowers/plans/2026-09-05-terminal-wheel-boundary.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-09-05-terminal-wheel-boundary.md b/docs/superpowers/plans/2026-09-05-terminal-wheel-boundary.md index 8898858f..fe74fc39 100644 --- a/docs/superpowers/plans/2026-09-05-terminal-wheel-boundary.md +++ b/docs/superpowers/plans/2026-09-05-terminal-wheel-boundary.md @@ -18,8 +18,8 @@ reported scrolling problem or change providers' alternate-screen behavior. ## Verification - Shared bubbling helper attached/disposed by all three terminal hosts. -- 25 affected renderer tests pass, including all host lifetimes; type-check and - the test contract pass. +- 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.