On a phone, typing into a mouse-tracking/redrawing TUI in the web viewer
(e.g. Claude Code while it's "thinking") repeatedly dismissed the soft
keyboard. On-device diagnostics traced it to three distinct DOM mutations
that each blur the focused .xterm-helper-textarea — and on iOS, once the
textarea blurs outside a user gesture, the keyboard can't be re-summoned:
1. renderStage's `innerHTML = ""` (the dominant cause). The host re-broadcasts
layout ~1/s during streaming — only the pane *title* changes (live progress)
— and onLayout rebuilt the whole stage every time, detaching the focused
textarea. Removing a focused node fires NO blur event on Safari, so the
keyboard just vanished. Fix: skip the rebuild when the rendered STRUCTURE
(active tab + shown-pane mode + pane-id/split skeleton, ratios included,
volatile pane fields excluded) is unchanged.
2. Auto-fit while the keyboard is up. window.resize fires on the user's cadence
(keyboard toggling, Safari's toolbar collapsing) → maybeAutoFit → fitScreen →
applyFont changes the font size → xterm re-renders → textarea blurs. Fix:
never refit while the soft keyboard is up (it would shrink the font to the
keyboard-occluded area anyway); apply the deferred fit on keyboard close.
3. The cursor-follow body transform. term.onCursorMove fired layoutForKeyboard
at output frequency, rewriting document.body's transform (an ancestor of the
textarea) and blurring it. Fix: drop the onCursorMove coupling; rewrite the
transform only on an actual keyboard open<->close transition, computed from a
scroll-invariant keyboard height (innerHeight - visualViewport.height, no
offsetTop) so output-driven auto-scroll can't masquerade as a geometry change.
Also: serve the viewer assets with Cache-Control: no-cache. The filenames
aren't content-hashed, so without revalidation a phone keeps running the viewer
JS/HTML from a previous app version (iOS Safari has no real hard-reload);
unchanged assets still return 304.
Generated with [Claude Code](https://claude.com/claude-code)
Problem
On a phone, typing into a mouse-tracking/redrawing TUI in the web viewer (e.g. Claude Code while it's thinking) repeatedly dismissed the soft keyboard. On iOS, once the focused
.xterm-helper-textareablurs outside a user gesture, the keyboard can't be re-summoned — so any output-driven blur dropped it for good.On-device diagnostics (a temporary on-screen event log, since iOS Safari has no remote devtools) traced it to three distinct DOM mutations, each of which blurs the focused textarea. Earlier output-timing theories all missed because the blurs landed on the user's interaction cadence, not the output cadence.
Root causes & fixes (viewer.js)
renderStage'sinnerHTML = ""— the dominant cause. The host re-broadcasts layout ~1/s during streaming (only the pane title changes — live progress), andonLayoutrebuilt the entire stage each time, detaching the focused textarea. Removing a focused node fires no blur event on Safari, so the keyboard silently vanished. → Skip the rebuild when the rendered structure is unchanged (active tab + shown-pane mode + pane-id/split skeleton incl. ratios; volatile pane fields liketitle/cwd/focused/colorexcluded).Auto-fit while the keyboard is up.
window.resizefires on the user's cadence (keyboard toggling, Safari toolbar collapsing) →maybeAutoFit → fitScreen → applyFontchanges the font size → xterm re-renders → textarea blurs. → Never refit while the soft keyboard is up (fitting to the keyboard-occluded area would shrink the font anyway); apply the deferred fit on keyboard close.The cursor-follow body transform.
term.onCursorMovefiredlayoutForKeyboardat output frequency, rewritingdocument.body's transform (an ancestor of the textarea) and blurring it. → Drop theonCursorMovecoupling; rewrite the transform only on an actual keyboard open↔close transition, computed from a scroll-invariant keyboard height (innerHeight - visualViewport.height, nooffsetTop) so output-driven auto-scroll can't masquerade as a geometry change.Also (SessionShareManager.kt)
Serve the viewer assets with
Cache-Control: no-cache. The filenames aren't content-hashed, so without revalidation a phone keeps running the viewer JS/HTML from a previous app version (iOS Safari has no real hard-reload). Unchanged assets still return304(Ktor setsLast-Modifiedfrom the jar entry).Trade-offs
Testing
Verified on iPhone Safari over a Cloudflare relay: raise the keyboard, type a long prompt while Claude Code streams/thinks — the keyboard now stays up throughout. Regressions confirmed intact: keyboard opens and pushes the view up on first tap, closing restores layout, Android unaffected (visualViewport inset 0).
Generated with Claude Code