Skip to content

feat(workspaces): mobile terminal UX — keyboard-aware viewport, key bar, gestures, select mode#25

Merged
miguelrisero merged 11 commits into
mainfrom
mr/99ce-mobile-gestures-keyboard
Jul 4, 2026
Merged

feat(workspaces): mobile terminal UX — keyboard-aware viewport, key bar, gestures, select mode#25
miguelrisero merged 11 commits into
mainfrom
mr/99ce-mobile-gestures-keyboard

Conversation

@miguelrisero

@miguelrisero miguelrisero commented Jul 4, 2026

Copy link
Copy Markdown
Owner

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

  1. Keyboard-aware viewport — the app shell tracks window.visualViewport on 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-content meta for Android; iOS focus-pan is pinned back; pinch-zoom (scale > 1) is detected and never fought. Applies to phone AND tablet-width layouts.
  2. Key bar (touch-only, always visible under the terminal): 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 (pasteTextIntoTerminal WeakSet brackets every paste path, including the OS paste callout via textarea paste interception on touch), not key events. Terminal query replies never consume the latch; bar/gesture keys do. Arrows honor DECCKM.
  3. Gestures (Termius-style): long-press → D-pad with 3 speed tiers + overlay; double-tap → Tab; three-finger tap → paste (guarded + explicit feedback). Pure time-injected controller, event-timestamp clocks, synchronous starved-timer promotion, coalesced multi-touch handling, swipe≠tap, touchcancel = cancel, React-detach cancellation.
  4. Select mode (toggle in the mobile controls): touch drag drives 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 direct navigator.clipboard (NOT the postMessage bridge — selections can hold secrets), stray fingers can't corrupt the drag.
  5. Font steppers A−/A+ (8–20px, persisted, touch-only) + Copy/Paste/Keyboard controls from fix(workspaces): mobile touch scroll + copy/paste in the terminal console #22 retained.

Review gauntlet (all applied)

  • Council round 1 (4× Codex + Claude security seat): sticky-ctrl provenance, silent-paste, clipboard-storm, D-pad timer race, gesture-timer leak, ASCII-only ctrl mapping, viewport gating — all fixed.
  • /simplify (4 agents): shared pasteIntoTerminal, installTerminalTouchLayers (ordering invariant as code), event-based flash channel (no stale replay), useTerminalMobileState, single KEYS array, memoized isTouchDevice, rAF-coalesced viewport store, dedup'd terminal.select().
  • /code-review xhigh (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.
  • Council round 2 (4× Codex + Claude seat): perf/DX CLEAN; security+maintainability P1s (bridge exfiltration, native-paste bypass) fixed.
  • CodeRabbit CLI: stray-finger drag corruption fixed. (CodeRabbit App isn't installed on this fork; CLI used instead.)

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)

  • iOS standalone-PWA: env(safe-area-inset-bottom) padding stays while the keyboard is open (~34px strip above the keyboard). Cosmetic; revisit if it annoys.
  • Hardcoded English strings in the touch controls — consistent with the pre-existing TerminalMobileControls precedent; i18n migration is a separate pass.
  • Multi-char IME commits don't consume the ctrl latch (would let invisible DSR/DA replies eat it); the latch is always visible on the highlighted key.
  • Touch-capable laptops/tablets get the key bar in desktop layout too — by design (they're the iPad case); it's collapsible via the controls chevron.
  • Font size is per-terminal-creation: an A+ in one tab applies to new terminals, not other already-open ones.

Checkpoints

  • Spec committed + implementation (5 phases)
  • In-browser verification vs live backend
  • Council r1 → fixes · /simplify → applied · /code-review xhigh + codex → fixes · Council r2 → fixes · CodeRabbit → fixes
  • CI green on final commit (frontend-checks SUCCESS; backend jobs skipped — frontend-only diff) · CodeRabbit: No findings on 15c160b

…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.
@miguelrisero miguelrisero marked this pull request as ready for review July 4, 2026 07:27
@miguelrisero miguelrisero merged commit 0eeebfb into main Jul 4, 2026
7 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