fix(mobile): route terminal taps without breaking keyboard focus - #244
Conversation
Ark0N
left a comment
There was a problem hiding this comment.
Thanks for the rebase and for the write-up, particularly the note that the focus test is not red on master and the methodological point that only a dispatched touch gesture reproduces the bug. Both are correct and worth keeping in the repo.
I fetched the branch into an isolated worktree, ran the checks, and probed the behaviour with real touch gestures against a live server. Requesting changes: the blocker is still reachable, through a second path this PR does not cover.
1. Blocking: the keyboard is still unreachable after a tab switch
selectSession() ends with scrollToLastNonEmptyLine() (app.js:4670, also :4442 on snapshot restore). That parks the viewport above the bottom whenever the buffer is taller than the screen and ends in blank rows, which is every real session. _classifyMobileTerminalTap() opens with:
if(!this._terminalViewportAtBottom())return'history';so every tap classifies as history, touchstart runs preventDefault() + _blurMobileTerminalInput(), and touchend's early return skips _focusMobileTerminalInput(). Both routes to focus close on the same gesture: the exact mechanism the last commit fixed for inert rows.
Measured, iPhone-class viewport (390x844), claude-mode session, buffer taller than the screen, scrollToLastNonEmptyLine() then a real page.touchscreen.tap:
| viewport state | document.activeElement after the tap | |
|---|---|---|
master b1614e8 | viewportY 82 / baseY 83 (off-bottom) | textarea.xterm-helper-textarea |
| this branch | viewportY 82 / baseY 83 (off-bottom) | body |
Same result when the tap lands on the prompt row itself. Master is unaffected because its touchend calls this.terminal.focus() unconditionally; only _sendSyntheticSgrTap was gated on at-bottom.
Suppressing the mouse report while scrolled up is right, and an improvement over master. Blurring is not, and there is no reason to preventDefault() at touchstart for a gesture that will send nothing.
Repro, as a test/mobile case:
// setup: claude-mode session, write ~120 transcript rows + a prompt box + 2 trailing blanksawaitpage.evaluate(()=>{app.scrollToLastNonEmptyLine();// what selectSession() does on every tab switch(document.activeElementasHTMLElement|null)?.blur?.();});awaitpage.touchscreen.tap(x,y);// anywhere in the terminal, prompt row includedconstactiveClass=awaitpage.evaluate(()=>document.activeElement?.className);expect(activeClass).toContain('xterm-helper-textarea');// green on master, red here2. terminal-action-pending does not exist
Both new functions guard on it:
if(document.body?.classList?.contains('terminal-action-pending'))return'content';// :3117if(document.body?.classList?.contains('terminal-action-pending'))returntrue;// :3216grep -rn terminal-action-pending src test docs returns nothing on master or on this branch. Both branches are permanently false, so "Permission/elicitation prompts own the full live terminal until answered" never engages. Either wire the class up where prompts are detected, or drop the guards so the comment does not promise coverage that is not there.
3. The claude status literal does not match claude
/^\s*[•·]\s*Working\b/ in _isActionableMobileTerminalTap, and /^\s*[•·]\s*Working\b.*(?:background|esc to interrupt)/i in _classifyMobileTerminalTap, are fixture-shaped. Live panes here on claude 2.1.226 print:
✻ Cooked for 2m 6s
✻ Baked for 9m 47s
✻ Brewed for 18m 41s
Different bullet (✻, U+273B, not •/·) and a randomised verb that is never "Working". This is the same trap your PR body documents about title strings, one layer down. The affordance regex does catch the actively-working row via "esc to interrupt", so behaviour largely survives, but both Working clauses are dead code.
4. _shouldForwardTouchScrollToApp() is never called
Defined at :3488 and unit-tested, but unreachable: touchmove still calls _shouldForwardWheelToApp({ shiftKey: false }) directly, exactly as on master. "Drag gestures still route through _shouldForwardTouchScrollToApp()" in the PR body is not accurate, and wiring it as written would restrict forwarding to claude only, dropping gemini from the path #205 established. Please either wire it deliberately (with that mode change justified) or drop it and its test.
Smaller items
- Undisclosed behaviour change.
shouldActivate = intent === 'content' || startedWithTerminalFocusmeans the first tap on the prompt row no longer positions the cursor; it takes two taps now. That looks deliberate (the "keeps the first prompt tap focus-only" test pins it), but it should be in the PR description, since it changes an interaction people already rely on. - Cost per gesture.
_classifyMobileTerminalTapdoes a full-viewporttranslateToStringscan. It now runs at touchstart (which also became non-passive), again inside_handleMobileTerminalTap, and_isActionableMobileTerminalTapdoes a third. Every scroll drag, not just every tap, pays a screen scan at gesture start. The touchstart result is already computed and then discarded; cache it for the touchend pair. - Duplicate line.
touchLastX = ev.touches[0].clientX;now appears twice in the touchmove handler. - Affordance false positives.
/\b(?:ctrl\+\w+|tap|click|enter|esc)\b[^.]{0,24}\bto\s+(?:expand|collapse|view|open|interrupt|see)\b/imatches ordinary transcript prose such as "click here to open the file", which would dismiss the keyboard on an inert row. _handleMobileTerminalTapreturns'history'for the "no touch or no terminal" bail-out, conflating a real classification with a guard.
What checks out
Everything you claimed verifies:
npx tsc --noEmit clean
npm run lint clean
npm run format:check clean
npm run check:frontend-syntax 28 files parse cleanly
test/terminal-touch-tap.test.ts 27 passed (27)
test/mobile/keyboard.test.ts branch 5 failed | 35 passed (40)
test/mobile/keyboard.test.ts master b1614e8 5 failed | 30 passed (35)
Identical failing test names on both sides, so the 5 are pre-existing and untouched here, as you said.
The direction is right, and separating "TUI owns this row" from "the user wants to type" is the correct framing. Item 1 is the one that has to be fixed before this can land; 2 through 4 are code that does not do what it says.
selectSession() ends with scrollToLastNonEmptyLine(), which parks the viewport one row ABOVE the bottom for any session whose buffer is taller than the screen and ends in blank rows, so that is the normal state after a tab switch. Nothing pinned that a tap there still leaves the keyboard reachable. The blocker reduced in #173 came back through exactly that gap in #244: a tap classifier that treats "viewport is scrolled up" as a reason to blur, paired with touchstart preventDefault cancelling the compatibility click, closes both routes to focus on the same gesture and strands document.activeElement on <body> with no way to type. The prompt row is no exception. Measured on a 390x844 viewport, claude-mode session, dispatched touch gesture: master leaves focus on textarea.xterm-helper-textarea, PR #244's terminal-ui.js leaves it on body. Green here, red against that branch. The test also pins the half that IS correct: SGR coordinates are meaningless off-bottom, so the tap must send no mouse report. It has to be a dispatched gesture. Calling the touchend handler directly bypasses touchstart's preventDefault, which is half of what closes the focus path, so a direct call reports the right intent and still misses the bug. test/mobile/keyboard.test.ts: 4 failed | 32 passed (36), against 4 failed | 31 passed (35) without it. Same four pre-existing failures either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 Ark0N#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>
…d up Addresses the review on Ark0N#244. BLOCKING (item 1). selectSession() ends with scrollToLastNonEmptyLine(), which parks the viewport above the bottom for any session taller than the screen, so after a tab switch every tap classified as 'history' — touchstart ran preventDefault() + blur, and touchend's early return skipped focus. Both routes to focus closed on one gesture, the same mechanism as Ark0N#173. Suppressing the mouse REPORT while scrolled up is right and is kept; suppressing FOCUS is not. touchstart now only preventDefaults 'content' taps (a scrolled-up viewport sends nothing, so there is no compatibility click worth cancelling), and the 'history' branch focuses instead of blurring. Verified against the maintainer's own test, which was already on master and red: `keeps the terminal input focusable after a tab switch parks the viewport off-bottom` fails without this change and passes with it. Item 2: dropped both `terminal-action-pending` guards. The class exists nowhere in the repo, so both branches were permanently false and the comment promised coverage that did not exist. Item 3: removed the `Working` literals. Live claude 2.1.226 prints "Cooked for 2m 6s" with a different bullet and a randomised verb, so they were dead code. The status row is matched by its affordance ("esc to interrupt") instead, which is what makes it actionable. The affordance regex is also tightened to require a key or gesture name, so prose like "click here to open the file" no longer dismisses the keyboard. Item 4: removed _shouldForwardTouchScrollToApp and its test. It was never called, and wiring it as written would have restricted forwarding to claude only, dropping gemini from the path Ark0N#205 established — a behaviour change this PR has no reason to make. Smaller items: the touchstart classification is cached and reused for the touchend of the same gesture (keyed on exact coordinates, so a moved finger re-classifies), removing two of the three full-viewport scans per gesture; the duplicated touchLastX assignment is gone; and the no-touch bail-out returns null rather than claiming 'history'. test/mobile/keyboard.test.ts: 51 tests, 5 failed | 46 passed — the same 5 pre-existing failures as master, unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
19e2ef5 to
3b85001CompareLint111
commented
Aug 10, 2026
Thank you for the review, and for writing the off-bottom test and landing it on master. Working against a red test you wrote made item 1 unambiguous. Rebased on Item 1 (blocking) — fixedYou were right, and my earlier probing missed it because I only ever tested at-bottom taps. Two changes, matching your framing that suppressing the mouse report while scrolled up is correct but suppressing focus is not:
Verified against your test rather than my own: Items 2–4 — all removed rather than wiredEach was code that did not do what it said, so I deleted rather than implemented. Wiring any of them would be a new feature inside a fix.
A correction to my PR descriptionMy original description said "Drag gestures still route through Smaller items
VerificationThe 5 failures are the same pre-existing ones, unchanged by this branch. 🤖 Generated with Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Reopening #186 on its own branch, rebased on current master, as you suggested in #173.
This is the one you said you wanted most, and it now carries the fix for the blocker you reduced — the keyboard being unreachable — rather than just the tap routing.
The blocker, fixed
You reported that tapping the terminal on a phone no longer raises the keyboard, so there is no way to type. That was real, and it was still present on my rebased branch until the last commit here.
Measured by dispatching real
touchstart/touchendevents against a running server, iPhone-class viewport (390×844), claude-mode session, tapping the middle of the terminal:document.activeElementafter the tapb1614e8textarea.xterm-helper-textareabodytextarea.xterm-helper-textareaMechanism, matching your reduction exactly:
_classifyMobileTerminalTap()returnscontentfor any non-prompt row, so_handleMobileTerminalTap()called_blurMobileTerminalInput(), whiletouchstart'spreventDefault()had already cancelled the compatibility click that would otherwise focus xterm. Both routes to focus closed on the same gesture.Why the blur could not simply be deleted
Two existing tests in
test/mobile/keyboard.test.tsassert the blur, and they encode correct behaviour: tapping a collapsible readback or a• Working (… esc to interrupt)status row acts on the CLI, and popping the keyboard there is wrong. Your repro taps an inert transcript row, where the only sensible outcome is "let me type". Both classified ascontentand were treated identically — that conflation was the actual defect.The fix distinguishes them by the affordance the CLI prints (
ctrl+r to expand,tap to collapse,esc to interrupt) rather than by row titles. Titles vary per CLI and per version; the affordance is what makes a row actionable in the first place. Since the hint sits on its own row and a finger lands on the title, the adjacent row is consulted too.I mention this because my first attempt keyed on title strings (
Agent readback,Tool result) taken from the test fixtures. The full suite passed, and a realistically-worded codex readback (Read src/foo.ts (120 lines)/ctrl+r to expand) still swallowed the keyboard. It matched the fixtures, not the behaviour. Caught by probing a readback whose wording differs from the tests.On the test you asked for
You asked for a test asserting a tap ends with
document.activeElement === terminal.textarea. It is here, dispatching a real gesture viapage.touchscreen.tap.In fairness, it is not red on master. Master does not have this bug, so the assertion is green there. It is a regression guard for this branch, not evidence against master. I would rather say that plainly than let the table above imply a comparison it does not support.
The same caveat applies to the unit tests in
test/terminal-touch-tap.test.ts: 8 of 27 fail on master, but withTypeError: ... is not a function, because the methods are new. That only proves absence, not wrong output.One methodological note that may be worth having in the repo: calling
_handleMobileTerminalTap()directly reports the correct intent and looks fine, because it bypasses thetouchstartpreventDefault(). Only a dispatched touch gesture reproduces the bug. Any focus test here has to go through real events.Verification
Against master
b1614e8:The 5 failures are pre-existing and identical on master (stale layout/accessory-bar expectations and a CJK timeout); they are untouched here. Full mobile suite, this branch vs pristine master on the same machine: both 54 failed, identical per-file counts, branch running 356 tests to master's 352 — the added tests pass and nothing regresses.
Coexistence with #205
The touch-forwarding path added in #205 is unchanged. Drag gestures still route through
_shouldForwardTouchScrollToApp()— forwarded as SGR wheel events for verified Claude versions, local xterm scrollback otherwise. This PR only changes what happens on a tap (no scroll), and only which rows dismiss the keyboard. The SGR mouse report a content tap sends is byte-identical to before; the readback test still asserts exactly one report of the form\x1b[<0;N;1M\x1b[<0;N;1m.Scope
3 files.
src/web/public/terminal-ui.jsplus the two test files. No changes to any guard or policy test.🤖 Generated with Claude Code