feat(workspaces): mobile terminal UX — keyboard-aware viewport, key bar, gestures, select mode#25
Merged
Merged
Conversation
…tures, select mode)
…er covers the terminal iOS Safari never shrinks the layout viewport when the on-screen keyboard opens, so the fixed inset-0 mobile shell kept its full height and the keyboard sat on top of the terminal's input line. Track window.visualViewport (new useVisualViewportHeight store hook, touch-only, SSR-safe) and drive the mobile shell's height from it; the existing ResizeObserver + fit pipeline then shrinks the PTY/tmux grid so the prompt stays visible. Pin the window scroll back to 0 when iOS pans on focus. Android Chrome gets the declarative fix too: interactive-widget= resizes-content on the viewport meta (local + remote web), which makes the layout viewport itself resize; the hook is a no-op safety net there. Desktop keeps h-screen/grid — the hook returns null off-touch.
…t steppers Touch-only hotkey row under the terminal (esc ⇥ ⇧⇥ ^C ctrl ← ↓ ↑ → ⏎) — the keys mobile soft keyboards can't type. Arrows follow DECCKM (applicationCursorKeysMode) so they work both at a shell prompt and inside claude/tmux. Buttons preventDefault on pointerdown so the system keyboard stays open while sending. Sticky Ctrl, Termius-style: tapping ctrl latches it (highlighted); the next single typed character is transformed to its control code in the onData→send pipe (pure applyStickyCtrl, unit-tested), then the latch clears. Multi-char bursts (paste/IME) pass through and unlatch. The latch/select flags live in a WeakMap keyed by the Terminal (terminalMobileState): terminals outlive their React components across remount/reattach, so per-mount refs would go stale — this gives the once-per-terminal pipe and the re-mounting React children the same state. TerminalMobileControls gains a select-mode toggle (wired next commit), A−/A+ font steppers (clamped 8–20, persisted, restored on touch devices at terminal creation), and now receives the live terminal + refit callback instead of a ref getter. Focusing the terminal on touch scrolls to the prompt so the keyboard never opens onto stale scrollback.
…ger paste) + select mode Gesture layer over the terminal, Termius-style, as a pure time-injected state machine (createTouchGestureController) with a thin DOM adapter: - long-press (350ms, no movement) enters D-pad mode with a small overlay; dragging sends repeated arrows in the dominant axis with three speed tiers by drag distance; direction changes fire immediately; lifting exits - double-tap sends Tab; three-finger tap pastes (same guarded clipboard path as the Paste button); a finger that moves before the long-press delay is a scroll and the layer stands down Coordination with the touch→wheel scroll bridge: the bridge gets an isSuppressed dep (dpadActive || selectMode from the shared mobile state) and abandons the touch sequence once suppressed — a D-pad drag can never double as wheel scrolling. Select mode (toggle in the mobile controls): gestures + scroll bridge stand down and a drag maps touch coords to buffer cells (touchToCell/linearSelection, pure + tested) driving terminal.select() client-side — no synthetic mouse events, so tmux mouse tracking never interferes; the existing onSelectionChange handler auto-copies. All controllers unit-tested (57 tests green via npx vitest).
…d/Linux) Verified in-browser: U+21E5/U+21E7/U+23CE are missing from Roboto/DejaVu and rendered as boxes. Phosphor arrow icons render identically everywhere; esc/^C/ctrl stay as text.
…iewport is live Avoids depending on dvh support: with no visualViewport (or no touch) the class alone reproduces the exact pre-existing fixed inset-0 behavior; the explicit height + bottom:auto only kick in when the hook has a real value.
- Sticky Ctrl now acts on KEYSTROKES only: an onKey-set flag marks the next onData chunk as keyboard-originated; pastes / IME commits / terminal query replies (DSR/DA) are never transformed and never consume the latch. Key-bar taps consume the latch (one-keystroke contract). toCtrlChar is ASCII-only — String.toUpperCase would map ß→SS→^S (XOFF freeze). - Three-finger paste: same guarded clipboard path + explicit status feedback as the Paste button (flash channel through the terminal mobile state) — no more silent clipboard read; gesture+selection installers gated on isTouchDevice() so non-touch sessions carry zero new listeners. - Select mode copies ONCE on release; the per-change auto-copy is suppressed while select mode is active (a drag no longer hammers the clipboard with dozens of intermediate writes / destroys the user's clipboard on touchstart). - D-pad promotion no longer depends on setTimeout: a starved timer promotes synchronously inside onTouchMove (before the slop check), and the gesture layer is attached BEFORE the scroll bridge so same-event suppression holds. - cancelActiveTerminalGesture(terminal): React unmount aborts an in-flight D-pad (repeat timer + dpadActive suppression can't outlive detach). - visualViewport store: listeners only attach on touch devices; the scrollTo(0,0) pin is touch-gated (desktop pinch-zoom is never fought). 64 unit tests green (new: timer starvation, cancel, non-ASCII ctrl).
Correctness (from 6 finder angles + codex + security cross-check): - Sticky Ctrl provenance now uses a paste marker instead of an onKey flag: Android IME (Gboard) commits text via composition with NO key event, so the flag left the latch dead on the PR's own target platform. New rule: a single-char onData chunk transforms unless a paste is in flight (pasteTextIntoTerminal brackets every app paste path with a WeakSet marker; xterm's paste() emits synchronously). Query replies (DSR/DA) are multi-char and pass through; gesture arrows/Tab and key-bar taps consume the latch (one-keystroke contract everywhere). - Gesture controller: coalesced multi-finger touchstart (all fingers in one event) now initializes pressAt/start coords and respects the select-mode gate — three-finger tap actually fires on iOS and can't leak through select mode; a travelling three-finger gesture (iPadOS system swipes) no longer counts as a tap; touchcancel is a cancel, never a tap/paste; adapter uses event timestamps + performance.now() so a busy main thread can't misread a late-delivered swipe as a dwell and NTP steps can't stall D-pad repeats; cancel() wipes double-tap history. - Select mode: capture-phase listeners + stopPropagation keep xterm's own viewport touch scrolling (plain shells) from scrolling under a selection drag; touchToCell returns null on degenerate rects instead of feeding NaN to terminal.select(); copy-on-release goes through writeClipboardViaBridge (VSCode-iframe fallback); terminal.select() only runs when the target cell changed. - Viewport store: pinch-zoom (visualViewport.scale > 1) now reports null — the app keeps its full-size layout and the scroll pin never fights a zoom pan; notifications are rAF-coalesced; keyboard-height override now applies on BOTH layout branches so touch tablets (>767px) get it too. - Unmount resets selectMode/ctrlLatched: modes no longer silently survive a pane close/reopen against the registry-persisted terminal. - Key bar: role=group (toolbar ARIA contract needs roving focus); D-pad overlay uses +/plain arrows (the fancy glyphs tofu on Android). Simplification (4 cleanup angles): - pasteIntoTerminal: one guarded, feedback-carrying paste implementation behind the Paste button, the three-finger gesture, and right-click paste. - installTerminalTouchLayers encodes the gestures→selection→scroll attach order as code instead of a prose comment in the component. - Flash channel is now an event subscription (no retained flash state — a stale 'Pasted' pill can't replay on remount), useTerminalMobileState dedupes the subscription boilerplate, KEYS is one array with ctrl in place (no slice(0,4) magic), focus guards live on the container, isTouchDevice is memoized, gesture timer reschedules only on deadline change, lastTap is one nullable object. 75 unit tests green (new: terminalMobileState store/flash contract, coalesced multi-touch, swipe-not-tap, cancel semantics, degenerate rects).
- Select-mode copy uses navigator.clipboard directly instead of writeClipboardViaBridge: the bridge's fallback posts the text to window.parent with targetOrigin '*', and a terminal selection can hold secrets — an untrusted framing page must never receive them. Failure is surfaced via the flash pill; the selection stays so the Copy button can retry. (Matches every other terminal copy path, which also use the direct clipboard.) - Touch devices intercept the textarea's native paste event (OS paste callout, hardware Cmd+V) and route it through pasteTextIntoTerminal so the sticky-Ctrl provenance marker covers ALL paste paths — a 1-char clipboard while Ctrl is latched can no longer become a control code. Desktop keeps xterm's native paste handler untouched. - Select-mode touchcancel clears the drag WITHOUT copying: a cancelled sequence must not clobber the clipboard with a partial selection. Deliberately unchanged: multi-char chunks (IME commits) pass through without consuming the latch — consuming there would let invisible terminal query replies (DSR/DA, also multi-char) eat an armed latch, the exact round-1 bug; the latch is always visible on the highlighted ctrl key.
…e drag A palm touch mid-drag no longer discards the anchor (the drag resumes when the stray finger lifts), and the copy-on-release waits for the LAST finger — a stray finger lifting first can't end the drag and copy a partial selection while the primary finger is still down. Mirrors the gesture controller's remaining-touches rule.
CodeRabbit: the previous listener registered on the textarea AFTER xterm's (attached at open()), so both ran — preventDefault can't stop an earlier listener on the same target, double-pasting every OS-callout/Cmd+V paste on touch devices. Ancestor capture-phase listener + stopPropagation runs before the target and keeps the event from xterm's handler entirely.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes the terminal console genuinely usable on phones. Spec:
docs/superpowers/specs/2026-07-04-mobile-terminal-ux-design.md(approved interactively before implementation).What's in it
window.visualViewporton touch devices (useVisualViewportHeight), so when the on-screen keyboard opens the shell shrinks, xterm refits, and tmux redraws with claude's input line sitting right above the keyboard.interactive-widget=resizes-contentmeta for Android; iOS focus-pan is pinned back; pinch-zoom (scale > 1) is detected and never fought. Applies to phone AND tablet-width layouts.esc ⇥ ⇧⇥ ^C ctrl ← ↓ ↑ → ⏎— phosphor icons (key-glyph codepoints are tofu on Android). Sticky ctrl latches (highlighted) and turns the next typed character into its control code — Android-IME-safe: provenance comes from a paste-marker (pasteTextIntoTerminalWeakSet brackets every paste path, including the OS paste callout via textareapasteinterception on touch), not key events. Terminal query replies never consume the latch; bar/gesture keys do. Arrows honor DECCKM.touchcancel= cancel, React-detach cancellation.terminal.select()client-side (tmux mouse-tracking never involved), capture-phase listeners keep xterm's native touch scroll out, copy fires exactly once on release via directnavigator.clipboard(NOT the postMessage bridge — selections can hold secrets), stray fingers can't corrupt the drag.Review gauntlet (all applied)
/simplify(4 agents): sharedpasteIntoTerminal,installTerminalTouchLayers(ordering invariant as code), event-based flash channel (no stale replay),useTerminalMobileState, single KEYS array, memoizedisTouchDevice, rAF-coalesced viewport store, dedup'dterminal.select()./code-reviewxhigh (6 finder angles) + codex review: Android IME latch fix, coalesced multi-touchstart, three-finger-swipe≠tap, touchcancel≠tap, NaN cell guard, tablet keyboard gap, monotonic clocks,role=group.Verified live (emulated-touch Chrome against the real backend + tmux claude session): keyboard-shrink → tmux refit 48→23 rows; typing + ^C + sticky-ctrl end-to-end; D-pad overlay + history recall via arrows; swipe scroll bridge intact; select-drag → "Copied selection"; font 12→13→12 persisted; desktop (no touch) renders zero mobile UI. 75 colocated unit tests.
Known limitations (deliberate)
env(safe-area-inset-bottom)padding stays while the keyboard is open (~34px strip above the keyboard). Cosmetic; revisit if it annoys.TerminalMobileControlsprecedent; i18n migration is a separate pass.Checkpoints