fix(mobile): prevent terminal tap selection

This commit is contained in:
Teigen
2026-06-17 23:40:21 +08:00
parent b1c4330680
commit b374121c18
3 changed files with 224 additions and 8 deletions
+17
View File
@@ -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;
+91 -8
View File
@@ -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));
+116
View File
@@ -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<string, (ev: any) => 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();
});
});