Skip to content
Merged
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
27 changes: 21 additions & 6 deletions src/components/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -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
Expand Down
46 changes: 42 additions & 4 deletions test/e2e/opencode-touch-scroll-input-policy.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const wsMocks = vi.hoisted(() => ({

let latestTerminal: {
scrollLines: ReturnType<typeof vi.fn>
element: HTMLElement | null
} | null = null

vi.mock('@/lib/ws-client', () => ({
Expand Down Expand Up @@ -75,6 +76,7 @@ vi.mock('@xterm/xterm', () => {
reset = vi.fn()
scrollToBottom = vi.fn()
scrollLines = vi.fn()
element: HTMLElement | null = null

constructor() {
latestTerminal = this
Expand Down Expand Up @@ -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(
Expand All @@ -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, {
Expand All @@ -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(
Expand All @@ -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, {
Expand All @@ -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()
})
})
Loading
Loading