diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index e3412fb8..ba97630b 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -32,6 +32,22 @@ // short window, only the app's synthetic tap-to-position mouse event should // reach xterm. const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450; + // Regions where a tap must NOT dismiss the on-screen keyboard + // (_installMobileKeyboardDismiss). Two groups: anything that is about to take + // focus itself, and the accessory bar, which is built to be used while the + // keyboard is open. + const MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR = [ + 'input', + 'textarea', + 'select', + 'button', + 'a[href]', + '[contenteditable=""]', + '[contenteditable="true"]', + '[tabindex]:not([tabindex="-1"])', + '.keyboard-accessory-bar', + '.path-picker-overlay', + ].join(','); // Escape sequences occupy no terminal cells, so they must come out before a // captured line's WIDTH can be measured (_estimateReplayRows). Covers OSC, // CSI, charset designators and the short escapes tmux emits; deliberately @@ -182,6 +198,7 @@ PAGE_KEY_SCREEN_FRACTION, PAGE_KEY_MAX_PER_BATCH, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, + MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, }; global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES; global.codemanCurrentXtermTheme = currentXtermTheme; @@ -786,6 +803,8 @@ Object.assign(CodemanApp.prototype, { // Hand-encode the SGR report for plain left-clicks on those sessions. container.addEventListener('click', (ev) => this._handleDesktopTerminalClick(ev)); + this._installMobileKeyboardDismiss(); + // Welcome message this.showWelcome(); @@ -3491,6 +3510,43 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Tapping outside the terminal closes the on-screen keyboard. + * + * The terminal keeps focus on a hidden textarea, and nothing ever released it: + * once the keyboard was up, every tap on the header, the tab strip or empty + * page chrome left it up, covering half a phone screen with no way to dismiss + * it but the OS back gesture. + * + * Deliberately narrow, because focus is not ours to steal: + * + * - only when the terminal input actually holds focus; + * - never for a tap inside the terminal — those are classified and routed by + * `_handleMobileTerminalTap`, which owns that decision; + * - never for a tap on another control. Anything focusable or clickable is + * about to take focus itself, and the accessory bar in particular exists to + * be used WHILE the keyboard is open, so dismissing there would fight the + * user. `closest()` covers taps landing on a child (an icon inside a button). + * + * Bound to `touchend` rather than `click`: a tap that dismisses the keyboard + * usually is not meant to activate whatever is underneath, and touchend fires + * before the synthesized click, so the blur lands first. + */ + _installMobileKeyboardDismiss() { + if (this._mobileKeyboardDismissHandler) return; + this._mobileKeyboardDismissHandler = (ev) => { + if (!this._isMobileTerminalInputFocused()) return; + const target = ev.target; + if (!target || typeof target.closest !== 'function') return; + if (target.closest('#terminalContainer')) return; + if (target.closest(window.CodemanTerminalInput.MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR)) return; + this._blurMobileTerminalInput(); + }; + // Passive: this never calls preventDefault, so it must not make the page + // feel less responsive to scrolling. + document.addEventListener('touchend', this._mobileKeyboardDismissHandler, { passive: true }); + }, + /** * Which 'content' taps should DISMISS the mobile keyboard. Expandable * readbacks, tool results and decision rows are TUI-owned: tapping them acts diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index df0c4cd1..3b8fedcf 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -624,6 +624,69 @@ describe('Virtual Keyboard', () => { expect(Number(styles?.zIndex)).toBeGreaterThanOrEqual(0); }); + it('dismisses the on-screen keyboard when a tap lands outside the terminal', async () => { + // The terminal holds focus on a hidden textarea and nothing released it, + // so once the keyboard was up every tap on the header or page chrome left + // it up — covering half a phone screen with no in-app way to close it. + // + // Driven as a real dispatched gesture: the handler is bound to touchend on + // document, and calling the internal helper would bypass the routing this + // test exists to check. + const result = await page.evaluate(async () => { + const tap = async (el: Element) => { + const rect = el.getBoundingClientRect(); + const x = Math.max(2, rect.left + Math.min(6, rect.width / 2)); + const y = Math.max(2, rect.top + Math.min(6, rect.height / 2)); + const target = document.elementFromPoint(x, y) || el; + const touch = new Touch({ identifier: 21, target, clientX: x, clientY: y }); + target.dispatchEvent( + new TouchEvent('touchstart', { + touches: [touch], + targetTouches: [touch], + changedTouches: [touch], + bubbles: true, + cancelable: true, + }) + ); + await new Promise((resolve) => setTimeout(resolve, 25)); + target.dispatchEvent( + new TouchEvent('touchend', { touches: [], changedTouches: [touch], bubbles: true, cancelable: true }) + ); + await new Promise((resolve) => setTimeout(resolve, 250)); + return document.activeElement?.className ?? ''; + }; + + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('transcript\r\n\r\n> ', resolve)); + + // Inert page chrome: the keyboard must close. + app._focusMobileTerminalInput(); + const focusedBefore = document.activeElement?.className ?? ''; + const afterOutside = await tap(document.querySelector('.logo, .header-brand, header') ?? document.body); + + // A real control: it takes focus itself, so we must NOT interfere. + app._focusMobileTerminalInput(); + const button = Array.from(document.querySelectorAll('button:not([disabled])')).find((candidate) => { + const rect = candidate.getBoundingClientRect(); + return rect.width > 8 && rect.height > 8; + }); + const afterButton = button ? await tap(button) : 'no-visible-button'; + + // Inside the terminal, tap classification owns the decision. + app._focusMobileTerminalInput(); + const afterTerminal = await tap(document.querySelector('#terminalContainer')!); + + return { focusedBefore, afterOutside, afterButton, afterTerminal }; + }); + + expect(result.focusedBefore).toContain('xterm-helper-textarea'); + // Red on master: the textarea keeps focus and the keyboard stays up. + expect(result.afterOutside).not.toContain('xterm-helper-textarea'); + expect(result.afterButton).toContain('xterm-helper-textarea'); + expect(result.afterTerminal).toContain('xterm-helper-textarea'); + }); + it('routes CJK textarea typing through local echo on Enter', async () => { await page.evaluate(() => { window.__sentInputs = [];