diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index ef062070c..e44967b7c 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1168,7 +1168,7 @@ Object.assign(CodemanApp.prototype, { // Momentum phase — convert pixel velocity to lines const lines = Math.round(velocity / cellHeight()); if (lines !== 0) { - if (this._shouldForwardWheelToApp({ shiftKey: false })) { + if (this._shouldForwardTouchToApp()) { // Flick momentum keeps feeding the CLI's transcript from the last // touch point; the 40ms coalescer batches the per-frame reports. this._forwardScrollToApp(touchLastX, touchLastY, lines); @@ -1347,7 +1347,7 @@ Object.assign(CodemanApp.prototype, { const ch = cellHeight(); const lines = Math.trunc(pixelAccum / ch); if (lines !== 0) { - if (this._shouldForwardWheelToApp({ shiftKey: false })) { + if (this._shouldForwardTouchToApp()) { this._logScrollRouting('forward-sgr'); this._forwardScrollToApp(touchLastX, touchLastY, lines); } else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) { @@ -5833,6 +5833,18 @@ Object.assign(CodemanApp.prototype, { // `target` ({ terminal, sessionId }) asks the question for a TerminalTile: // its own terminal's tracking mode and its own session, never the active one. // Either field left out means the primary pane's. + // xterm encodes native wheel events when mouse tracking is enabled, but + // our touch listeners consume finger drags themselves. Forward those + // explicitly: local scrollLines() cannot move an alternate-screen buffer. + _shouldForwardTouchToApp() { + if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false; + const mode = this.terminal?.modes?.mouseTrackingMode; + // The existing synthetic sender uses SGR; tracking alone does not imply + // that encoding (legacy/X10 applications must not receive SGR bytes). + const sgr = this.terminal?._core?.coreMouseService?.activeEncoding === 'SGR'; + return !!(mode && mode !== 'none' && mode !== 'x10' && sgr) || this._shouldForwardWheelToApp({ shiftKey: false }); + }, + _shouldForwardWheelToApp(ev, target = {}) { if (ev.shiftKey) return false; // Opt-out (App Settings → Input → "Wheel scrolls local history"): pin the diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 4c3bcad20..6293a21cf 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -877,3 +877,38 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => { expect(logs[0]).toContain('cliVersion=unknown'); }); }); + +describe('touch scrolling when xterm owns native mouse tracking', () => { + it.each(['vt200', 'drag', 'any'])('forwards touch for %s tracking even for a shell session', (mode) => { + const { app } = hollowApp({ mode: 'shell' }); + app.terminal.modes.mouseTrackingMode = mode; + app.terminal._core = { coreMouseService: { activeEncoding: 'SGR' } }; + app.terminal.buffer.active.type = 'alternate'; + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + expect(app._shouldForwardTouchToApp()).toBe(true); + }); + + it('does not send SGR to a legacy-protocol or X10 application', () => { + const { app } = hollowApp({ mode: 'shell' }); + app.terminal.modes.mouseTrackingMode = 'vt200'; + app.terminal._core = { coreMouseService: { activeEncoding: 'DEFAULT' } }; + expect(app._shouldForwardTouchToApp()).toBe(false); + app.terminal._core.coreMouseService.activeEncoding = 'SGR'; + app.terminal.modes.mouseTrackingMode = 'x10'; + expect(app._shouldForwardTouchToApp()).toBe(false); + }); + + it('keeps the explicit local-history preference for touch', () => { + const { app } = hollowApp({ mode: 'shell' }); + app.terminal.modes.mouseTrackingMode = 'any'; + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); + expect(app._shouldForwardTouchToApp()).toBe(false); + }); + + it('uses normal CLI routing when xterm mouse tracking is off', () => { + const { app } = hollowApp({ cliVersion: '2.1.187', cliMouseTracking: true }); + expect(app._shouldForwardTouchToApp()).toBe(true); + app.sessions.get('sess-1').cliMouseTracking = false; + expect(app._shouldForwardTouchToApp()).toBe(false); + }); +}); diff --git a/test/terminal-touch-paging.browser.test.ts b/test/terminal-touch-paging.browser.test.ts index 4dcaaba00..5fb41a7c2 100644 --- a/test/terminal-touch-paging.browser.test.ts +++ b/test/terminal-touch-paging.browser.test.ts @@ -144,4 +144,58 @@ describe('touch paging of a hollow Codex pane in a real browser', () => { expect(second.keys).toBe(UP + UP); await touch('touchend', x, y0 + Math.ceil(cell)); }, 60000); + it.each([390, 820])('forwards drag and momentum with native mouse tracking at %ipx', async (width) => { + await page.setViewportSize({ width, height: 900 }); + await page.evaluate(async (id) => { + const app = (window as any).app; + app.sessions.set(id, { id, name: id, mode: 'shell', status: 'idle' }); + app.activeSessionId = id; + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); + app._wheelSgrQueue = ''; + const sent: string[] = []; + app._sendInputEphemeral = (_id: string, data: string) => sent.push(data); + (window as any).__mouseTouchProbe = { sent }; + await new Promise((resolve) => app.terminal.write('\x1b[?1049h\x1b[?1003h\x1b[?1006h', resolve)); + }, PROBE_ID); + const point = await page.evaluate(() => { + const app = (window as any).app; + const r = document.getElementById('terminalContainer')!.getBoundingClientRect(); + return { + x: r.left + 30, + y: r.top + 200, + mode: app.terminal.modes.mouseTrackingMode, + buffer: app.terminal.buffer.active.type, + }; + }); + expect(point.mode).toBe('any'); + expect(point.buffer).toBe('alternate'); + await touch('touchstart', point.x, point.y); + await touch('touchmove', point.x, point.y - 60); + const drag = await page.evaluate(() => { + (window as any).app._flushWheelSgrQueue(); + return (window as any).__mouseTouchProbe.sent.join(''); + }); + expect(drag).toMatch(/\x1b\[<65;\d+;\d+M/); + await touch('touchend', point.x, point.y - 60); + await page.waitForTimeout(150); + const momentum = await page.evaluate(() => { + (window as any).app._flushWheelSgrQueue(); + return (window as any).__mouseTouchProbe.sent.join(''); + }); + expect(momentum.length).toBeGreaterThan(drag.length); + // Stop momentum before changing the requested mouse encoding. + await touch('touchstart', point.x, point.y); + await touch('touchcancel', point.x, point.y); + await page.evaluate(async () => { + const app = (window as any).app; + app._wheelSgrQueue = ''; + (window as any).__mouseTouchProbe.sent.length = 0; + await new Promise((resolve) => app.terminal.write('\x1b[?1006l', resolve)); + }); + await touch('touchstart', point.x, point.y); + await touch('touchmove', point.x, point.y - 60); + await page.waitForTimeout(60); + expect(await page.evaluate(() => (window as any).__mouseTouchProbe.sent.join(''))).toBe(''); + await touch('touchcancel', point.x, point.y); + }); });