From 9535edc367a69a018a4b8957565a0fb965a52230 Mon Sep 17 00:00:00 2001 From: Teigen Date: Mon, 6 Jul 2026 16:35:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(mobile):=20restore=20tap-to-position=20curs?= =?UTF-8?q?or=20after=20master=20merge=20=E2=80=94=20hand-encode=20SGR=20w?= =?UTF-8?q?hen=20server=20strips=20mouse=20DECSETs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.1.7 (3172bef, arrived via the master merge) strips mouse-tracking DECSET sequences from claude/codex/gemini output so the wheel keeps scrolling scrollback. Side effect: the browser xterm's mouseTrackingMode is permanently 'none' for those sessions, and the mobile touchend tap branch gates its synthetic click on exactly that mode — so tap-to-position-cursor silently died. Fix: when tracking reads 'none' but the session mode is one the server strips (claude/codex/gemini — the PTY-side TUI still has tracking ON), encode the SGR press+release report directly from the touch point and send it to the PTY, bypassing xterm's mouse encoder. No DOM click is dispatched, so xterm's local selection cannot trigger either. Tests: 3 new cases in test/terminal-touch-tap.test.ts (SGR encoding, grid clamping, shell-mode exclusion); verified E2E via Playwright iPhone emulation against both a stripped-stream instance and the production bundle. --- src/web/public/terminal-ui.js | 33 ++++++++++++++++++++++ test/terminal-touch-tap.test.ts | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d44e5dbf..326b818d 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -452,6 +452,15 @@ Object.assign(CodemanApp.prototype, { } if (touch && mouseTrackingOn) { this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY); + } else if (touch && this._sessionUsesServerMouseStrip()) { + // The server strips mouse-tracking DECSETs from claude/codex/gemini + // output (isAltScreenStripMode, session.ts) so the wheel keeps + // scrolling scrollback — which leaves THIS xterm permanently at + // mouseTrackingMode 'none' even though the TUI on the PTY side has + // tracking ON and still understands SGR reports. Encode the report + // ourselves and send it straight to the PTY: no DOM click is + // dispatched, so xterm's local selection can't trigger either. + this._sendSyntheticSgrTap(touch.clientX, touch.clientY); } this._syncMobileHelperTextareaToCursor(); // Route subsequent typing to the right place: keep the CJK input @@ -2073,6 +2082,30 @@ Object.assign(CodemanApp.prototype, { } }, + // Mirror of the server's isAltScreenStripMode (session.ts): session modes whose + // output stream has mouse-tracking DECSET sequences stripped before reaching the + // browser. For these, xterm's live mouseTrackingMode is useless as a gate — the + // PTY-side TUI keeps tracking enabled, we just never see the enable sequence. + _sessionUsesServerMouseStrip() { + const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude'; + return mode === 'claude' || mode === 'codex' || mode === 'gemini'; + }, + + // Encode a tap as an SGR mouse report (press + release at button 0) and send it + // to the PTY directly, bypassing xterm's mouse encoder. Column/row are derived + // from the touch point the same way xterm maps a click: offset inside + // .xterm-screen divided by the rendered cell size, 1-based, clamped to the grid. + _sendSyntheticSgrTap(clientX, clientY) { + if (!this.activeSessionId || !this.terminal || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return; + const screen = this.terminal.element?.querySelector('.xterm-screen'); + const cell = this.terminal._core?._renderService?.dimensions?.css?.cell; + if (!screen || !cell?.width || !cell?.height) return; + const rect = screen.getBoundingClientRect(); + const col = Math.max(1, Math.min(this.terminal.cols, Math.floor((clientX - rect.left) / cell.width) + 1)); + const row = Math.max(1, Math.min(this.terminal.rows, Math.floor((clientY - rect.top) / cell.height) + 1)); + this._sendInputAsync(this.activeSessionId, `\x1b[<0;${col};${row}M\x1b[<0;${col};${row}m`); + }, + _installMobileTapMouseGuard() { const el = this.terminal?.element; if (!el || el._codemanTapMouseGuardInstalled) return; diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 04121595..c9edf859 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -94,6 +94,56 @@ describe('terminal touch tap mouse guard', () => { expect(event.stopImmediatePropagation).not.toHaveBeenCalled(); }); + it('encodes a tap as an SGR press+release when the server strips mouse DECSETs (claude mode)', () => { + const { app } = loadTerminalUiHarness(); + const sent: Array<{ id: string; data: string }> = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app._sendInputAsync = (id: string, data: string) => sent.push({ id, data }); + app.terminal = { + cols: 80, + rows: 24, + element: { + querySelector: () => ({ getBoundingClientRect: () => ({ left: 10, top: 20 }) }), + }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + + expect(app._sessionUsesServerMouseStrip()).toBe(true); + // touch at x=10+8*20+1, y=20+16*5+1 → col 21, row 6 (1-based) + app._sendSyntheticSgrTap(171, 101); + + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<0;21;6M\x1b[<0;21;6m' }]); + }); + + it('clamps SGR tap coordinates to the terminal grid', () => { + const { app } = loadTerminalUiHarness(); + const sent: string[] = []; + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'claude' }]]); + app._sendInputAsync = (_id: string, data: string) => sent.push(data); + app.terminal = { + cols: 80, + rows: 24, + element: { + querySelector: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }), + }, + _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }, + }; + + app._sendSyntheticSgrTap(-50, 99999); + + expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']); + }); + + it('does not treat shell sessions as server-mouse-strip mode', () => { + const { app } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + app.sessions = new Map([['sess-1', { mode: 'shell' }]]); + + expect(app._sessionUsesServerMouseStrip()).toBe(false); + }); + it('allows trusted mouse events after the tap window expires', () => { const { app, setNow } = loadTerminalUiHarness(); const { element, dispatch } = createElementHarness();