diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 18091558..c9754e42 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2246,6 +2246,23 @@ body.solo-mode .btn-lifecycle-log { touch-action: none; } +/* Mobile terminal taps are cursor-positioning gestures, not text-selection gestures. + xterm re-enables selectable DOM text in its accessibility tree, so force the + whole terminal subtree back to non-selectable on touch devices. Synthetic mouse + events for tap-to-position still dispatch normally through JavaScript. */ +body.touch-device .terminal-container .xterm, +body.touch-device .terminal-container .xterm .xterm-viewport, +body.touch-device .terminal-container .xterm .xterm-screen, +body.touch-device .terminal-container .xterm .xterm-rows, +body.touch-device .terminal-container .xterm .xterm-accessibility-tree, +body.touch-device .terminal-container .xterm .xterm-accessibility-tree *, +body.touch-device .terminal-container .xterm .xterm-selection, +body.touch-device .terminal-container .xterm .xterm-helper-textarea { + user-select: none !important; + -webkit-user-select: none !important; + -webkit-touch-callout: none !important; +} + /* Welcome Overlay */ .welcome-overlay { position: absolute; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 64a8a5a5..14226ce6 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -19,6 +19,10 @@ // suppressed, so high-frequency Codex status redraws don't snap the viewport // back to the bottom while the user is inspecting earlier output. const USER_SCROLL_STICKY_SUPPRESS_MS = 1500; + // Mobile browsers synthesize trusted mouse events after touchend. During this + // short window, only the app's synthetic tap-to-position mouse event should + // reach xterm. + const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450; function isTerminalQueryResponse(data) { return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data); @@ -46,6 +50,7 @@ isTerminalQueryResponse, shouldSuppressTerminalQueryResponse, USER_SCROLL_STICKY_SUPPRESS_MS, + TOUCH_COMPAT_MOUSE_SUPPRESS_MS, }; global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES; global.codemanCurrentXtermTheme = currentXtermTheme; @@ -107,6 +112,7 @@ Object.assign(CodemanApp.prototype, { const container = document.getElementById('terminalContainer'); this.terminal.open(container); + this._installMobileTapMouseGuard(); // Suppress xterm key handling during CJK IME composition. // Without this, xterm processes raw keyDown events (e.g., "Process" key) @@ -425,16 +431,32 @@ Object.assign(CodemanApp.prototype, { scrollFrame = requestAnimationFrame(scrollLoop); } if (!didScroll && this.terminal) { + // ── Tap-to-position cursor ────────────────────────────────── + // Synthesize a click from the real touch point so the foreground app + // moves its cursor to the tapped cell (iOS doesn't reliably do this + // itself under touch-action:none). CRITICAL: only when mouse tracking + // is ON. xterm disables its local SelectionService while mouse events + // are active, so the synthetic click is forwarded to the PTY as an SGR + // report (cursor moves). But when tracking is OFF, that same click + // drives xterm's LOCAL selection (detail 1/2/3 → char/word/line) — a + // tap on CJK text would select & copy it instead of positioning. So + // gate strictly on the live mouse-tracking mode. + const touch = ev.changedTouches && ev.changedTouches[0]; + const mouseMode = this.terminal.modes?.mouseTrackingMode; + const mouseTrackingOn = !!mouseMode && mouseMode !== 'none'; + if (touch) { + this._suppressTrustedTapMouseEvents(); + } + if (touch && mouseTrackingOn) { + this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY); + } + this._syncMobileHelperTextareaToCursor(); + // Route subsequent typing to the right place: keep the CJK input + // field focused when Chinese input is on, otherwise the terminal. const cjkInput = document.getElementById('cjkInput'); if (cjkInput?.classList.contains('cjk-input-visible')) { cjkInput.focus(); } else { - // Don't synthesize mousedown — the browser does it naturally - // because we don't preventDefault() for taps (< TAP_THRESHOLD). - // touch-action:none blocks native scroll but NOT click synthesis. - // The browser's mousedown carries detail:1 + correct coordinates, - // so xterm's SelectionService._handleSingleClick picks it up. - this._syncMobileHelperTextareaToCursor(); this.terminal.focus(); } } @@ -596,8 +618,13 @@ Object.assign(CodemanApp.prototype, { // survives tab switches and reconnects. this.terminal.onData((data) => { - // CJK input has focus — block xterm from sending to PTY - if (window.cjkActive || document.activeElement?.id === 'cjkInput') return; + // Mouse SGR reports (tap-to-position) are NOT IME input — they must reach + // the PTY even while the CJK input field owns focus. Without this exception + // tapping to move the cursor silently does nothing whenever Chinese input + // is on, because cjkActive stays true the whole time the field is visible. + const isMouseReport = /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data); + // CJK input has focus — block xterm from sending keystrokes to PTY + if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) return; if (this.activeSessionId) { // Filter terminal query replies generated by xterm.js itself. // Forwarding them through the WebSocket injects DA/DSR/CPR replies @@ -2005,6 +2032,62 @@ Object.assign(CodemanApp.prototype, { } catch {} }, + // ═══════════════════════════════════════════════════════════════ + // Synthetic tap → mouse report + // ═══════════════════════════════════════════════════════════════ + // Dispatch a mousedown+mouseup pair at viewport coords (clientX/clientY) to + // xterm's root element. xterm's mouse-reporting handler reads the event's + // client coords, maps them to a terminal cell relative to .xterm-screen, and + // — when the foreground app has mouse tracking active (DECSET 1000/1002/1006, + // which Claude's input enables) — encodes an SGR mouse report to the PTY. + // That is the same path a real desktop click takes; on touch devices the + // browser's own compatibility-event synthesis is unreliable (and suppressed + // by touch-action:none), so we drive it explicitly. With mouse tracking off + // it degrades to a harmless zero-length click (no drag → no text selection). + _dispatchSyntheticTerminalClick(clientX, clientY) { + const el = this.terminal?.element; + if (!el || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return; + // xterm registers its mouseup listener on document during mousedown, so a + // bubbling mouseup reaches it; dispatch both to the root element in order. + const base = { + bubbles: true, + cancelable: true, + view: window, + clientX, + clientY, + screenX: clientX, + screenY: clientY, + button: 0, + detail: 1, + }; + try { + el.dispatchEvent(new MouseEvent('mousedown', { ...base, buttons: 1 })); + el.dispatchEvent(new MouseEvent('mouseup', { ...base, buttons: 0 })); + } catch { + /* MouseEvent constructor unavailable — tap-to-position simply no-ops */ + } + }, + + _installMobileTapMouseGuard() { + const el = this.terminal?.element; + if (!el || el._codemanTapMouseGuardInstalled) return; + if (typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice && !MobileDetection.isTouchDevice()) return; + el._codemanTapMouseGuardInstalled = true; + const suppressTrustedCompatMouse = (ev) => { + const suppressUntil = this._trustedTapMouseSuppressUntil || 0; + if (!ev.isTrusted || performance.now() > suppressUntil) return; + ev.preventDefault(); + ev.stopImmediatePropagation(); + }; + el.addEventListener('mousedown', suppressTrustedCompatMouse, true); + el.addEventListener('mouseup', suppressTrustedCompatMouse, true); + }, + + _suppressTrustedTapMouseEvents() { + const ms = window.CodemanTerminalInput?.TOUCH_COMPAT_MOUSE_SUPPRESS_MS || 450; + this._trustedTapMouseSuppressUntil = performance.now() + ms; + }, + increaseFontSize() { const current = this.terminal.options.fontSize || 14; this.setFontSize(Math.min(current + 2, 24)); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts new file mode 100644 index 00000000..04121595 --- /dev/null +++ b/test/terminal-touch-tap.test.ts @@ -0,0 +1,116 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +function loadTerminalUiHarness() { + const CodemanApp = function CodemanApp(this: any) {}; + let now = 1_000; + const context = vm.createContext({ + window: {}, + CodemanApp, + console: { warn: vi.fn(), log: vi.fn() }, + _crashDiag: { log: vi.fn() }, + performance: { now: () => now }, + requestAnimationFrame: (_fn: () => void) => 1, + setTimeout: (_fn: () => void) => 1, + Blob: function Blob() {}, + URL: { + createObjectURL: () => 'blob:yield', + revokeObjectURL: () => {}, + }, + Worker: function Worker(this: any) { + this.postMessage = () => {}; + }, + MobileDetection: { + isTouchDevice: () => true, + }, + DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, + TERMINAL_CHUNK_SIZE: 32 * 1024, + }); + + const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + vm.runInContext(code, context, { filename: 'terminal-ui.js' }); + + const app = new (CodemanApp as any)(); + return { + app, + setNow: (value: number) => { + now = value; + }, + }; +} + +function createElementHarness() { + const listeners = new Map void>(); + return { + element: { + addEventListener: vi.fn((type: string, listener: (ev: any) => void) => { + listeners.set(type, listener); + }), + }, + dispatch(type: string, event: any) { + listeners.get(type)?.(event); + }, + }; +} + +describe('terminal touch tap mouse guard', () => { + it('suppresses browser trusted compatibility mouse events during the tap window', () => { + const { app } = loadTerminalUiHarness(); + const { element, dispatch } = createElementHarness(); + app.terminal = { element }; + + app._installMobileTapMouseGuard(); + app._suppressTrustedTapMouseEvents(); + + const event = { + isTrusted: true, + preventDefault: vi.fn(), + stopImmediatePropagation: vi.fn(), + }; + dispatch('mousedown', event); + + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(event.stopImmediatePropagation).toHaveBeenCalledOnce(); + }); + + it('allows the app synthetic mouse event through the tap window', () => { + const { app } = loadTerminalUiHarness(); + const { element, dispatch } = createElementHarness(); + app.terminal = { element }; + + app._installMobileTapMouseGuard(); + app._suppressTrustedTapMouseEvents(); + + const event = { + isTrusted: false, + preventDefault: vi.fn(), + stopImmediatePropagation: vi.fn(), + }; + dispatch('mousedown', event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.stopImmediatePropagation).not.toHaveBeenCalled(); + }); + + it('allows trusted mouse events after the tap window expires', () => { + const { app, setNow } = loadTerminalUiHarness(); + const { element, dispatch } = createElementHarness(); + app.terminal = { element }; + + app._installMobileTapMouseGuard(); + app._suppressTrustedTapMouseEvents(); + setNow(2_000); + + const event = { + isTrusted: true, + preventDefault: vi.fn(), + stopImmediatePropagation: vi.fn(), + }; + dispatch('mousedown', event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.stopImmediatePropagation).not.toHaveBeenCalled(); + }); +});