Skip to content

fix(viewer): keep the soft keyboard up while a TUI streams output#292

Merged
kshivang merged 1 commit into
masterfrom
fix/viewer-keyboard-dismiss-on-output
Jun 9, 2026
Merged

fix(viewer): keep the soft keyboard up while a TUI streams output#292
kshivang merged 1 commit into
masterfrom
fix/viewer-keyboard-dismiss-on-output

Conversation

@kshivang

@kshivang kshivang commented Jun 9, 2026

Copy link
Copy Markdown
Owner

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-textarea blurs 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)

  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 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 like title/cwd/focused/color excluded).

  2. Auto-fit while the keyboard is up. window.resize fires on the user's cadence (keyboard toggling, Safari toolbar collapsing) → maybeAutoFit → fitScreen → applyFont changes 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.

  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. → 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 (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 return 304 (Ktor sets Last-Modified from the jar entry).

Trade-offs

  • No mid-open re-push for predictive/accessory-bar inset changes (rare, not worth a blur risk); the open-time push is still cursor-aware.
  • A host-side split ratio change still rebuilds (ratios are in the signature) so the divider stays in sync; only the streaming title/cwd/focused churn is ignored.

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

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)
@claude

claude Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review — fix(viewer): keep the soft keyboard up while a TUI streams output

Reviewed both files end-to-end against the surrounding viewer/share code. This is a high-quality, well-scoped fix: the root-cause writeup is precise, and the three mutations it targets (renderStage rebuild, auto-fit refont, cursor-follow transform) are genuinely the DOM mutations that would blur .xterm-helper-textarea. Highlights and a few minor observations below.

What's solid

  • Scroll-invariant kbH = innerHeight - vv.height (dropping offsetTop) is the key insight — iOS auto-scroll-to-caret only moves offsetTop, so excluding it correctly prevents output from masquerading as a geometry change. Gating the transform rewrite on an actual open !== keyboardOpen transition follows cleanly.
  • treeSkeleton/stageSignature correctly model what renderStage actually draws. Confirmed buildNode derives pane focus from the client's own currentPaneId (line 1518), never the host's focused, and pane titles/cwd/color render only in renderTabBar (called unconditionally in onLayout). So excluding those volatile fields is safe — titles still update live; only the structural rebuild is suppressed.
  • Deferred auto-fit correctly preserves autoFitPending and replays via maybeAutoFit() on the close transition. cursorBottomPx() reading the old appliedShiftPx before it's updated is also correct (computes in the un-shifted frame).
  • Cache-Control: no-cache is the right call for non-hashed assets, and the comment about 304/Last-Modified is accurate for Ktor's staticResources.

Minor observations (none blocking)

  1. Split id is excluded from the signature. treeSkeleton encodes dir+ratio+children but not node.id, while divider drag uses node.id for resizeSplit (lines 1597/1620/1632). If the host ever reassigned a split's id while keeping dir/ratio/children identical, a same-structure layout would be skipped and the divider would carry a stale splitId. Almost certainly impossible in practice (split ids are stable), but a one-line note in the skeleton comment would mark it as a deliberate omission.
  2. The > 60 keyboard threshold is now in two placeslayoutForKeyboard (kbH > 60) and softKeyboardUp (innerHeight - vv.height > 60). They must stay in lockstep or auto-fit deferral and transform gating could disagree at the boundary. Consider a shared KEYBOARD_MIN_PX const (and having softKeyboardUp reuse the same expression) to keep them from drifting.
  3. relayoutSinglePane() / updateDims() no longer run on same-structure layouts (they live inside the now-skipped renderStage). Likely fine since both derive from structure + font, not the volatile fields — but it's the one behavioral edge worth a manual sanity check: a host-side change that alters dims without changing the tree skeleton would leave the dims readout stale until the next real rebuild/fit.

Tests

No automated coverage, which is understandable for browser-geometry behavior the project tests manually (and the PR documents on-device verification). That said, treeSkeleton/stageSignature are now the core correctness mechanism and are pure functions — cheap to pin with a tiny unit test (e.g. "title-only delta → identical sig", "pane add/remove → different sig", "ratio change → different sig"), since a future regression here silently reintroduces the keyboard drop.

Overall: clean, well-commented, and the trade-offs (no mid-open re-push, ratio churn still rebuilds) are explicitly documented. Nice work. 👍

@kshivang kshivang merged commit 5117771 into master Jun 9, 2026
5 checks passed
Sign up for free to 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.

1 participant