diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 83c2282d..76049505 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -650,6 +650,7 @@ Object.assign(CodemanApp.prototype, { let didScroll = false; // track whether touchmove fired (tap vs scroll) let touchStartY = 0; + let tapCanActivateTerminal = false; const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll container.addEventListener( 'touchstart', @@ -662,6 +663,7 @@ Object.assign(CodemanApp.prototype, { pixelAccum = 0; isTouching = true; didScroll = false; + tapCanActivateTerminal = this._shouldForwardMobileTapToApp(); lastTime = 0; if (scrollFrame) { cancelAnimationFrame(scrollFrame); @@ -737,9 +739,13 @@ Object.assign(CodemanApp.prototype, { if (touch) { this._suppressTrustedTapMouseEvents(); } - if (touch && mouseTrackingOn) { + if (touch && tapCanActivateTerminal && mouseTrackingOn) { this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY); - } else if (touch && this._sessionUsesServerMouseStrip()) { + } else if ( + touch && + tapCanActivateTerminal && + 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 @@ -759,6 +765,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.focus(); } } + tapCanActivateTerminal = false; }, { passive: true } ); @@ -3363,6 +3370,26 @@ Object.assign(CodemanApp.prototype, { } catch {} }, + /** + * A tap that opens the phone keyboard is focus-only. Forwarding that same + * gesture as a mouse click would activate the highlighted CLI menu option. + * Once the keyboard and terminal input were already active at touchstart, + * later taps may intentionally position the cursor or select a TUI row. + */ + _shouldForwardMobileTapToApp() { + const keyboardVisible = + (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) || + document.body?.classList?.contains('keyboard-visible'); + if (!keyboardVisible) return false; + + const active = document.activeElement; + return ( + active === this.terminal?.textarea || + active?.classList?.contains('xterm-helper-textarea') || + active?.id === 'cjkInput' + ); + }, + // ═══════════════════════════════════════════════════════════════ // Synthetic tap → mouse report // ═══════════════════════════════════════════════════════════════ diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index 38dc5d22..ca6c1ba7 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -756,12 +756,16 @@ describe('Virtual Keyboard', () => { it('focuses the terminal helper textarea when the terminal is tapped', async () => { await page.evaluate(() => { + window.__sentInputs = []; app.activeSessionId = 'mobile-focus-visible-input-test'; app.sessions.set('mobile-focus-visible-input-test', { id: 'mobile-focus-visible-input-test', mode: 'codex', status: 'running', }); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; app.hideWelcome(); const settings = app.loadAppSettingsFromStorage(); settings.cjkInputEnabled = false; @@ -771,8 +775,12 @@ describe('Virtual Keyboard', () => { await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } }); - const activeClass = await page.evaluate(() => document.activeElement?.className); - expect(activeClass).toContain('xterm-helper-textarea'); + const state = await page.evaluate(() => ({ + activeClass: document.activeElement?.className, + sentInputs: window.__sentInputs, + })); + expect(state.activeClass).toContain('xterm-helper-textarea'); + expect(state.sentInputs).toEqual([]); }); // Regression guard for the phone-keyboard blocker reduced in #173 and re-hit diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 378ba1a7..528838d4 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -6,8 +6,16 @@ import { describe, expect, it, vi } from 'vitest'; function loadTerminalUiHarness() { const CodemanApp = function CodemanApp(this: any) {}; let now = 1_000; + let keyboardVisible = false; + let activeElement: unknown = null; const context = vm.createContext({ window: {}, + document: { + body: { classList: { contains: () => false } }, + get activeElement() { + return activeElement; + }, + }, CodemanApp, console: { warn: vi.fn(), log: vi.fn() }, _crashDiag: { log: vi.fn() }, @@ -25,6 +33,11 @@ function loadTerminalUiHarness() { MobileDetection: { isTouchDevice: () => true, }, + KeyboardHandler: { + get keyboardVisible() { + return keyboardVisible; + }, + }, DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, TERMINAL_CHUNK_SIZE: 32 * 1024, }); @@ -38,6 +51,12 @@ function loadTerminalUiHarness() { setNow: (value: number) => { now = value; }, + setKeyboardVisible: (visible: boolean) => { + keyboardVisible = visible; + }, + setActiveElement: (element: unknown) => { + activeElement = element; + }, }; } @@ -56,6 +75,19 @@ function createElementHarness() { } describe('terminal touch tap mouse guard', () => { + it('keeps the keyboard-opening tap focus-only', () => { + const { app, setActiveElement, setKeyboardVisible } = loadTerminalUiHarness(); + const textarea = { classList: { contains: () => true } }; + app.terminal = { textarea }; + setActiveElement(textarea); + + setKeyboardVisible(false); + expect(app._shouldForwardMobileTapToApp()).toBe(false); + + setKeyboardVisible(true); + expect(app._shouldForwardMobileTapToApp()).toBe(true); + }); + it('suppresses browser trusted compatibility mouse events during the tap window', () => { const { app } = loadTerminalUiHarness(); const { element, dispatch } = createElementHarness();