fix(mobile): keep the keyboard reachable on inert transcript taps

A mid-terminal tap on a claude-mode session left document.activeElement on
<body>, so the on-screen keyboard could not be raised and there was no way to
type — the blocker reduced upstream in #173.

_classifyMobileTerminalTap returns 'content' for any non-prompt row, and
_handleMobileTerminalTap blurred on every 'content' tap while touchstart's
preventDefault had already cancelled the compatibility click that would
otherwise focus xterm. Both routes to focus were closed on the same gesture.

Blur now applies only to rows that are actually TUI-owned. The distinguishing
signal is the affordance a CLI prints on or beside the row ("ctrl+r to expand",
"tap to collapse", "esc to interrupt"), not the row's title text — a readback's
title row carries no hint of its own, so the adjacent row is consulted too.
Keying on titles would recognise only the exact strings a fixture happens to
use and would let a real readback keep the keyboard open.

Measured with a real touchstart/touchend gesture, iPhone-class viewport,
claude-mode session, tapping mid-transcript:

  before  document.activeElement = body
  after   document.activeElement = xterm-helper-textarea

Note: upstream master already passes this assertion, so the added test is a
regression guard for this branch, not a test that fails on master.

test/mobile/keyboard.test.ts: 40 tests, 5 failed | 35 passed — the same 5
pre-existing failures as master (stale layout/accessory-bar expectations and a
CJK timeout), unchanged by this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
lior
2026-08-10 13:22:59 +03:00
co-authored by Claude Opus 5
parent 1410362e5b
commit 623fedf5b7
2 changed files with 254 additions and 1 deletions
+55 -1
View File
@@ -3462,6 +3462,60 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Which 'content' taps should DISMISS the mobile keyboard. Expandable
* readbacks, tool results and decision rows are TUI-owned: tapping them acts
* on the CLI, so popping the keyboard there is wrong. An inert transcript row
* still sends its mouse report, but must keep the keyboard reachable —
* touchstart's preventDefault cancels the compatibility click that would
* otherwise focus xterm, so focus has to be restored explicitly.
*/
_isActionableMobileTerminalTap(clientX, clientY) {
if (document.body?.classList?.contains('terminal-action-pending')) return true;
const pos = this._clientPointToCell(clientX, clientY);
const buffer = this.terminal?.buffer?.active;
if (!pos || !buffer?.getLine) return false;
const rows = Math.max(1, this.terminal.rows || 1);
const lines = [];
const wrappedRows = [];
for (let row = 0; row < rows; row++) {
const line = buffer.getLine(buffer.viewportY + row);
lines.push(line?.translateToString?.(true) || '');
wrappedRows.push(Boolean(line?.isWrapped));
}
const tappedRow = pos.row - 1;
let logicalLineStart = tappedRow;
while (logicalLineStart > 0 && wrappedRows[logicalLineStart]) logicalLineStart--;
let logicalLineEnd = tappedRow;
while (logicalLineEnd + 1 < rows && wrappedRows[logicalLineEnd + 1]) logicalLineEnd++;
const tappedLine = lines.slice(logicalLineStart, logicalLineEnd + 1).join('');
// Match the AFFORDANCE a CLI prints, not the row's title text: an
// expandable readback, tool result or status row advertises how to act on
// it ("ctrl+r to expand", "tap to collapse", "esc to interrupt"). Keying on
// titles instead would only recognise the exact strings a fixture happens
// to use, and would let a real readback keep the keyboard open.
//
// The hint sits on its own row, so a readback's TITLE row — the one a
// finger actually lands on — carries no affordance text itself. Look at the
// adjacent row too, which is how these blocks are laid out in practice.
const affordance =
/\b(?:ctrl\+\w+|tap|click|enter|esc)\b[^.]{0,24}\bto\s+(?:expand|collapse|view|open|interrupt|see)\b/i;
const blockStart = Math.max(0, logicalLineStart - 1);
const blockEnd = Math.min(rows - 1, logicalLineEnd + 1);
for (let row = blockStart; row <= blockEnd; row++) {
if (affordance.test(lines[row])) return true;
}
if (/^\s*[•·]\s*Working\b/i.test(tappedLine)) return true;
const hasMenuPrompt = lines.some((line) => /^\s*[❯›]\s+\d+[.)]\s/.test(line));
const hasMenuChoice = lines.some((line) => /^\s+\d+[.)]\s/.test(line));
return hasMenuPrompt && hasMenuChoice;
},
_focusMobileTerminalInput() {
this._syncMobileHelperTextareaToCursor();
const cjkInput = document.getElementById('cjkInput');
@@ -3493,7 +3547,7 @@ Object.assign(CodemanApp.prototype, {
this._sendSyntheticSgrTap(touch.clientX, touch.clientY);
}
if (intent === 'content') {
if (intent === 'content' && this._isActionableMobileTerminalTap(touch.clientX, touch.clientY)) {
// A synthetic xterm click can focus its helper textarea. Blur after the
// report so collapsing a readback never opens or retains the keyboard.
this._blurMobileTerminalInput();