Skip to content

fix(mobile): route terminal taps without breaking keyboard focus - #244

Merged
Ark0N merged 6 commits into
Ark0N:masterfrom
Lint111:feat/mobile-terminal-taps
Aug 10, 2026
Merged

fix(mobile): route terminal taps without breaking keyboard focus#244
Ark0N merged 6 commits into
Ark0N:masterfrom
Lint111:feat/mobile-terminal-taps

Conversation

@Lint111

Copy link
Copy Markdown
Contributor

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/touchend events against a running server, iPhone-class viewport (390×844), claude-mode session, tapping the middle of the terminal:

document.activeElement after the tap
master b1614e8textarea.xterm-helper-textarea
this branch, before the fix commitbody
this branch, nowtextarea.xterm-helper-textarea

Mechanism, matching your reduction exactly: _classifyMobileTerminalTap() returns content for any non-prompt row, so _handleMobileTerminalTap() called _blurMobileTerminalInput(), while touchstart's preventDefault() 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.ts assert 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 as content and 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 via page.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 with TypeError: ... 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 the touchstartpreventDefault(). Only a dispatched touch gesture reproduces the bug. Any focus test here has to go through real events.

Verification

Against master b1614e8:

npx tsc --noEmit clean
test/terminal-touch-tap.test.ts 27 passed (27)
npm run test:mobile -- test/mobile/keyboard.test.ts 5 failed | 35 passed (40)

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.js plus the two test files. No changes to any guard or policy test.

🤖 Generated with Claude Code

CopilotAI lite review requested due to automatic review settings August 9, 2026 11:59
CopilotAI reviewed Aug 9, 2026

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Ark0NArk0N left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 statedocument.activeElement after the tap
master b1614e8viewportY 82 / baseY 83 (off-bottom)textarea.xterm-helper-textarea
this branchviewportY 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 here

2. 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;// :3216

grep -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' || startedWithTerminalFocus means 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._classifyMobileTerminalTap does a full-viewport translateToString scan. It now runs at touchstart (which also became non-passive), again inside _handleMobileTerminalTap, and _isActionableMobileTerminalTap does 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/i matches ordinary transcript prose such as "click here to open the file", which would dismiss the keyboard on an inert row.
  • _handleMobileTerminalTap returns '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.

Ark0N pushed a commit that referenced this pull request Aug 9, 2026
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>
Lint111and others added 6 commits August 10, 2026 13:21
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>
@Lint111
Lint111force-pushed the feat/mobile-terminal-taps branch from 19e2ef5 to 3b85001CompareAugust 10, 2026 10:32
@Lint111

Copy link
Copy Markdown
ContributorAuthor

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 752374a and pushed as 3b85001f.

Item 1 (blocking) — fixed

You 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:

  • touchstart now calls preventDefault() only for content taps. A scrolled-up viewport sends nothing, so there is no compatibility click worth cancelling.
  • the history branch of _handleMobileTerminalTap focuses instead of blurring.

Verified against your test rather than my own: keeps the terminal input focusable after a tab switch parks the viewport off-bottom fails without this change and passes with it (checked by reverting the history branch alone).

Items 2–4 — all removed rather than wired

Each 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.

  • 2. Both terminal-action-pending guards are gone. You are right that the class exists nowhere in the repo; both branches were permanently false.
  • 3. The Working literals are gone. Your ✻ Cooked for 2m 6s observation is the whole point — the bullet and the verb are both unstable. The status row is now matched by its affordance (esc to interrupt), which is what makes it actionable. I also tightened the affordance regex to require a key or gesture name, so your click here to open the file case no longer dismisses the keyboard.
  • 4._shouldForwardTouchScrollToApp and its test are gone. Your point about it restricting forwarding to claude only and dropping gemini is exactly why it should not be wired as written.

A correction to my PR description

My original description said "Drag gestures still route through _shouldForwardTouchScrollToApp()." That was wrong, and worse, I claimed it after checking only that the commit touched no forwarding function — I never verified the new function was reachable. It was not. Sorry for asserting something I had not actually established; the claim should have been that touch scroll routing is unchanged from master, which is true and was all the PR needed.

Smaller items

  • Cost per gesture. The touchstart classification is now cached and reused for the touchend of the same gesture, keyed on the exact start coordinates so a finger that moved re-classifies at its real position. That removes two of the three full-viewport scans.
  • Duplicate line. Removed.
  • 'history' bail-out. Now returns null — a guard result, not a classification.
  • Two-tap prompt behaviour. Deliberate, and you are right that it belonged in the description. First tap on the prompt row focuses without positioning the cursor; the second positions it. The intent is that a tap meant to raise the keyboard cannot also fire an action in the CLI. keeps the first prompt tap focus-only so it cannot activate a CLI row pins it. If you would rather the first tap both focus and position, say so and I will change it — it is a one-line change to shouldActivate.

Verification

npx tsc --noEmit clean
npm run lint clean
npm run format:check clean
npm run check:frontend-syntax 31 files parse cleanly
test/terminal-touch-tap.test.ts 26 passed (26) [was 27; -1 removed with item 4]
test/mobile/keyboard.test.ts 5 failed | 46 passed (51)

The 5 failures are the same pre-existing ones, unchanged by this branch.

🤖 Generated with Claude Code

@Ark0N
Ark0N merged commit a4cdb35 into Ark0N:masterAug 10, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@Lint111@Ark0N