From 34e293c3d0fb8fab2d20a4d7d00e69c60600bedf Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 2 Jun 2026 13:59:30 -0700 Subject: [PATCH] fix(terminal): enable touch-scroll in alternate buffer with mouse tracking For providers with the native scrollInputPolicy (the default), touch swipes in alternate buffer + mouse-tracking mode formerly fell through to scrollLines, which is a no-op in xterm.js alternate buffers because they have no scrollback. Fix: when native policy is active and the terminal is in alternate buffer with mouse tracking on, dispatch synthetic WheelEvents directly to term.element. xterm.js translates these into mouse-wheel CSI sequences sent to the PTY, matching desktop wheel behavior. Includes position data (clientX/clientY) on synthetic events for accurate mouse tracking in TUIs with multiple scrollable regions. Tests cover: - Native policy wheel dispatch (upward and downward direction, multi-line swipes producing repeated events, correct deltaMode). - term.element == null fallback to scrollLines. - Fallback-to-cursor-keys policy sends cursor key sequences. - Normal buffer continues to call scrollLines as before. --- src/components/TerminalView.tsx | 27 +- ...pencode-touch-scroll-input-policy.test.tsx | 46 +++- ...nalView.touch-scroll-input-policy.test.tsx | 236 +++++++++++++++++- 3 files changed, 297 insertions(+), 12 deletions(-) diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index ec7027fef..11a8de406 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -156,10 +156,6 @@ type TerminalInputBlockedReason = | 'codex_clean_exit_decision_pending' | 'codex_lifecycle_loss_pending' -function shouldSuppressNativeTouchScroll(term: Terminal): boolean { - return term.buffer.active.type === 'alternate' && term.modes.mouseTrackingMode !== 'none' -} - function terminalInputBlockedNotice(reason: TerminalInputBlockedReason): string { switch (reason) { case 'codex_identity_pending': @@ -782,8 +778,27 @@ function TerminalView({ tabId, paneId, paneContent, hidden }: TerminalViewProps) const rawLines = touchScrollAccumulatorRef.current / TOUCH_SCROLL_PIXELS_PER_LINE const lines = rawLines > 0 ? Math.floor(rawLines) : Math.ceil(rawLines) if (lines !== 0) { - if (!translateScrollLinesToInput(term, lines) && !shouldSuppressNativeTouchScroll(term)) { - term.scrollLines(lines) + const policy = providerBehaviorRef.current.scrollInputPolicy + if (!translateScrollLinesToInput(term, lines)) { + const el = term.element + if (policy === 'native' && el && term.buffer.active.type === 'alternate' && term.modes.mouseTrackingMode !== 'none') { + // Dispatch synthetic wheel events so xterm.js handles them natively + // (sends mouse-wheel CSI sequences to the PTY). + const absLines = Math.abs(lines) + const scrollDirection = lines < 0 ? -1 : 1 + for (let i = 0; i < absLines; i++) { + el.dispatchEvent(new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: touch.clientX, + clientY: touch.clientY, + deltaY: scrollDirection, + deltaMode: WheelEvent.DOM_DELTA_LINE, + })) + } + } else { + term.scrollLines(lines) + } } touchScrollAccumulatorRef.current -= lines * TOUCH_SCROLL_PIXELS_PER_LINE diff --git a/test/e2e/opencode-touch-scroll-input-policy.test.tsx b/test/e2e/opencode-touch-scroll-input-policy.test.tsx index 6f041d09a..de65cadef 100644 --- a/test/e2e/opencode-touch-scroll-input-policy.test.tsx +++ b/test/e2e/opencode-touch-scroll-input-policy.test.tsx @@ -20,6 +20,7 @@ const wsMocks = vi.hoisted(() => ({ let latestTerminal: { scrollLines: ReturnType + element: HTMLElement | null } | null = null vi.mock('@/lib/ws-client', () => ({ @@ -75,6 +76,7 @@ vi.mock('@xterm/xterm', () => { reset = vi.fn() scrollToBottom = vi.fn() scrollLines = vi.fn() + element: HTMLElement | null = null constructor() { latestTerminal = this @@ -177,7 +179,7 @@ describe('opencode touch scroll input policy (e2e)', () => { ;(globalThis as any).setMobileForTest(false) }) - it('does not translate touch scroll for opencode providers when policy is native', async () => { + it('dispatches synthetic wheel events for opencode providers when policy is native', async () => { const { store, tabId, paneId, paneContent } = createStore('opencode', [opencodeExtensionWithBehaviorHint]) const { getByTestId } = render( @@ -192,6 +194,14 @@ describe('opencode touch scroll input policy (e2e)', () => { expect(latestTerminal).not.toBeNull() }) + // Set up a mock element for dispatchEvent + const mockEl = document.createElement('div') + const dispatchSpy = vi.fn() + mockEl.dispatchEvent = dispatchSpy + if (latestTerminal) { + latestTerminal.element = mockEl + } + wsMocks.send.mockClear() fireEvent.touchStart(container, { @@ -201,13 +211,23 @@ describe('opencode touch scroll input policy (e2e)', () => { touches: [{ clientX: 20, clientY: 100 }], }) - expect(latestTerminal?.scrollLines).not.toHaveBeenCalled() + // Should dispatch synthetic wheel events to the terminal element + expect(dispatchSpy).toHaveBeenCalledTimes(1) + const wheelEvent = dispatchSpy.mock.calls[0][0] as WheelEvent + expect(wheelEvent).toBeInstanceOf(WheelEvent) + expect(wheelEvent.deltaY).toBe(1) + expect(wheelEvent.deltaMode).toBe(WheelEvent.DOM_DELTA_LINE) + expect(wheelEvent.clientX).toBe(20) + expect(wheelEvent.clientY).toBe(100) + // Should NOT send cursor key sequences expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'terminal.input', })) + // Should NOT call scrollLines (alt buffer has no scrollback) + expect(latestTerminal?.scrollLines).not.toHaveBeenCalled() }) - it('skips scrollLines in alt screen for non-opted-in providers', async () => { + it('dispatches synthetic wheel events for shell providers in alt screen with mouse tracking', async () => { const { store, tabId, paneId, paneContent } = createStore('shell') const { getByTestId } = render( @@ -222,6 +242,14 @@ describe('opencode touch scroll input policy (e2e)', () => { expect(latestTerminal).not.toBeNull() }) + // Set up a mock element for dispatchEvent + const mockEl = document.createElement('div') + const dispatchSpy = vi.fn() + mockEl.dispatchEvent = dispatchSpy + if (latestTerminal) { + latestTerminal.element = mockEl + } + wsMocks.send.mockClear() fireEvent.touchStart(container, { @@ -231,9 +259,19 @@ describe('opencode touch scroll input policy (e2e)', () => { touches: [{ clientX: 20, clientY: 100 }], }) - expect(latestTerminal?.scrollLines).not.toHaveBeenCalled() + // Shell providers default to native policy, so they should dispatch wheel events + expect(dispatchSpy).toHaveBeenCalledTimes(1) + const wheelEvent = dispatchSpy.mock.calls[0][0] as WheelEvent + expect(wheelEvent).toBeInstanceOf(WheelEvent) + expect(wheelEvent.deltaY).toBe(1) + expect(wheelEvent.deltaMode).toBe(WheelEvent.DOM_DELTA_LINE) + expect(wheelEvent.clientX).toBe(20) + expect(wheelEvent.clientY).toBe(100) + // Should NOT send cursor key sequences expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'terminal.input', })) + // Should NOT call scrollLines (alt buffer has no scrollback) + expect(latestTerminal?.scrollLines).not.toHaveBeenCalled() }) }) diff --git a/test/unit/client/components/TerminalView.touch-scroll-input-policy.test.tsx b/test/unit/client/components/TerminalView.touch-scroll-input-policy.test.tsx index 1c1f1e923..9c89551e4 100644 --- a/test/unit/client/components/TerminalView.touch-scroll-input-policy.test.tsx +++ b/test/unit/client/components/TerminalView.touch-scroll-input-policy.test.tsx @@ -20,6 +20,7 @@ const wsMocks = vi.hoisted(() => ({ let latestTerminal: { scrollLines: ReturnType + element: HTMLElement | null } | null = null vi.mock('@/lib/ws-client', () => ({ @@ -75,6 +76,7 @@ vi.mock('@xterm/xterm', () => { reset = vi.fn() scrollToBottom = vi.fn() scrollLines = vi.fn() + element: HTMLElement | null = null constructor() { latestTerminal = this @@ -177,7 +179,7 @@ describe('TerminalView touch scroll input policy', () => { ;(globalThis as any).setMobileForTest(false) }) - it('does not translate touch scrolling for opencode providers when policy is native', async () => { + it('dispatches synthetic wheel events for opencode providers when policy is native', async () => { const { store, tabId, paneId, paneContent } = createStore('opencode', [opencodeExtensionWithBehaviorHint]) const { getByTestId } = render( @@ -192,17 +194,247 @@ describe('TerminalView touch scroll input policy', () => { expect(latestTerminal).not.toBeNull() }) + // Set up a mock element for dispatchEvent + const mockEl = document.createElement('div') + const dispatchSpy = vi.fn() + mockEl.dispatchEvent = dispatchSpy + if (latestTerminal) { + latestTerminal.element = mockEl + } + + wsMocks.send.mockClear() + + fireEvent.touchStart(container, { + touches: [{ clientX: 20, clientY: 120 }], + }) + // 20px exceeds the current 18px-per-line threshold, so this should emit one wheel event. + fireEvent.touchMove(container, { + touches: [{ clientX: 20, clientY: 100 }], + }) + + // Should dispatch synthetic wheel events to the terminal element + expect(dispatchSpy).toHaveBeenCalledTimes(1) + const wheelEvent = dispatchSpy.mock.calls[0][0] as WheelEvent + expect(wheelEvent).toBeInstanceOf(WheelEvent) + expect(wheelEvent.deltaY).toBe(1) + expect(wheelEvent.deltaMode).toBe(WheelEvent.DOM_DELTA_LINE) + // Verify position data for accurate mouse tracking + expect(wheelEvent.clientX).toBe(20) + expect(wheelEvent.clientY).toBe(100) + // Should NOT send cursor key sequences + expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.input', + })) + // Should NOT call scrollLines (alt buffer has no scrollback) + expect(latestTerminal?.scrollLines).not.toHaveBeenCalled() + }) + + it('dispatches multiple wheel events for large multi-line swipes', async () => { + const { store, tabId, paneId, paneContent } = createStore('opencode', [opencodeExtensionWithBehaviorHint]) + + const { getByTestId } = render( + + + , + ) + + const container = getByTestId('terminal-xterm-container') + + await waitFor(() => { + expect(latestTerminal).not.toBeNull() + }) + + const mockEl = document.createElement('div') + const dispatchSpy = vi.fn() + mockEl.dispatchEvent = dispatchSpy + if (latestTerminal) { + latestTerminal.element = mockEl + } + + wsMocks.send.mockClear() + + fireEvent.touchStart(container, { + touches: [{ clientX: 20, clientY: 120 }], + }) + // 55px exceeds 3 lines (54px), so this should emit 3 wheel events. + fireEvent.touchMove(container, { + touches: [{ clientX: 20, clientY: 65 }], + }) + + expect(dispatchSpy).toHaveBeenCalledTimes(3) + for (let i = 0; i < 3; i++) { + const wheelEvent = dispatchSpy.mock.calls[i][0] as WheelEvent + expect(wheelEvent).toBeInstanceOf(WheelEvent) + expect(wheelEvent.deltaY).toBe(1) + expect(wheelEvent.deltaMode).toBe(WheelEvent.DOM_DELTA_LINE) + } + expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.input', + })) + expect(latestTerminal?.scrollLines).not.toHaveBeenCalled() + }) + + it('dispatches wheel events with negative deltaY for upward scroll', async () => { + const { store, tabId, paneId, paneContent } = createStore('opencode', [opencodeExtensionWithBehaviorHint]) + + const { getByTestId } = render( + + + , + ) + + const container = getByTestId('terminal-xterm-container') + + await waitFor(() => { + expect(latestTerminal).not.toBeNull() + }) + + const mockEl = document.createElement('div') + const dispatchSpy = vi.fn() + mockEl.dispatchEvent = dispatchSpy + if (latestTerminal) { + latestTerminal.element = mockEl + } + + wsMocks.send.mockClear() + + fireEvent.touchStart(container, { + touches: [{ clientX: 20, clientY: 100 }], + }) + // Move DOWN 25px (finger moves down = content scrolls up = negative deltaY) + fireEvent.touchMove(container, { + touches: [{ clientX: 20, clientY: 125 }], + }) + + expect(dispatchSpy).toHaveBeenCalledTimes(1) + const wheelEvent = dispatchSpy.mock.calls[0][0] as WheelEvent + expect(wheelEvent).toBeInstanceOf(WheelEvent) + expect(wheelEvent.deltaY).toBe(-1) + expect(wheelEvent.deltaMode).toBe(WheelEvent.DOM_DELTA_LINE) + expect(wheelEvent.clientX).toBe(20) + expect(wheelEvent.clientY).toBe(125) + expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.input', + })) + expect(latestTerminal?.scrollLines).not.toHaveBeenCalled() + }) + + it('falls back to scrollLines when term.element is null in alt+mouse mode', async () => { + const { store, tabId, paneId, paneContent } = createStore('opencode', [opencodeExtensionWithBehaviorHint]) + + const { getByTestId } = render( + + + , + ) + + const container = getByTestId('terminal-xterm-container') + + await waitFor(() => { + expect(latestTerminal).not.toBeNull() + }) + + // Ensure element is null + if (latestTerminal) { + latestTerminal.element = null + } + + wsMocks.send.mockClear() + + fireEvent.touchStart(container, { + touches: [{ clientX: 20, clientY: 120 }], + }) + fireEvent.touchMove(container, { + touches: [{ clientX: 20, clientY: 100 }], + }) + + // Fallback: should call scrollLines instead of dispatching wheel events + expect(latestTerminal?.scrollLines).toHaveBeenCalledWith(1) + expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.input', + })) + }) + + it('sends cursor key sequences for fallbackToCursorKeysWhenAltScreenMouseCapture policy', async () => { + const extensionWithFallback: ClientExtensionEntry = { + name: 'opencode', + version: '1.0.0', + label: 'OpenCode', + description: 'OpenCode CLI agent', + category: 'cli', + cli: { + terminalBehavior: { + preferredRenderer: 'canvas', + scrollInputPolicy: 'fallbackToCursorKeysWhenAltScreenMouseCapture', + }, + }, + } + + const { store, tabId, paneId, paneContent } = createStore('opencode', [extensionWithFallback]) + + const { getByTestId } = render( + + + , + ) + + const container = getByTestId('terminal-xterm-container') + + await waitFor(() => { + expect(latestTerminal).not.toBeNull() + }) + wsMocks.send.mockClear() fireEvent.touchStart(container, { touches: [{ clientX: 20, clientY: 120 }], }) - // 20px exceeds the current 18px-per-line threshold, so this should emit one down-arrow sequence. fireEvent.touchMove(container, { touches: [{ clientX: 20, clientY: 100 }], }) + // Should send cursor key sequences (down arrow for positive scroll) + expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'terminal.input', + data: '\u001b[B', + })) + // Should NOT dispatch wheel events expect(latestTerminal?.scrollLines).not.toHaveBeenCalled() + }) + + it('calls scrollLines for shell providers in normal buffer', async () => { + // Mock terminal in normal buffer + const { store, tabId, paneId, paneContent } = createStore('shell', []) + + const { getByTestId } = render( + + + , + ) + + const container = getByTestId('terminal-xterm-container') + + await waitFor(() => { + expect(latestTerminal).not.toBeNull() + }) + + // Switch to normal buffer + if (latestTerminal) { + latestTerminal.buffer = { active: { type: 'normal' as const } } + } + + wsMocks.send.mockClear() + + fireEvent.touchStart(container, { + touches: [{ clientX: 20, clientY: 120 }], + }) + fireEvent.touchMove(container, { + touches: [{ clientX: 20, clientY: 100 }], + }) + + // Should call scrollLines for normal buffer + expect(latestTerminal?.scrollLines).toHaveBeenCalled() + // Should NOT send input sequences expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'terminal.input', }))