From a6a572e635395a14f97850b580258012069c2b20 Mon Sep 17 00:00:00 2001 From: lior Date: Mon, 10 Aug 2026 17:37:27 +0300 Subject: [PATCH] fix(mobile): close the on-screen keyboard when tapping outside the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a phone the terminal holds focus on a hidden textarea, and nothing ever released it. Once the keyboard was up, tapping the header, the tab strip or any empty page chrome left it up — covering roughly half the screen with no in-app way to dismiss it. Repro, iPhone-class viewport (390x844), claude-mode session, focus the terminal then tap the header logo: | | document.activeElement after the tap | | --- | --- | | master | textarea.xterm-helper-textarea (keyboard stays up) | | this branch | body (keyboard closes) | A document-level touchend handler blurs the terminal input, deliberately scoped so focus is never stolen from something that wants it: - only when the terminal input actually holds focus; - never inside #terminalContainer — _handleMobileTerminalTap already classifies and routes those taps and owns that decision; - never on a control. Anything focusable or clickable is about to take focus itself, and the keyboard accessory bar exists to be used WHILE the keyboard is open, so dismissing there would fight the user. Bound to touchend rather than click: a tap meant to dismiss usually is not meant to activate what sits underneath, and touchend fires before the synthesized click so the blur lands first. The listener is passive — it never calls preventDefault. Test: `dismisses the on-screen keyboard when a tap lands outside the terminal` in test/mobile/keyboard.test.ts. It fails on master with a BEHAVIOURAL assertion (`expected 'xterm-helper-textarea' not to contain 'xterm-helper-textarea'`), not a TypeError, and passes here. It drives real dispatched touch events rather than calling the helper, because the handler is bound on document and a direct call would bypass the routing under test. test/mobile/keyboard.test.ts: 52 tests, 5 failed | 47 passed. Master is 51 tests, 5 failed | 46 passed — the same five pre-existing failures (stale layout and accessory-bar expectations, a CJK timeout), untouched here. Full suite: 4944 passed | 12 skipped, 0 failed. Co-Authored-By: Claude Opus 5 --- src/web/public/terminal-ui.js | 56 +++++++++++++++++++++++++++++++ test/mobile/keyboard.test.ts | 63 +++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) 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 = [];