fix(mobile): close the on-screen keyboard when tapping outside the terminal

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 <noreply@anthropic.com>
This commit is contained in:
lior
2026-08-10 17:37:27 +03:00
co-authored by Claude Opus 5
parent 26416f98de
commit a6a572e635
2 changed files with 119 additions and 0 deletions
+56
View File
@@ -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
+63
View File
@@ -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<void>((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 = [];