diff --git a/docs/superpowers/specs/2026-07-04-mobile-terminal-ux-design.md b/docs/superpowers/specs/2026-07-04-mobile-terminal-ux-design.md
new file mode 100644
index 0000000000..4232569e49
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-04-mobile-terminal-ux-design.md
@@ -0,0 +1,174 @@
+# Mobile Terminal UX — Design
+
+**Date:** 2026-07-04
+**Status:** Approved (decisions settled interactively with Miguel; he is away during implementation)
+**Builds on:** PR #22 (touch→wheel scroll bridge, mobile Copy/Paste/Keyboard controls, OSC 52 clipboard)
+
+## Problem
+
+The terminal console (xterm.js hosting the tmux-backed `claude` CLI session, plus
+plain shell side terminals) is still close to unusable on phones:
+
+1. When the on-screen keyboard opens, nothing resizes or scrolls — the keyboard
+ covers the bottom half of the terminal, including claude's input line.
+2. There is no way to select text by touch (xterm.js has no touch selection).
+3. There is no way to send Esc, Tab, Shift+Tab, Ctrl combos, or arrow keys —
+ mobile soft keyboards simply don't have them.
+
+**Platforms:** iOS Safari (primary, plain web + home-screen PWA), Android Chrome
+(secondary). Desktop behavior must remain byte-for-byte unchanged: everything
+below is gated on touch capability (`useIsTouchDevice` / `isTouchDevice`, same
+gates PR #22 used).
+
+**Scope:** `XTermInstance` and its satellites in `packages/web-core` — so it
+applies to both the CLI main pane and shell side terminals automatically.
+
+## Decisions (settled with Miguel)
+
+| Topic | Decision |
+|---|---|
+| Keyboard handling | Shrink + refit: container tracks `visualViewport`, xterm refits, PTY/tmux resizes; input line always visible |
+| Key bar | `esc ⇥ ⇧⇥ ^C ctrl ← ↓ ↑ → ⏎` with sticky/latching ctrl; fixed set, no settings UI |
+| Gestures | Long-press + drag = arrow D-pad (3 speed tiers); double-tap = Tab; three-finger tap = paste. **No pinch zoom.** |
+| Text selection | Explicit select-mode toggle button in the mobile controls (gestures/scroll bridge pause while active) |
+| Font size | A+ / A− stepper buttons in mobile controls, persisted in localStorage |
+| Delivery | PR + merge via ship-it light; **no /opt deploy** |
+
+## Design
+
+### 1. Keyboard-aware viewport (`useVisualViewportHeight` + layout wiring)
+
+New hook in `packages/web-core/src/shared/hooks/`: subscribes to
+`window.visualViewport` `resize` + `scroll` events (SSR-safe, `null` /
+no-op when `visualViewport` is unavailable or the device is not
+touch-capable). It exposes the current visible height.
+
+Wiring: the terminal wrapper (in `XTermInstance`'s outer container's ancestor —
+the workspace mobile layout) caps its height to the visual viewport when the
+keyboard is open, instead of `h-screen`'s layout-viewport height. The existing
+`ResizeObserver` + debounced `fitTerminal` in `XTermInstance` then does the
+refit + WS resize automatically — no new resize plumbing.
+
+Details:
+
+- `interactive-widget=resizes-content` added to the viewport meta in both
+ `packages/local-web/index.html` and `packages/remote-web/index.html` —
+ Android Chrome then resizes the layout viewport natively; the hook remains
+ the iOS path (and a no-op safety net on Android).
+- On keyboard open (viewport shrink) and on terminal focus:
+ `term.scrollToBottom()` so the prompt is visible immediately.
+- Compensate iOS Safari's scroll-the-page-on-focus behavior: when
+ `visualViewport.offsetTop > 0`, scroll the window back so the app chrome
+ stays pinned (best-effort; keep it simple and defensive).
+- The key bar (below) renders inside this sized container, bottom-anchored, so
+ it naturally sits directly above the system keyboard.
+
+### 2. Key bar (`TerminalKeyBar.tsx`)
+
+Touch-only component (returns `null` off-touch), rendered by `XTermInstance`
+under the terminal viewport, always visible on touch devices (no fragile
+keyboard-open detection; it doubles as the tap-target to focus the terminal).
+Horizontally scrollable single row, 44px-high touch targets:
+
+`esc` `⇥` `⇧⇥` `^C` `ctrl` `←` `↓` `↑` `→` `⏎`
+
+Sequences sent through the existing single data pipe (`terminal.input(...)`,
+which flows through `onData` → `conn.send`):
+
+- `esc` → `\x1b`; `⇥` → `\t`; `⇧⇥` → `\x1b[Z` (CSI Z); `^C` → `\x03`;
+ `⏎` → `\r`.
+- Arrows respect application cursor keys mode
+ (`terminal.modes.applicationCursorKeysMode`): `\x1bOA…` vs `\x1b[A…`.
+- Buttons must not steal focus from the terminal (`preventDefault` on
+ pointer/mouse down) so the system keyboard stays open.
+
+**Sticky ctrl:** tapping `ctrl` latches it (visually highlighted). The next
+single printable character arriving on the data pipe is transformed to its
+control code (`char & 0x1f`), then ctrl unlatches. Tapping `ctrl` again while
+latched cancels. Implemented as a pure transform function
+(`applyStickyCtrl(data, latched) → {out, stillLatched}`) inserted in the
+`onData` → `send` path in `XTermInstance`, driven by shared state
+(ref + subscription) owned by the key bar. Multi-char bursts (paste, IME
+commits) pass through untouched and unlatch — only a single-char chunk is
+transformed. Unit-tested.
+
+### 3. Gesture layer (`terminalTouchGestures.ts`)
+
+Same architecture as `terminalTouchScroll.ts`: a pure, unit-testable gesture
+controller (state machine consuming `{type, touches, timestamp}` events and
+emitting actions) plus a thin `install` function that binds real touch events
+and executes actions. Coordinated with the existing scroll bridge:
+
+- **Long-press + drag = D-pad.** Finger down and held ≥ ~350 ms within a small
+ slop radius enters D-pad mode (a subtle overlay indicator appears). Dragging
+ past a threshold sends repeated arrow keys in the dominant axis direction;
+ repeat rate has 3 tiers by drag distance (Termius-style "gears"; roughly
+ ~110 ms / ~55 ms / ~25 ms per keystroke). Lifting the finger exits. While in
+ D-pad mode the scroll bridge is suppressed.
+- **Movement before the long-press threshold** = scroll, handled by the
+ existing touch-scroll bridge exactly as today.
+- **Double-tap = Tab.** Two taps within ~250 ms and tap-slop distance send
+ `\t`. Single taps still focus the terminal as before (tap behavior itself is
+ unchanged — we only add the second-tap interpretation).
+- **Three-finger tap = paste.** Same clipboard read path + feedback as the
+ Paste button (including the availability guards from PR #22).
+- Arrow sequences honor application cursor keys mode (shared helper with the
+ key bar).
+- Any multi-touch that isn't a clean three-finger tap keeps today's "ignore
+ until all fingers lift" rule.
+
+### 4. Select mode (toggle in `TerminalMobileControls`)
+
+New toggle button (e.g. text-cursor icon) in the existing mobile controls:
+
+- **On:** gesture layer + touch-scroll bridge are disabled. Touch drag maps to
+ xterm buffer selection: translate touch coords → cell coords using the
+ `.xterm-screen` bounding box and `cols`/`rows`, drive
+ `terminal.select(col, row, length)` across the drag (line-spanning
+ selections included). The existing `onSelectionChange` handler already
+ copies selections to the clipboard; the Copy button also picks them up.
+ A status flash ("Select mode — drag to select") explains the state.
+- **Off (default):** everything behaves as designed above. Toggling off clears
+ the selection.
+- No synthetic mouse events are forwarded to tmux — selection is purely
+ client-side over the visible buffer, which sidesteps mouse-tracking entirely.
+- This is the riskiest item (Miguel: skippable if it breaks things). It is
+ last in the build order and isolated behind its toggle so it can be dropped
+ without touching the rest.
+
+### 5. Font size steppers
+
+`A−` / `A+` buttons in `TerminalMobileControls`: adjust
+`terminal.options.fontSize` within 8–20 (default 12), call `fitTerminal`-style
+refit (fit + WS resize), persist to `localStorage`
+(`vk-terminal-mobile-font-size`). On touch devices, terminal creation reads the
+persisted value; desktop keeps the hardcoded 12.
+
+## Error handling
+
+- All new listeners/timers install once per created terminal (PR #22 pattern:
+ live with the element, die on dispose) and must not leak across reattach.
+- Clipboard paths reuse PR #22's guarded helpers (distinct
+ unavailable/blocked/empty feedback).
+- `visualViewport` absent → hook returns null → layout falls back to current
+ behavior (no crash, desktop unchanged).
+- Repeat timers (D-pad) must be cancelled on touchend/touchcancel/dispose.
+
+## Testing
+
+- Vitest (run via `npx vitest`, per repo convention) for the pure parts:
+ gesture controller state machine (long-press vs scroll vs double-tap vs
+ three-finger disambiguation, speed tiers, cancellation), sticky-ctrl
+ transform, arrow-sequence selection by cursor mode, touch→cell coordinate
+ mapping, font-size clamp/persistence logic.
+- `pnpm run check` + `pnpm run lint` green.
+- Manual smoke on device after merge (Miguel; /opt deploy handled separately
+ via ship-it, not in this change).
+
+## Build order
+
+1. Keyboard-aware viewport + meta tag (issue #1 — biggest pain, standalone).
+2. Key bar + sticky ctrl (issue #3).
+3. Gesture layer (D-pad, double-tap Tab, three-finger paste).
+4. Font steppers.
+5. Select mode (riskiest, optional, isolated).
diff --git a/packages/local-web/index.html b/packages/local-web/index.html
index 543f99629b..41e6c16271 100644
--- a/packages/local-web/index.html
+++ b/packages/local-web/index.html
@@ -7,7 +7,7 @@
-
+
diff --git a/packages/remote-web/index.html b/packages/remote-web/index.html
index 228f40d6df..9d788e5a49 100644
--- a/packages/remote-web/index.html
+++ b/packages/remote-web/index.html
@@ -2,7 +2,7 @@
-
+
BetterCoding Remote
diff --git a/packages/web-core/src/shared/components/TerminalKeyBar.tsx b/packages/web-core/src/shared/components/TerminalKeyBar.tsx
new file mode 100644
index 0000000000..6a16ff8685
--- /dev/null
+++ b/packages/web-core/src/shared/components/TerminalKeyBar.tsx
@@ -0,0 +1,136 @@
+import type { Terminal } from '@xterm/xterm';
+import type { Icon } from '@phosphor-icons/react';
+import {
+ ArrowDownIcon,
+ ArrowElbowDownLeftIcon,
+ ArrowLeftIcon,
+ ArrowLineLeftIcon,
+ ArrowLineRightIcon,
+ ArrowRightIcon,
+ ArrowUpIcon,
+} from '@phosphor-icons/react';
+
+import { cn } from '@/shared/lib/utils';
+import { useIsTouchDevice } from '@/shared/hooks/useIsMobile';
+import { keySequence, type BarKey } from '@/shared/lib/terminalKeySequences';
+import {
+ getTerminalMobileState,
+ patchTerminalMobileState,
+ useTerminalMobileState,
+} from '@/shared/lib/terminalMobileState';
+
+interface TerminalKeyBarProps {
+ /** Live terminal (null until mounted) — used for cursor-mode + ctrl latch state. */
+ terminal: Terminal | null;
+ /** Send raw bytes to the PTY (bypasses the sticky-Ctrl transform on typed input). */
+ onSendKey: (data: string) => void;
+}
+
+// Icons instead of key-glyph characters (⇥ ⇧⇥ ⏎): those codepoints are
+// missing from Android/Linux system fonts and render as tofu boxes; the
+// phosphor icons render identically everywhere. 'ctrl' is the latching
+// modifier button, rendered specially at its position in this list.
+const KEYS: ReadonlyArray<{
+ key: BarKey | 'ctrl';
+ label?: string;
+ Icon?: Icon;
+ aria: string;
+}> = [
+ { key: 'esc', label: 'esc', aria: 'Escape' },
+ { key: 'tab', Icon: ArrowLineRightIcon, aria: 'Tab' },
+ { key: 'shift-tab', Icon: ArrowLineLeftIcon, aria: 'Shift+Tab' },
+ { key: 'ctrl-c', label: '^C', aria: 'Control+C (interrupt)' },
+ {
+ key: 'ctrl',
+ label: 'ctrl',
+ aria: 'Control (sticky — next letter sends the combo)',
+ },
+ { key: 'left', Icon: ArrowLeftIcon, aria: 'Arrow left' },
+ { key: 'down', Icon: ArrowDownIcon, aria: 'Arrow down' },
+ { key: 'up', Icon: ArrowUpIcon, aria: 'Arrow up' },
+ { key: 'right', Icon: ArrowRightIcon, aria: 'Arrow right' },
+ { key: 'enter', Icon: ArrowElbowDownLeftIcon, aria: 'Enter' },
+];
+
+const KEY_CLASS =
+ 'flex items-center justify-center min-w-11 h-9 px-2 rounded-md shrink-0 ' +
+ 'bg-secondary border text-sm text-low active:bg-primary active:text-normal ' +
+ 'transition-colors select-none';
+
+/**
+ * Termius-style hotkey row for keys mobile soft keyboards can't type:
+ * Esc / Tab / Shift+Tab / ^C / sticky Ctrl / arrows / Enter.
+ *
+ * Touch-only (renders null otherwise) and always visible on touch devices —
+ * it sits at the bottom of the terminal pane, which the visual-viewport
+ * sizing keeps directly above the on-screen keyboard while typing.
+ *
+ * Buttons preventDefault on pointerdown so a tap never steals focus from
+ * xterm's textarea — the system keyboard stays open while sending keys.
+ */
+export function TerminalKeyBar({ terminal, onSendKey }: TerminalKeyBarProps) {
+ const isTouch = useIsTouchDevice();
+ const { ctrlLatched } = useTerminalMobileState(terminal);
+
+ if (!isTouch) return null;
+
+ const keepFocus = (e: { preventDefault: () => void }) => e.preventDefault();
+
+ const sendKey = (key: BarKey) => {
+ const appCursor = terminal?.modes.applicationCursorKeysMode ?? false;
+ onSendKey(keySequence(key, appCursor));
+ // A bar tap counts as the latch's "one keystroke" — otherwise ctrl,
+ // arrow, then a typed letter would surprise-fire a control combo.
+ if (terminal && getTerminalMobileState(terminal).ctrlLatched) {
+ patchTerminalMobileState(terminal, { ctrlLatched: false });
+ }
+ terminal?.scrollToBottom();
+ };
+
+ const toggleCtrl = () => {
+ if (!terminal) return;
+ patchTerminalMobileState(terminal, {
+ ctrlLatched: !getTerminalMobileState(terminal).ctrlLatched,
+ });
+ };
+
+ const renderKey = ({ key, label, Icon, aria }: (typeof KEYS)[number]) => {
+ const isCtrl = key === 'ctrl';
+ return (
+ sendKey(key)}
+ >
+ {Icon ? (
+
+ ) : (
+ label
+ )}
+
+ );
+ };
+
+ return (
+ // preventDefault on pointer/mouse down at the toolbar level (events
+ // bubble) so no tap steals focus from xterm's textarea — the system
+ // keyboard stays open while sending keys.
+ // role="group", not "toolbar": the ARIA toolbar contract requires roving
+ // tabindex + arrow-key traversal, and arrow keys here are CONTENT.
+
+ {KEYS.map(renderKey)}
+
+ );
+}
diff --git a/packages/web-core/src/shared/components/TerminalMobileControls.tsx b/packages/web-core/src/shared/components/TerminalMobileControls.tsx
index 8a5ffa2f7c..9457adc703 100644
--- a/packages/web-core/src/shared/components/TerminalMobileControls.tsx
+++ b/packages/web-core/src/shared/components/TerminalMobileControls.tsx
@@ -1,8 +1,9 @@
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import type { Terminal } from '@xterm/xterm';
import {
CopyIcon,
ClipboardTextIcon,
+ CursorTextIcon,
KeyboardIcon,
CaretLeftIcon,
CaretRightIcon,
@@ -11,10 +12,24 @@ import {
import { cn } from '@/shared/lib/utils';
import { useIsTouchDevice } from '@/shared/hooks/useIsMobile';
import { extractViewportText } from '@/shared/lib/terminalViewportText';
+import {
+ clampTerminalFontSize,
+ saveMobileTerminalFontSize,
+ TERMINAL_DEFAULT_FONT_SIZE,
+} from '@/shared/lib/terminalFontSize';
+import {
+ getTerminalMobileState,
+ patchTerminalMobileState,
+ subscribeTerminalMobileFlash,
+ useTerminalMobileState,
+} from '@/shared/lib/terminalMobileState';
+import { pasteIntoTerminal } from '@/shared/lib/terminalPaste';
interface TerminalMobileControlsProps {
- /** Live terminal accessor — refs don't trigger renders, so read on demand. */
- getTerminal: () => Terminal | null;
+ /** Live terminal (null until the mount effect registers it). */
+ terminal: Terminal | null;
+ /** Re-fit the grid + report the new size to the PTY (font-size steppers). */
+ refit: () => void;
}
const STATUS_MS = 1600;
@@ -24,23 +39,27 @@ const BUTTON_CLASS =
'text-low hover:text-normal active:bg-primary transition-colors';
/**
- * Touch-only Copy / Paste / Keyboard affordances for the terminal. Desktop keeps
- * its mouse/keyboard flow (drag-select, right-click, Ctrl/Cmd+V) untouched — this
- * renders nothing unless the device is touch-capable.
+ * Touch-only Copy / Paste / Select / font-size / Keyboard affordances for the
+ * terminal. Desktop keeps its mouse/keyboard flow (drag-select, right-click,
+ * Ctrl/Cmd+V) untouched — this renders nothing unless the device is
+ * touch-capable.
*
* Mounted as a sibling of (NOT inside) the xterm element so taps never reach
- * xterm's focus/selection handling. Collapsible and pinned top-right so it can't
- * cover claude's bottom input. Every action gives explicit feedback (mobile
- * clipboard calls fail silently otherwise).
+ * xterm's focus/selection handling. Collapsible and pinned top-right so it
+ * can't cover claude's bottom input. Every action gives explicit feedback
+ * (mobile clipboard calls fail silently otherwise).
*/
export function TerminalMobileControls({
- getTerminal,
+ terminal,
+ refit,
}: TerminalMobileControlsProps) {
const isTouch = useIsTouchDevice();
const [expanded, setExpanded] = useState(true);
const [status, setStatus] = useState(null);
const statusTimer = useRef | null>(null);
+ const { selectMode } = useTerminalMobileState(terminal);
+
useEffect(
() => () => {
if (statusTimer.current) clearTimeout(statusTimer.current);
@@ -48,43 +67,32 @@ export function TerminalMobileControls({
[]
);
- if (!isTouch) return null;
-
- const flash = (msg: string) => {
+ const flash = useCallback((msg: string) => {
setStatus(msg);
if (statusTimer.current) clearTimeout(statusTimer.current);
statusTimer.current = setTimeout(() => setStatus(null), STATUS_MS);
- };
+ }, []);
+
+ // Surface one-shot status messages emitted by the DOM-level installers
+ // (e.g. the three-finger paste gesture) in the same flash pill.
+ useEffect(() => {
+ if (!terminal) return;
+ return subscribeTerminalMobileFlash(terminal, flash);
+ }, [terminal, flash]);
+
+ if (!isTouch) return null;
const handleKeyboard = () => {
- getTerminal()?.focus();
+ terminal?.focus();
};
- const handlePaste = async () => {
- const term = getTerminal();
- if (!term) return;
- // Insecure contexts / some WebViews have no Clipboard API at all — optional
- // chaining would otherwise resolve to undefined and look like "empty".
- if (!navigator.clipboard?.readText) {
- flash('Paste unavailable');
- return;
- }
- try {
- const text = await navigator.clipboard.readText();
- if (!text) {
- flash('Clipboard empty');
- return;
- }
- term.paste(text);
- flash('Pasted');
- } catch {
- flash('Paste blocked');
- }
+ const handlePaste = () => {
+ if (!terminal) return;
+ void pasteIntoTerminal(terminal, flash);
};
const handleCopy = async () => {
- const term = getTerminal();
- if (!term) return;
+ if (!terminal) return;
// Guard the write API up front so we never flash "Copied" without copying.
if (!navigator.clipboard?.writeText) {
flash('Copy unavailable');
@@ -92,12 +100,12 @@ export function TerminalMobileControls({
}
let text: string;
let label: string;
- if (term.hasSelection()) {
- text = term.getSelection();
+ if (terminal.hasSelection()) {
+ text = terminal.getSelection();
label = 'Copied selection';
} else {
- const buf = term.buffer.active;
- text = extractViewportText(buf, buf.viewportY, term.rows);
+ const buf = terminal.buffer.active;
+ text = extractViewportText(buf, buf.viewportY, terminal.rows);
label = 'Copied screen';
}
if (!text) {
@@ -112,6 +120,28 @@ export function TerminalMobileControls({
}
};
+ const handleSelectMode = () => {
+ if (!terminal) return;
+ const next = !getTerminalMobileState(terminal).selectMode;
+ patchTerminalMobileState(terminal, { selectMode: next });
+ if (!next) terminal.clearSelection();
+ flash(next ? 'Select mode — drag to select' : 'Select mode off');
+ };
+
+ const stepFontSize = (delta: number) => {
+ if (!terminal) return;
+ const current = terminal.options.fontSize ?? TERMINAL_DEFAULT_FONT_SIZE;
+ const next = clampTerminalFontSize(current + delta);
+ if (next === current) {
+ flash(`Font ${current}px (limit)`);
+ return;
+ }
+ terminal.options.fontSize = next;
+ saveMobileTerminalFontSize(next);
+ refit();
+ flash(`Font ${next}px`);
+ };
+
const actions = [
{ label: 'Copy from terminal', Icon: CopyIcon, onClick: handleCopy },
{
@@ -146,18 +176,50 @@ export function TerminalMobileControls({
{status}
)}
- {expanded &&
- actions.map(({ label, Icon, onClick }) => (
+ {expanded && (
+ <>
+ {actions.map(({ label, Icon, onClick }) => (
+
+
+
+ ))}
+
+
+
stepFontSize(-1)}
>
-
+ A−
- ))}
+ stepFontSize(1)}
+ >
+ A+
+
+ >
+ )}
(null);
const terminalRef = useRef(null);
const fitAddonRef = useRef(null);
+ // State mirror of terminalRef for children that must re-render/subscribe
+ // when the live terminal (re)attaches (key bar latch highlight, controls).
+ const [liveTerminal, setLiveTerminal] = useState(null);
const {
registerTerminalInstance,
getTerminalInstance,
@@ -102,7 +121,11 @@ export function XTermInstance({
} else {
terminal = new Terminal({
cursorBlink: true,
- fontSize: 12,
+ // Touch devices restore the persisted A−/A+ stepper choice; desktop
+ // keeps the fixed default.
+ fontSize: isTouchDevice()
+ ? loadMobileTerminalFontSize()
+ : TERMINAL_DEFAULT_FONT_SIZE,
fontFamily: '"IBM Plex Mono", monospace',
theme: getTerminalTheme(),
});
@@ -150,9 +173,29 @@ export function XTermInstance({
registerTerminalInstance(tabId, terminal, fitAddon);
+ // Sticky Ctrl (mobile key bar): a latched ctrl turns the next single
+ // TYPED character into its control code. Provenance rules: pastes are
+ // bracketed by the isTerminalPasting marker (all app paste paths go
+ // through pasteTextIntoTerminal; xterm's paste() emits synchronously) —
+ // clipboard text is never transformed. Terminal query replies (DSR/DA)
+ // are always multi-char and pass through untouched. Everything else
+ // that arrives as a single character is a keystroke — including Android
+ // IME commits, which xterm delivers via composition WITHOUT a key
+ // event, so an onKey-based flag would leave the latch dead on Gboard.
+ // The latch stays armed until a keystroke consumes it.
terminal.onData((data) => {
const conn = getTerminalConnection(tabId);
- conn?.send(data);
+ if (!conn) return;
+ if (
+ data.length === 1 &&
+ !isTerminalPasting(terminal) &&
+ getTerminalMobileState(terminal).ctrlLatched
+ ) {
+ patchTerminalMobileState(terminal, { ctrlLatched: false });
+ conn.send(applyStickyCtrl(data).out);
+ return;
+ }
+ conn.send(data);
});
// Windows-console clipboard ergonomics. Selecting text copies it
@@ -161,6 +204,11 @@ export function XTermInstance({
// terminal and live exactly as long as it does (the listener dies with
// the element on dispose, the selection hook with the terminal).
terminal.onSelectionChange(() => {
+ // Touch select mode fires a selection change per touchmove — copying
+ // here would hammer the clipboard with intermediate selections (and
+ // destroy whatever the user had copied the moment a drag starts).
+ // That mode copies exactly once on release (terminalTouchSelection).
+ if (getTerminalMobileState(terminal).selectMode) return;
const text = terminal.getSelection();
if (text) {
void navigator.clipboard?.writeText(text).catch(() => {
@@ -182,7 +230,8 @@ export function XTermInstance({
void navigator.clipboard
?.readText()
.then((text) => {
- if (text) terminal.paste(text);
+ // Marker-carrying paste — never sticky-Ctrl-transformed.
+ if (text) pasteTextIntoTerminal(terminal, text);
})
.catch(() => {
// Paste permission denied — Ctrl+V still works via the
@@ -191,17 +240,19 @@ export function XTermInstance({
}
});
- // Mobile/touch: bridge vertical swipes to the SAME wheel events xterm
- // already handles for a desktop mouse wheel (see terminalTouchScroll).
- // Attached once on the freshly created element and intentionally NOT
- // removed in the mount cleanup below — like the listeners above it lives
- // with the element and tears down on terminal.dispose(). Removing it per
- // unmount would leave reattached terminals without mobile scrolling.
- installTerminalTouchScroll(terminal);
+ // All touch layers (gestures → selection → scroll bridge) in the one
+ // valid order — see terminalTouchLayers for the ordering invariant and
+ // lifetime rules. Attached once per created terminal; NOT removed in
+ // the mount cleanup below — the listeners live and die with the
+ // element on terminal.dispose(), like the handlers above.
+ installTerminalTouchLayers(terminal, (data) =>
+ getTerminalConnection(tabId)?.send(data)
+ );
}
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
+ setLiveTerminal(terminal);
// Ensure a backend connection exists for this tab — also on the reattach
// path, so a tab whose connection died (e.g. reconnect gave up, server
@@ -225,11 +276,24 @@ export function XTermInstance({
}
return () => {
+ // A D-pad gesture running right now would never see its touchend once
+ // the element leaves the DOM — stop its repeat timer and release the
+ // scroll-bridge suppression before detaching. Modes also reset: a
+ // select mode or armed ctrl latch silently surviving a pane
+ // close/reopen (the terminal persists in the registry) would leave
+ // swipes selecting instead of scrolling, or fire a control code
+ // minutes later, with no visible cue at remount.
+ cancelActiveTerminalGesture(terminal);
+ patchTerminalMobileState(terminal, {
+ selectMode: false,
+ ctrlLatched: false,
+ });
if (terminal.element && terminal.element.parentNode) {
terminal.element.parentNode.removeChild(terminal.element);
}
terminalRef.current = null;
fitAddonRef.current = null;
+ setLiveTerminal(null);
};
}, [
tabId,
@@ -262,17 +326,30 @@ export function XTermInstance({
if (isActive) terminalRef.current?.focus();
}, [isActive]);
+ const sendKey = useCallback(
+ (data: string) => {
+ getTerminalConnection(tabId)?.send(data);
+ },
+ [tabId, getTerminalConnection]
+ );
+
return (
// The padding ring is painted terminal-black (not the app surface color)
// so the always-dark terminal doesn't sit in a light frame on the light
// theme.
-
-
terminalRef.current} />
+
+ {/* Touch-only hotkey row (renders null off-touch). Sits at the pane's
+ bottom edge — the visual-viewport sizing keeps that edge above the
+ on-screen keyboard, Termius-style. */}
+
);
}
diff --git a/packages/web-core/src/shared/components/ui-new/containers/SharedAppLayout.tsx b/packages/web-core/src/shared/components/ui-new/containers/SharedAppLayout.tsx
index e36fe2e955..2f811f53a2 100644
--- a/packages/web-core/src/shared/components/ui-new/containers/SharedAppLayout.tsx
+++ b/packages/web-core/src/shared/components/ui-new/containers/SharedAppLayout.tsx
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { Outlet } from '@tanstack/react-router';
import { SyncErrorProvider } from '@/shared/providers/SyncErrorProvider';
import { useIsMobile } from '@/shared/hooks/useIsMobile';
+import { useVisualViewportHeight } from '@/shared/hooks/useVisualViewportHeight';
import { useUiPreferencesStore } from '@/shared/stores/useUiPreferencesStore';
import { cn } from '@/shared/lib/utils';
@@ -17,6 +18,7 @@ import { WorkspacesSidebarReopenTag } from '@vibe/ui/components/WorkspacesSideba
export function SharedAppLayout() {
const currentDestination = useCurrentAppDestination();
const isMobile = useIsMobile();
+ const visualViewportHeight = useVisualViewportHeight();
const mobileFontScale = useUiPreferencesStore((s) => s.mobileFontScale);
const isLeftSidebarVisible = useUiPreferencesStore(
(s) => s.isLeftSidebarVisible
@@ -57,6 +59,20 @@ export function SharedAppLayout() {
? 'flex fixed inset-0 pb-[env(safe-area-inset-bottom)]'
: 'grid grid-rows-[auto_1fr] h-screen'
)}
+ style={
+ // Track the VISUAL viewport so the app (and the terminal's input
+ // line) ends above the on-screen keyboard instead of underneath it —
+ // iOS never shrinks the layout viewport (see useVisualViewportHeight).
+ // Applied on BOTH layout branches: the hook is non-null only on
+ // touch devices, which includes tablets wide enough for the desktop
+ // layout (their on-screen keyboard covers the terminal all the
+ // same). The explicit height wins over `bottom: 0` / `h-screen`;
+ // when the hook has no value (no touch / no visualViewport /
+ // pinch-zoomed) the classes alone keep the pre-existing behavior.
+ visualViewportHeight !== null
+ ? { height: visualViewportHeight, bottom: 'auto' }
+ : undefined
+ }
>
{!isMobile && (
<>
diff --git a/packages/web-core/src/shared/hooks/useIsMobile.ts b/packages/web-core/src/shared/hooks/useIsMobile.ts
index 460ed6e20e..e77740b7ee 100644
--- a/packages/web-core/src/shared/hooks/useIsMobile.ts
+++ b/packages/web-core/src/shared/hooks/useIsMobile.ts
@@ -62,12 +62,19 @@ export function useIsRealMobile(): boolean {
* iPadOS with a desktop UA and hybrid touch laptops. Capability doesn't change
* within a session, so it is computed once.
*/
+let touchDeviceCache: boolean | null = null;
+
export function isTouchDevice(): boolean {
if (typeof window === 'undefined') return false;
- const coarsePointer =
- typeof window.matchMedia === 'function' &&
- window.matchMedia('(pointer: coarse)').matches;
- return coarsePointer || navigator.maxTouchPoints > 0;
+ // Capability is session-constant, and this now runs on hot paths (store
+ // snapshots, per-viewport-event) — evaluate the media query once.
+ if (touchDeviceCache === null) {
+ const coarsePointer =
+ typeof window.matchMedia === 'function' &&
+ window.matchMedia('(pointer: coarse)').matches;
+ touchDeviceCache = coarsePointer || navigator.maxTouchPoints > 0;
+ }
+ return touchDeviceCache;
}
// No-op subscribe: touch capability doesn't change within a session, so the
diff --git a/packages/web-core/src/shared/hooks/useVisualViewportHeight.ts b/packages/web-core/src/shared/hooks/useVisualViewportHeight.ts
new file mode 100644
index 0000000000..2f698982d4
--- /dev/null
+++ b/packages/web-core/src/shared/hooks/useVisualViewportHeight.ts
@@ -0,0 +1,98 @@
+import { useSyncExternalStore } from 'react';
+
+import { isTouchDevice } from './useIsMobile';
+
+/**
+ * Visible-viewport height tracking for the on-screen keyboard.
+ *
+ * iOS Safari never shrinks the layout viewport when the keyboard opens — a
+ * `fixed inset-0` app keeps its full height and the keyboard just covers the
+ * bottom half (including the terminal's input line). The only reliable signal
+ * is `window.visualViewport`, so this store mirrors its height (and pins the
+ * window scroll back to 0 when iOS pans the page on focus).
+ *
+ * Android Chrome resizes the layout viewport itself when the viewport meta
+ * carries `interactive-widget=resizes-content`; there the visual height equals
+ * the layout height and applying it is a no-op.
+ *
+ * Pinch-zoom is NOT the keyboard: when `visualViewport.scale > 1` the height
+ * shrink and the offset pan are the user zooming, so the store reports null
+ * (layout falls back to its full-size classes) and never fights the pan.
+ */
+
+type Listener = () => void;
+
+/** Above this scale the viewport change is user pinch-zoom, not a keyboard. */
+const ZOOM_SCALE_EPSILON = 1.01;
+
+let listeners: Listener[] = [];
+let height: number | null = null;
+let attached = false;
+let notifyFrame: number | null = null;
+
+function readHeight(): number | null {
+ const vv = window.visualViewport;
+ if (!vv) return null;
+ if (vv.scale > ZOOM_SCALE_EPSILON) return null;
+ // Round down so the container never overflows the visible area by a
+ // sub-pixel (which would put the terminal's last row behind the keyboard).
+ return Math.floor(vv.height);
+}
+
+function onViewportChange() {
+ const vv = window.visualViewport;
+ if (!vv) return;
+ // iOS pans the visual viewport to reveal a focused input near the bottom;
+ // with the app sized to the visible area there is nothing to reveal, so
+ // undo the pan to keep the app chrome pinned to the top. Never at zoom —
+ // an offset while zoomed is the user panning around their pinch-zoom.
+ if (vv.offsetTop > 0 && vv.scale <= ZOOM_SCALE_EPSILON) {
+ window.scrollTo(0, 0);
+ }
+ const next = readHeight();
+ if (next === height) return;
+ height = next;
+ // Coalesce the keyboard-animation event burst to one notify per frame —
+ // this store re-renders the app shell, so per-event renders are real cost.
+ if (notifyFrame !== null) return;
+ notifyFrame = requestAnimationFrame(() => {
+ notifyFrame = null;
+ for (const l of [...listeners]) l();
+ });
+}
+
+function subscribe(listener: Listener) {
+ listeners.push(listener);
+ const vv = window.visualViewport;
+ // Never attach on non-touch devices — the hook always snapshots null there,
+ // so listening would only risk desktop side effects for no consumer.
+ // (Touch capability is session-constant; see isTouchDevice.)
+ if (vv && !attached && isTouchDevice()) {
+ attached = true;
+ height = readHeight();
+ vv.addEventListener('resize', onViewportChange);
+ vv.addEventListener('scroll', onViewportChange);
+ }
+ return () => {
+ listeners = listeners.filter((l) => l !== listener);
+ // Listeners on visualViewport stay attached for the session — the store is
+ // module-level and the events are cheap; detaching/reattaching on every
+ // subscriber churn buys nothing.
+ };
+}
+
+function getSnapshot(): number | null {
+ if (!isTouchDevice()) return null;
+ if (height === null) height = readHeight();
+ return height;
+}
+
+/**
+ * Current visual viewport height in px on touch devices, or `null` when the
+ * device isn't touch-capable, `visualViewport` is unavailable, or the user is
+ * pinch-zoomed — callers fall back to their normal (layout-viewport) sizing
+ * on `null`.
+ */
+export function useVisualViewportHeight(): number | null {
+ return useSyncExternalStore(subscribe, getSnapshot, () => null);
+}
diff --git a/packages/web-core/src/shared/lib/terminalFontSize.test.ts b/packages/web-core/src/shared/lib/terminalFontSize.test.ts
new file mode 100644
index 0000000000..8167e04204
--- /dev/null
+++ b/packages/web-core/src/shared/lib/terminalFontSize.test.ts
@@ -0,0 +1,31 @@
+import { describe, it, expect } from 'vitest';
+
+import {
+ clampTerminalFontSize,
+ TERMINAL_DEFAULT_FONT_SIZE,
+ TERMINAL_MAX_FONT_SIZE,
+ TERMINAL_MIN_FONT_SIZE,
+} from './terminalFontSize';
+
+describe('clampTerminalFontSize', () => {
+ it('keeps values inside the range', () => {
+ expect(clampTerminalFontSize(12)).toBe(12);
+ expect(clampTerminalFontSize(TERMINAL_MIN_FONT_SIZE)).toBe(
+ TERMINAL_MIN_FONT_SIZE
+ );
+ expect(clampTerminalFontSize(TERMINAL_MAX_FONT_SIZE)).toBe(
+ TERMINAL_MAX_FONT_SIZE
+ );
+ });
+
+ it('clamps out-of-range values', () => {
+ expect(clampTerminalFontSize(2)).toBe(TERMINAL_MIN_FONT_SIZE);
+ expect(clampTerminalFontSize(99)).toBe(TERMINAL_MAX_FONT_SIZE);
+ });
+
+ it('rounds and falls back on garbage', () => {
+ expect(clampTerminalFontSize(13.6)).toBe(14);
+ expect(clampTerminalFontSize(NaN)).toBe(TERMINAL_DEFAULT_FONT_SIZE);
+ expect(clampTerminalFontSize(Infinity)).toBe(TERMINAL_DEFAULT_FONT_SIZE);
+ });
+});
diff --git a/packages/web-core/src/shared/lib/terminalFontSize.ts b/packages/web-core/src/shared/lib/terminalFontSize.ts
new file mode 100644
index 0000000000..c17c2b6fee
--- /dev/null
+++ b/packages/web-core/src/shared/lib/terminalFontSize.ts
@@ -0,0 +1,41 @@
+/**
+ * Mobile terminal font-size preference (A− / A+ steppers in the mobile
+ * controls). Desktop keeps the hardcoded default — the preference is only
+ * read on touch devices.
+ */
+
+export const TERMINAL_DEFAULT_FONT_SIZE = 12;
+export const TERMINAL_MIN_FONT_SIZE = 8;
+export const TERMINAL_MAX_FONT_SIZE = 20;
+
+const STORAGE_KEY = 'vk-terminal-mobile-font-size';
+
+export function clampTerminalFontSize(size: number): number {
+ if (!Number.isFinite(size)) return TERMINAL_DEFAULT_FONT_SIZE;
+ return Math.min(
+ Math.max(Math.round(size), TERMINAL_MIN_FONT_SIZE),
+ TERMINAL_MAX_FONT_SIZE
+ );
+}
+
+export function loadMobileTerminalFontSize(): number {
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (raw === null) return TERMINAL_DEFAULT_FONT_SIZE;
+ return clampTerminalFontSize(Number(raw));
+ } catch {
+ // Storage can be unavailable (private mode, blocked) — use the default.
+ return TERMINAL_DEFAULT_FONT_SIZE;
+ }
+}
+
+export function saveMobileTerminalFontSize(size: number): void {
+ try {
+ window.localStorage.setItem(
+ STORAGE_KEY,
+ String(clampTerminalFontSize(size))
+ );
+ } catch {
+ // Best-effort persistence only.
+ }
+}
diff --git a/packages/web-core/src/shared/lib/terminalKeySequences.test.ts b/packages/web-core/src/shared/lib/terminalKeySequences.test.ts
new file mode 100644
index 0000000000..53d604e6ee
--- /dev/null
+++ b/packages/web-core/src/shared/lib/terminalKeySequences.test.ts
@@ -0,0 +1,88 @@
+import { describe, it, expect } from 'vitest';
+
+import {
+ applyStickyCtrl,
+ keySequence,
+ toCtrlChar,
+} from './terminalKeySequences';
+
+describe('keySequence', () => {
+ it('returns fixed sequences for non-arrow keys regardless of cursor mode', () => {
+ for (const app of [false, true]) {
+ expect(keySequence('esc', app)).toBe('\x1b');
+ expect(keySequence('tab', app)).toBe('\t');
+ expect(keySequence('shift-tab', app)).toBe('\x1b[Z');
+ expect(keySequence('ctrl-c', app)).toBe('\x03');
+ expect(keySequence('enter', app)).toBe('\r');
+ }
+ });
+
+ it('uses CSI arrows in normal cursor mode', () => {
+ expect(keySequence('up', false)).toBe('\x1b[A');
+ expect(keySequence('down', false)).toBe('\x1b[B');
+ expect(keySequence('right', false)).toBe('\x1b[C');
+ expect(keySequence('left', false)).toBe('\x1b[D');
+ });
+
+ it('uses SS3 arrows in application cursor mode (claude/tmux)', () => {
+ expect(keySequence('up', true)).toBe('\x1bOA');
+ expect(keySequence('down', true)).toBe('\x1bOB');
+ expect(keySequence('right', true)).toBe('\x1bOC');
+ expect(keySequence('left', true)).toBe('\x1bOD');
+ });
+});
+
+describe('toCtrlChar', () => {
+ it('maps letters case-insensitively to control codes', () => {
+ expect(toCtrlChar('c')).toBe('\x03');
+ expect(toCtrlChar('C')).toBe('\x03');
+ expect(toCtrlChar('a')).toBe('\x01');
+ expect(toCtrlChar('z')).toBe('\x1a');
+ });
+
+ it('maps the classic punctuation combos', () => {
+ expect(toCtrlChar('@')).toBe('\x00');
+ expect(toCtrlChar('[')).toBe('\x1b');
+ expect(toCtrlChar('\\')).toBe('\x1c');
+ expect(toCtrlChar(']')).toBe('\x1d');
+ expect(toCtrlChar('^')).toBe('\x1e');
+ expect(toCtrlChar('_')).toBe('\x1f');
+ expect(toCtrlChar(' ')).toBe('\x00');
+ expect(toCtrlChar('?')).toBe('\x7f');
+ });
+
+ it('returns null for keys without a control code', () => {
+ expect(toCtrlChar('1')).toBeNull();
+ expect(toCtrlChar('.')).toBeNull();
+ expect(toCtrlChar('ab')).toBeNull();
+ expect(toCtrlChar('')).toBeNull();
+ });
+});
+
+describe('applyStickyCtrl', () => {
+ it('transforms a single mappable character', () => {
+ expect(applyStickyCtrl('c')).toEqual({ out: '\x03', applied: true });
+ expect(applyStickyCtrl('D')).toEqual({ out: '\x04', applied: true });
+ });
+
+ it('passes through single unmappable characters', () => {
+ expect(applyStickyCtrl('1')).toEqual({ out: '1', applied: false });
+ });
+
+ it('passes through multi-char bursts (paste, IME, escape sequences)', () => {
+ expect(applyStickyCtrl('hello')).toEqual({ out: 'hello', applied: false });
+ expect(applyStickyCtrl('\x1b[A')).toEqual({
+ out: '\x1b[A',
+ applied: false,
+ });
+ });
+});
+
+describe('toCtrlChar — non-ASCII safety (council round 1)', () => {
+ it('never maps non-ASCII letters (toUpperCase would turn ß into SS → ^S)', () => {
+ expect(toCtrlChar('ß')).toBeNull();
+ expect(toCtrlChar('é')).toBeNull();
+ expect(toCtrlChar('ñ')).toBeNull();
+ expect(toCtrlChar('~')).toBeNull();
+ });
+});
diff --git a/packages/web-core/src/shared/lib/terminalKeySequences.ts b/packages/web-core/src/shared/lib/terminalKeySequences.ts
new file mode 100644
index 0000000000..a645f34716
--- /dev/null
+++ b/packages/web-core/src/shared/lib/terminalKeySequences.ts
@@ -0,0 +1,93 @@
+/**
+ * Escape sequences for the mobile terminal key bar and gesture layer.
+ *
+ * Everything here is pure so the sequence choices (especially the
+ * application-cursor-keys split and the sticky-Ctrl transform) stay
+ * unit-testable without a live terminal.
+ */
+
+export type BarKey =
+ | 'esc'
+ | 'tab'
+ | 'shift-tab'
+ | 'ctrl-c'
+ | 'enter'
+ | 'up'
+ | 'down'
+ | 'left'
+ | 'right';
+
+export type ArrowKey = 'up' | 'down' | 'left' | 'right';
+
+const ARROW_FINAL: Record = {
+ up: 'A',
+ down: 'B',
+ right: 'C',
+ left: 'D',
+};
+
+/**
+ * Sequence for one key-bar key. `applicationCursorKeys` mirrors DECCKM
+ * (xterm's `terminal.modes.applicationCursorKeysMode`): full-screen apps like
+ * claude/tmux set it and expect SS3 (`ESC O A`) arrows; the normal shell line
+ * expects CSI (`ESC [ A`).
+ */
+export function keySequence(
+ key: BarKey,
+ applicationCursorKeys: boolean
+): string {
+ switch (key) {
+ case 'esc':
+ return '\x1b';
+ case 'tab':
+ return '\t';
+ case 'shift-tab':
+ return '\x1b[Z';
+ case 'ctrl-c':
+ return '\x03';
+ case 'enter':
+ return '\r';
+ default:
+ return (applicationCursorKeys ? '\x1bO' : '\x1b[') + ARROW_FINAL[key];
+ }
+}
+
+/**
+ * Control character for a printable key, or null when the combo has no
+ * control code (digits, most punctuation). Mirrors what a hardware Ctrl+key
+ * produces: letters map case-insensitively (^C = 0x03), plus the classic
+ * `@[\]^_?` and space mappings.
+ */
+export function toCtrlChar(ch: string): string | null {
+ if (ch.length !== 1) return null;
+ if (ch === ' ') return '\x00';
+ if (ch === '?') return '\x7f';
+ // ASCII-only: String.toUpperCase would map ß→SS (→ ^S, XOFF freeze!) and
+ // other locale surprises, so uppercase by code point arithmetic instead.
+ let code = ch.charCodeAt(0);
+ if (code >= 0x61 && code <= 0x7a) code -= 0x20; // a-z → A-Z
+ // @ A-Z [ \ ] ^ _ → 0x00-0x1f
+ if (code >= 0x40 && code <= 0x5f) return String.fromCharCode(code & 0x1f);
+ return null;
+}
+
+export interface StickyCtrlResult {
+ /** Data to forward to the PTY. */
+ out: string;
+ /** Whether the latch consumed this chunk (transformed it). */
+ applied: boolean;
+}
+
+/**
+ * Apply a latched Ctrl to the next input chunk. Only a single printable
+ * character is transformed — multi-char bursts (paste, IME commits, escape
+ * sequences) pass through untouched. Either way the caller clears the latch:
+ * sticky Ctrl arms exactly one keystroke, like Termius.
+ */
+export function applyStickyCtrl(data: string): StickyCtrlResult {
+ if (data.length === 1) {
+ const ctrl = toCtrlChar(data);
+ if (ctrl !== null) return { out: ctrl, applied: true };
+ }
+ return { out: data, applied: false };
+}
diff --git a/packages/web-core/src/shared/lib/terminalMobileState.test.ts b/packages/web-core/src/shared/lib/terminalMobileState.test.ts
new file mode 100644
index 0000000000..1f97681011
--- /dev/null
+++ b/packages/web-core/src/shared/lib/terminalMobileState.test.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect, vi } from 'vitest';
+import type { Terminal } from '@xterm/xterm';
+
+import {
+ flashTerminalMobileStatus,
+ getTerminalMobileState,
+ patchTerminalMobileState,
+ subscribeTerminalMobileFlash,
+ subscribeTerminalMobileState,
+} from './terminalMobileState';
+
+// The store only uses the terminal as a WeakMap key.
+const term = () => ({}) as Terminal;
+
+describe('terminal mobile state store', () => {
+ it('starts with everything off', () => {
+ expect(getTerminalMobileState(term())).toEqual({
+ ctrlLatched: false,
+ selectMode: false,
+ dpadActive: false,
+ });
+ });
+
+ it('patches change the snapshot identity only on real transitions', () => {
+ const t = term();
+ const before = getTerminalMobileState(t);
+ patchTerminalMobileState(t, { ctrlLatched: false }); // no-op
+ expect(getTerminalMobileState(t)).toBe(before);
+ patchTerminalMobileState(t, { ctrlLatched: true });
+ const after = getTerminalMobileState(t);
+ expect(after).not.toBe(before);
+ expect(after.ctrlLatched).toBe(true);
+ });
+
+ it('notifies subscribers on transitions and not on no-ops', () => {
+ const t = term();
+ const cb = vi.fn();
+ const unsubscribe = subscribeTerminalMobileState(t, cb);
+ patchTerminalMobileState(t, { selectMode: false }); // no-op
+ expect(cb).not.toHaveBeenCalled();
+ patchTerminalMobileState(t, { selectMode: true });
+ expect(cb).toHaveBeenCalledTimes(1);
+ unsubscribe();
+ patchTerminalMobileState(t, { selectMode: false });
+ expect(cb).toHaveBeenCalledTimes(1);
+ });
+
+ it('isolates state between terminals', () => {
+ const a = term();
+ const b = term();
+ patchTerminalMobileState(a, { dpadActive: true });
+ expect(getTerminalMobileState(a).dpadActive).toBe(true);
+ expect(getTerminalMobileState(b).dpadActive).toBe(false);
+ });
+});
+
+describe('flash channel (events, not state)', () => {
+ it('delivers messages to live listeners only — nothing is retained', () => {
+ const t = term();
+ const early = vi.fn();
+ flashTerminalMobileStatus(t, 'lost'); // no listener yet — dropped
+ const unsubscribe = subscribeTerminalMobileFlash(t, early);
+ expect(early).not.toHaveBeenCalled(); // no replay of 'lost'
+ flashTerminalMobileStatus(t, 'Pasted');
+ expect(early).toHaveBeenCalledWith('Pasted');
+ unsubscribe();
+ flashTerminalMobileStatus(t, 'after');
+ expect(early).toHaveBeenCalledTimes(1);
+ });
+
+ it('repeated identical messages each notify', () => {
+ const t = term();
+ const cb = vi.fn();
+ subscribeTerminalMobileFlash(t, cb);
+ flashTerminalMobileStatus(t, 'Pasted');
+ flashTerminalMobileStatus(t, 'Pasted');
+ expect(cb).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/web-core/src/shared/lib/terminalMobileState.ts b/packages/web-core/src/shared/lib/terminalMobileState.ts
new file mode 100644
index 0000000000..2fb8badd52
--- /dev/null
+++ b/packages/web-core/src/shared/lib/terminalMobileState.ts
@@ -0,0 +1,124 @@
+import { useCallback, useSyncExternalStore } from 'react';
+import type { Terminal } from '@xterm/xterm';
+
+/**
+ * Per-terminal mutable state shared between the React layer (key bar, mobile
+ * controls) and the DOM-level installers (touch scroll, gestures, selection).
+ *
+ * Why a WeakMap keyed by the Terminal and not React state/refs: terminals
+ * outlive their React components (XTermInstance re-mounts reattach a live
+ * terminal from the provider registry), while the touch installers and the
+ * onData pipe are wired ONCE at terminal creation. Anything created per mount
+ * (a ref, a closure) would go stale on reattach; the WeakMap gives both sides
+ * the same state object for the terminal's whole life and collects with it.
+ */
+export interface TerminalMobileState {
+ /** Sticky Ctrl is latched; the next single typed character becomes a control code. */
+ ctrlLatched: boolean;
+ /** Select mode: touch drag selects text; gestures + scroll bridge stand down. */
+ selectMode: boolean;
+ /** A long-press D-pad gesture is running; the scroll bridge stands down. */
+ dpadActive: boolean;
+}
+
+const EMPTY_STATE: TerminalMobileState = {
+ ctrlLatched: false,
+ selectMode: false,
+ dpadActive: false,
+};
+
+interface Entry {
+ state: TerminalMobileState;
+ listeners: Set<() => void>;
+ /** One-shot status listeners (flash pill) — events, not retained state. */
+ flashListeners: Set<(message: string) => void>;
+}
+
+const entries = new WeakMap();
+
+function entryFor(terminal: Terminal): Entry {
+ let entry = entries.get(terminal);
+ if (!entry) {
+ entry = {
+ state: { ...EMPTY_STATE },
+ listeners: new Set(),
+ flashListeners: new Set(),
+ };
+ entries.set(terminal, entry);
+ }
+ return entry;
+}
+
+export function getTerminalMobileState(
+ terminal: Terminal
+): Readonly {
+ return entryFor(terminal).state;
+}
+
+export function patchTerminalMobileState(
+ terminal: Terminal,
+ patch: Partial
+): void {
+ const entry = entryFor(terminal);
+ const changed = (Object.keys(patch) as (keyof TerminalMobileState)[]).some(
+ (key) => patch[key] !== undefined && entry.state[key] !== patch[key]
+ );
+ if (!changed) return;
+ // Snapshot object identity changes only on real transitions so React's
+ // useSyncExternalStore consumers don't re-render on no-op patches.
+ entry.state = { ...entry.state, ...patch };
+ for (const listener of [...entry.listeners]) listener();
+}
+
+export function subscribeTerminalMobileState(
+ terminal: Terminal,
+ listener: () => void
+): () => void {
+ const entry = entryFor(terminal);
+ entry.listeners.add(listener);
+ return () => entry.listeners.delete(listener);
+}
+
+/**
+ * One-shot status message from a DOM-level installer (e.g. the paste gesture)
+ * to whichever mobile controls are currently mounted. Deliberately an EVENT,
+ * not a state field: retained flash state would replay a stale "Pasted" pill
+ * on every remount against a long-lived terminal.
+ */
+export function flashTerminalMobileStatus(
+ terminal: Terminal,
+ message: string
+): void {
+ for (const listener of [...entryFor(terminal).flashListeners]) {
+ listener(message);
+ }
+}
+
+export function subscribeTerminalMobileFlash(
+ terminal: Terminal,
+ listener: (message: string) => void
+): () => void {
+ const entry = entryFor(terminal);
+ entry.flashListeners.add(listener);
+ return () => entry.flashListeners.delete(listener);
+}
+
+/**
+ * React view of a terminal's mobile state. Snapshot identity only changes on
+ * real transitions (see patchTerminalMobileState), so consumers can safely
+ * destructure per-field without extra memoization.
+ */
+export function useTerminalMobileState(
+ terminal: Terminal | null
+): Readonly {
+ const subscribe = useCallback(
+ (cb: () => void) =>
+ terminal ? subscribeTerminalMobileState(terminal, cb) : () => {},
+ [terminal]
+ );
+ return useSyncExternalStore(
+ subscribe,
+ () => (terminal ? getTerminalMobileState(terminal) : EMPTY_STATE),
+ () => EMPTY_STATE
+ );
+}
diff --git a/packages/web-core/src/shared/lib/terminalPaste.ts b/packages/web-core/src/shared/lib/terminalPaste.ts
new file mode 100644
index 0000000000..5ae0087b51
--- /dev/null
+++ b/packages/web-core/src/shared/lib/terminalPaste.ts
@@ -0,0 +1,52 @@
+import type { Terminal } from '@xterm/xterm';
+
+// Terminals with a paste currently flowing through their onData pipe.
+// xterm's paste() emits data synchronously, so the marker brackets exactly
+// the paste's chunks — the sticky-Ctrl transform checks it to keep clipboard
+// text from ever being turned into control codes (a 1-char unbracketed paste
+// is indistinguishable from a keystroke by shape alone).
+const pasting = new WeakSet();
+
+export function isTerminalPasting(terminal: Terminal): boolean {
+ return pasting.has(terminal);
+}
+
+/** The ONLY way app code should paste into a terminal — carries the marker. */
+export function pasteTextIntoTerminal(terminal: Terminal, text: string): void {
+ pasting.add(terminal);
+ try {
+ terminal.paste(text);
+ } finally {
+ pasting.delete(terminal);
+ }
+}
+
+/**
+ * Guarded clipboard→terminal paste with explicit user feedback — the single
+ * implementation behind both the Paste button and the three-finger gesture.
+ * A silent clipboard read would be a pastejacking aid, so every outcome
+ * reports: unavailable (no Clipboard API — insecure context/WebView), empty,
+ * blocked (permission denied), or pasted.
+ */
+export async function pasteIntoTerminal(
+ terminal: Terminal,
+ notify: (message: string) => void
+): Promise {
+ // Optional chaining alone would resolve to undefined on insecure contexts
+ // and look like an empty clipboard — report "unavailable" distinctly.
+ if (!navigator.clipboard?.readText) {
+ notify('Paste unavailable');
+ return;
+ }
+ try {
+ const text = await navigator.clipboard.readText();
+ if (!text) {
+ notify('Clipboard empty');
+ return;
+ }
+ pasteTextIntoTerminal(terminal, text);
+ notify('Pasted');
+ } catch {
+ notify('Paste blocked');
+ }
+}
diff --git a/packages/web-core/src/shared/lib/terminalTouchGestures.test.ts b/packages/web-core/src/shared/lib/terminalTouchGestures.test.ts
new file mode 100644
index 0000000000..044d380986
--- /dev/null
+++ b/packages/web-core/src/shared/lib/terminalTouchGestures.test.ts
@@ -0,0 +1,321 @@
+import { describe, it, expect } from 'vitest';
+
+import type { ArrowKey } from './terminalKeySequences';
+import {
+ createTouchGestureController,
+ dpadDirection,
+ dpadInterval,
+ DOUBLE_TAP_MS,
+ DPAD_DEAD_ZONE_PX,
+ DPAD_TIERS,
+ LONG_PRESS_MS,
+ TAP_SLOP_PX,
+} from './terminalTouchGestures';
+
+function harness({ enabled = true } = {}) {
+ const arrows: ArrowKey[] = [];
+ const events: string[] = [];
+ const dpad: Array<{ active: boolean; dir: ArrowKey | null }> = [];
+ const ctrl = createTouchGestureController({
+ isEnabled: () => enabled,
+ sendArrow: (d) => {
+ arrows.push(d);
+ events.push(`arrow:${d}`);
+ },
+ sendTab: () => events.push('tab'),
+ paste: () => events.push('paste'),
+ setDpad: (active, dir) => dpad.push({ active, dir }),
+ });
+ return { ctrl, arrows, events, dpad };
+}
+
+const p = (x: number, y: number, touches = 1) => ({
+ touches,
+ clientX: x,
+ clientY: y,
+});
+
+/** Drive timers exactly as the adapter would: run onTimer at nextTimerAt. */
+function runTimersUntil(
+ ctrl: ReturnType,
+ until: number
+) {
+ for (;;) {
+ const at = ctrl.nextTimerAt();
+ if (at === null || at > until) return;
+ ctrl.onTimer(at);
+ }
+}
+
+describe('dpadDirection', () => {
+ it('is null inside the dead zone', () => {
+ expect(dpadDirection(DPAD_DEAD_ZONE_PX - 1, 0)).toBeNull();
+ expect(dpadDirection(0, -(DPAD_DEAD_ZONE_PX - 1))).toBeNull();
+ });
+
+ it('picks the dominant axis', () => {
+ expect(dpadDirection(40, 10)).toBe('right');
+ expect(dpadDirection(-40, 10)).toBe('left');
+ expect(dpadDirection(10, 40)).toBe('down');
+ expect(dpadDirection(10, -40)).toBe('up');
+ });
+});
+
+describe('dpadInterval', () => {
+ it('maps distance to the three tiers', () => {
+ expect(dpadInterval(20)).toBe(DPAD_TIERS[0].interval);
+ expect(dpadInterval(DPAD_TIERS[0].dist)).toBe(DPAD_TIERS[1].interval);
+ expect(dpadInterval(DPAD_TIERS[1].dist + 100)).toBe(DPAD_TIERS[2].interval);
+ });
+});
+
+describe('long-press D-pad', () => {
+ it('promotes to D-pad after the long-press delay without movement', () => {
+ const { ctrl, dpad } = harness();
+ ctrl.onTouchStart(p(100, 100), 0);
+ expect(ctrl.nextTimerAt()).toBe(LONG_PRESS_MS);
+ runTimersUntil(ctrl, LONG_PRESS_MS);
+ expect(dpad).toEqual([{ active: true, dir: null }]);
+ });
+
+ it('sends an arrow immediately on engaging a direction, then repeats', () => {
+ const { ctrl, arrows } = harness();
+ ctrl.onTouchStart(p(100, 100), 0);
+ runTimersUntil(ctrl, LONG_PRESS_MS);
+ // Drag down past the dead zone → immediate arrow.
+ ctrl.onTouchMove(
+ p(100, 100 + DPAD_DEAD_ZONE_PX + 5, 1),
+ LONG_PRESS_MS + 10
+ );
+ expect(arrows).toEqual(['down']);
+ // Repeats fire on the timer at tier-1 cadence.
+ runTimersUntil(ctrl, LONG_PRESS_MS + 10 + 2 * DPAD_TIERS[0].interval);
+ expect(arrows).toEqual(['down', 'down', 'down']);
+ });
+
+ it('speeds up in higher tiers and pauses in the dead zone', () => {
+ const { ctrl, arrows } = harness();
+ ctrl.onTouchStart(p(100, 100), 0);
+ runTimersUntil(ctrl, LONG_PRESS_MS);
+ const t0 = LONG_PRESS_MS + 10;
+ ctrl.onTouchMove(p(100, 100 + DPAD_TIERS[1].dist + 20), t0);
+ expect(arrows).toEqual(['down']);
+ // Fastest tier cadence.
+ runTimersUntil(ctrl, t0 + DPAD_TIERS[2].interval);
+ expect(arrows).toEqual(['down', 'down']);
+ // Back inside the dead zone → repeats stop.
+ ctrl.onTouchMove(p(100, 102), t0 + DPAD_TIERS[2].interval + 1);
+ expect(ctrl.nextTimerAt()).toBeNull();
+ });
+
+ it('fires immediately again on direction change', () => {
+ const { ctrl, arrows } = harness();
+ ctrl.onTouchStart(p(100, 100), 0);
+ runTimersUntil(ctrl, LONG_PRESS_MS);
+ ctrl.onTouchMove(p(100, 140), LONG_PRESS_MS + 5);
+ ctrl.onTouchMove(p(160, 100), LONG_PRESS_MS + 20);
+ expect(arrows).toEqual(['down', 'right']);
+ });
+
+ it('prevents default while in D-pad mode', () => {
+ const { ctrl } = harness();
+ ctrl.onTouchStart(p(100, 100), 0);
+ runTimersUntil(ctrl, LONG_PRESS_MS);
+ expect(ctrl.onTouchMove(p(100, 103), LONG_PRESS_MS + 5).prevent).toBe(true);
+ });
+
+ it('releases the D-pad when the finger lifts', () => {
+ const { ctrl, dpad } = harness();
+ ctrl.onTouchStart(p(100, 100), 0);
+ runTimersUntil(ctrl, LONG_PRESS_MS);
+ ctrl.onTouchEnd(0, LONG_PRESS_MS + 50);
+ expect(dpad.at(-1)).toEqual({ active: false, dir: null });
+ expect(ctrl.nextTimerAt()).toBeNull();
+ });
+
+ it('cancels the D-pad when a second finger lands', () => {
+ const { ctrl, dpad } = harness();
+ ctrl.onTouchStart(p(100, 100), 0);
+ runTimersUntil(ctrl, LONG_PRESS_MS);
+ ctrl.onTouchStart(p(120, 100, 2), LONG_PRESS_MS + 10);
+ expect(dpad.at(-1)).toEqual({ active: false, dir: null });
+ });
+});
+
+describe('scroll handoff', () => {
+ it('stands down when the finger moves before the long-press delay', () => {
+ const { ctrl, dpad, events } = harness();
+ ctrl.onTouchStart(p(100, 100), 0);
+ const r = ctrl.onTouchMove(p(100, 100 + TAP_SLOP_PX + 5), 50);
+ expect(r.prevent).toBe(false);
+ expect(ctrl.nextTimerAt()).toBeNull(); // no long-press pending anymore
+ runTimersUntil(ctrl, 10_000);
+ ctrl.onTouchEnd(0, 120);
+ expect(dpad).toEqual([]);
+ expect(events).toEqual([]);
+ });
+});
+
+describe('double-tap = Tab', () => {
+ it('sends Tab on two quick taps in place', () => {
+ const { ctrl, events } = harness();
+ ctrl.onTouchStart(p(100, 100), 0);
+ ctrl.onTouchEnd(0, 40);
+ ctrl.onTouchStart(p(103, 98), 120);
+ ctrl.onTouchEnd(0, 160);
+ expect(events).toEqual(['tab']);
+ });
+
+ it('does not fire when the taps are too far apart in time or space', () => {
+ const { ctrl, events } = harness();
+ ctrl.onTouchStart(p(100, 100), 0);
+ ctrl.onTouchEnd(0, 40);
+ ctrl.onTouchStart(p(100, 100), 40 + DOUBLE_TAP_MS + 50);
+ ctrl.onTouchEnd(0, 40 + DOUBLE_TAP_MS + 90);
+ ctrl.onTouchStart(p(300, 300), 40 + DOUBLE_TAP_MS + 200);
+ ctrl.onTouchEnd(0, 40 + DOUBLE_TAP_MS + 240);
+ expect(events).toEqual([]);
+ });
+
+ it('a triple tap yields exactly one Tab', () => {
+ const { ctrl, events } = harness();
+ for (const [start, end] of [
+ [0, 30],
+ [100, 130],
+ [200, 230],
+ ] as const) {
+ ctrl.onTouchStart(p(100, 100), start);
+ ctrl.onTouchEnd(0, end);
+ }
+ expect(events).toEqual(['tab']);
+ });
+
+ it('a long-press is not a tap', () => {
+ const { ctrl, events } = harness();
+ ctrl.onTouchStart(p(100, 100), 0);
+ runTimersUntil(ctrl, LONG_PRESS_MS);
+ ctrl.onTouchEnd(0, LONG_PRESS_MS + 20);
+ ctrl.onTouchStart(p(100, 100), LONG_PRESS_MS + 60);
+ ctrl.onTouchEnd(0, LONG_PRESS_MS + 90);
+ expect(events).toEqual([]);
+ });
+});
+
+describe('three-finger tap = paste', () => {
+ it('pastes when three fingers tap quickly', () => {
+ const { ctrl, events } = harness();
+ ctrl.onTouchStart(p(100, 100, 1), 0);
+ ctrl.onTouchStart(p(120, 100, 2), 10);
+ ctrl.onTouchStart(p(140, 100, 3), 20);
+ ctrl.onTouchEnd(2, 80);
+ ctrl.onTouchEnd(1, 90);
+ ctrl.onTouchEnd(0, 100);
+ expect(events).toEqual(['paste']);
+ });
+
+ it('does not paste on a two-finger gesture (pinch)', () => {
+ const { ctrl, events } = harness();
+ ctrl.onTouchStart(p(100, 100, 1), 0);
+ ctrl.onTouchStart(p(120, 100, 2), 10);
+ ctrl.onTouchEnd(1, 80);
+ ctrl.onTouchEnd(0, 90);
+ expect(events).toEqual([]);
+ });
+
+ it('does not paste when the fingers dwell too long', () => {
+ const { ctrl, events } = harness();
+ ctrl.onTouchStart(p(100, 100, 1), 0);
+ ctrl.onTouchStart(p(120, 100, 2), 10);
+ ctrl.onTouchStart(p(140, 100, 3), 20);
+ ctrl.onTouchEnd(0, 2_000);
+ expect(events).toEqual([]);
+ });
+});
+
+describe('select mode (disabled layer)', () => {
+ it('ignores the whole touch sequence when disabled', () => {
+ const { ctrl, events, dpad } = harness({ enabled: false });
+ ctrl.onTouchStart(p(100, 100), 0);
+ runTimersUntil(ctrl, 10_000);
+ ctrl.onTouchEnd(0, 40);
+ ctrl.onTouchStart(p(100, 100), 100);
+ ctrl.onTouchEnd(0, 140);
+ expect(events).toEqual([]);
+ expect(dpad).toEqual([]);
+ });
+});
+
+describe('timer starvation + cancellation (council round 1)', () => {
+ it('promotes to D-pad inside onTouchMove when the timer never fired', () => {
+ const { ctrl, arrows, dpad } = harness();
+ ctrl.onTouchStart(p(100, 100), 0);
+ // No onTimer call at all — simulates a starved main thread. First move
+ // arrives well past the long-press threshold.
+ const r = ctrl.onTouchMove(
+ p(100, 100 + DPAD_DEAD_ZONE_PX + 10),
+ LONG_PRESS_MS + 200
+ );
+ expect(r.prevent).toBe(true);
+ expect(dpad[0]).toEqual({ active: true, dir: null });
+ expect(arrows).toEqual(['down']);
+ });
+
+ it('still hands an early move to the scroll bridge', () => {
+ const { ctrl, dpad } = harness();
+ ctrl.onTouchStart(p(100, 100), 0);
+ const r = ctrl.onTouchMove(p(100, 160), LONG_PRESS_MS - 50);
+ expect(r.prevent).toBe(false);
+ expect(dpad).toEqual([]);
+ });
+
+ it('cancel() releases an active D-pad and stops repeats', () => {
+ const { ctrl, dpad } = harness();
+ ctrl.onTouchStart(p(100, 100), 0);
+ runTimersUntil(ctrl, LONG_PRESS_MS);
+ ctrl.onTouchMove(p(100, 150), LONG_PRESS_MS + 10);
+ ctrl.cancel();
+ expect(dpad.at(-1)).toEqual({ active: false, dir: null });
+ expect(ctrl.nextTimerAt()).toBeNull();
+ // A stray touchend after cancel is a no-op.
+ ctrl.onTouchEnd(0, LONG_PRESS_MS + 100);
+ expect(dpad.at(-1)).toEqual({ active: false, dir: null });
+ });
+});
+
+describe('coalesced multi-touch + cancellation semantics (review round 1)', () => {
+ it('three-finger tap works when all fingers land in ONE touchstart', () => {
+ const { ctrl, events } = harness();
+ // Previous gesture long ago — pressAt must not leak from it.
+ ctrl.onTouchStart(p(50, 50), 0);
+ ctrl.onTouchEnd(0, 30);
+ ctrl.onTouchStart(p(100, 100, 3), 10_000);
+ ctrl.onTouchEnd(0, 10_080);
+ expect(events).toEqual(['paste']);
+ });
+
+ it('coalesced multi-start respects the select-mode gate', () => {
+ const { ctrl, events } = harness({ enabled: false });
+ ctrl.onTouchStart(p(100, 100, 3), 0);
+ ctrl.onTouchEnd(0, 80);
+ expect(events).toEqual([]);
+ });
+
+ it('a travelling three-finger gesture (system swipe) does not paste', () => {
+ const { ctrl, events } = harness();
+ ctrl.onTouchStart(p(100, 100, 3), 0);
+ ctrl.onTouchMove(p(100 + 3 * TAP_SLOP_PX, 100, 3), 40);
+ ctrl.onTouchEnd(0, 90);
+ expect(events).toEqual([]);
+ });
+
+ it('cancel() wipes double-tap history — a cancelled tap is not tap #1', () => {
+ const { ctrl, events } = harness();
+ ctrl.onTouchStart(p(100, 100), 0);
+ ctrl.onTouchEnd(0, 30); // clean tap #1
+ ctrl.onTouchStart(p(100, 100), 100);
+ ctrl.cancel(); // browser claimed the second touch
+ ctrl.onTouchStart(p(100, 100), 200);
+ ctrl.onTouchEnd(0, 230);
+ expect(events).toEqual([]); // cancelled sequence must not complete a pair
+ });
+});
diff --git a/packages/web-core/src/shared/lib/terminalTouchGestures.ts b/packages/web-core/src/shared/lib/terminalTouchGestures.ts
new file mode 100644
index 0000000000..ddb85962d1
--- /dev/null
+++ b/packages/web-core/src/shared/lib/terminalTouchGestures.ts
@@ -0,0 +1,412 @@
+import type { Terminal } from '@xterm/xterm';
+
+import type { ArrowKey } from './terminalKeySequences';
+import {
+ getTerminalMobileState,
+ patchTerminalMobileState,
+} from './terminalMobileState';
+
+/**
+ * Touch gesture layer for the terminal (Termius-inspired):
+ *
+ * - long-press, then drag → arrow-key D-pad with 3 speed tiers
+ * - double-tap → Tab (autocomplete)
+ * - three-finger tap → paste
+ *
+ * Coexists with the touch→wheel scroll bridge (terminalTouchScroll): a finger
+ * that MOVES before the long-press delay is a scroll and this controller
+ * stands down; a finger that DWELLS first enters D-pad mode, sets
+ * `dpadActive` in the shared mobile state, and the scroll bridge stands down
+ * (it checks `isSuppressed`). Select mode disables the whole layer.
+ *
+ * `createTouchGestureController` is pure and time-injected (every method takes
+ * `now`; scheduling is pull-based via `nextTimerAt`) — the unit-tested seam.
+ * `installTerminalTouchGestures` binds real events and one timer to it, using
+ * event timestamps (not handler-delivery time) so a busy main thread can't
+ * misclassify a fast swipe as a dwell, and the monotonic clock so NTP steps
+ * can't stall repeats mid-gesture.
+ */
+
+export const LONG_PRESS_MS = 350;
+export const TAP_SLOP_PX = 12;
+export const DOUBLE_TAP_MS = 300;
+/** Drag distance (px) from the press origin before arrows start firing. */
+export const DPAD_DEAD_ZONE_PX = 14;
+/** Max age (ms) of a touch sequence still counting as a three-finger tap. */
+export const MULTI_TAP_MS = 500;
+
+/** Speed tiers: drag distance (px) → repeat interval (ms). Ordered. */
+export const DPAD_TIERS: ReadonlyArray<{ dist: number; interval: number }> = [
+ { dist: 90, interval: 130 },
+ { dist: 160, interval: 60 },
+ { dist: Infinity, interval: 28 },
+];
+
+export function dpadDirection(
+ dx: number,
+ dy: number,
+ deadZone = DPAD_DEAD_ZONE_PX
+): ArrowKey | null {
+ if (Math.max(Math.abs(dx), Math.abs(dy)) < deadZone) return null;
+ if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? 'right' : 'left';
+ return dy > 0 ? 'down' : 'up';
+}
+
+export function dpadInterval(distance: number): number {
+ for (const tier of DPAD_TIERS) {
+ if (distance < tier.dist) return tier.interval;
+ }
+ return DPAD_TIERS[DPAD_TIERS.length - 1].interval;
+}
+
+export interface GesturePoint {
+ touches: number;
+ clientX: number;
+ clientY: number;
+}
+
+export interface GestureDeps {
+ /** false → the layer is inert for the rest of the touch sequence (select mode). */
+ isEnabled: () => boolean;
+ sendArrow: (dir: ArrowKey) => void;
+ sendTab: () => void;
+ paste: () => void;
+ /** D-pad engaged/released — drives the overlay + scroll-bridge suppression. */
+ setDpad: (active: boolean, dir: ArrowKey | null) => void;
+}
+
+export interface GestureMoveResult {
+ prevent: boolean;
+}
+
+type Phase = 'idle' | 'pressed' | 'dpad' | 'multi' | 'ignored';
+
+export function createTouchGestureController(deps: GestureDeps) {
+ let phase: Phase = 'idle';
+ let startX = 0;
+ let startY = 0;
+ let pressAt = 0;
+ let maxTouches = 0;
+ /** A multi-touch gesture travelled beyond slop — it's a swipe, not a tap. */
+ let multiMoved = false;
+ // D-pad repeat state.
+ let dir: ArrowKey | null = null;
+ let interval = DPAD_TIERS[0].interval;
+ let nextRepeatAt = 0;
+ // Double-tap tracking.
+ let lastTap: { at: number; x: number; y: number } | null = null;
+
+ const exitDpad = () => {
+ if (phase === 'dpad') deps.setDpad(false, null);
+ dir = null;
+ };
+
+ const promoteToDpad = () => {
+ phase = 'dpad';
+ dir = null;
+ deps.setDpad(true, null);
+ };
+
+ return {
+ onTouchStart(p: GesturePoint, now: number): void {
+ if (p.touches === 1) {
+ if (!deps.isEnabled()) {
+ phase = 'ignored';
+ return;
+ }
+ phase = 'pressed';
+ startX = p.clientX;
+ startY = p.clientY;
+ pressAt = now;
+ maxTouches = 1;
+ multiMoved = false;
+ return;
+ }
+ if (phase === 'idle') {
+ // The whole gesture arrived as one multi-finger touchstart (fingers
+ // landing within the same frame — the normal case for a deliberate
+ // three-finger tap). Initialize here or `pressAt` would be stale
+ // from the previous gesture and the select-mode gate skipped.
+ if (!deps.isEnabled()) {
+ phase = 'ignored';
+ return;
+ }
+ phase = 'multi';
+ startX = p.clientX;
+ startY = p.clientY;
+ pressAt = now;
+ maxTouches = p.touches;
+ multiMoved = false;
+ return;
+ }
+ // Additional finger landed mid-gesture. A D-pad in progress is
+ // cancelled; otherwise keep tracking a potential three-finger tap.
+ exitDpad();
+ maxTouches = Math.max(maxTouches, p.touches);
+ phase = phase === 'ignored' ? 'ignored' : 'multi';
+ },
+
+ onTouchMove(p: GesturePoint, now: number): GestureMoveResult {
+ const movedPastSlop =
+ Math.abs(p.clientX - startX) > TAP_SLOP_PX ||
+ Math.abs(p.clientY - startY) > TAP_SLOP_PX;
+
+ if (phase === 'multi') {
+ // A travelling multi-touch is a swipe/pinch (e.g. iPadOS three-finger
+ // system gestures) — it must never count as a three-finger TAP.
+ if (movedPastSlop) multiMoved = true;
+ return { prevent: false };
+ }
+
+ if (phase === 'pressed') {
+ if (now - pressAt >= LONG_PRESS_MS) {
+ // The promotion timer can be starved by a busy main thread (TUI
+ // redraw, keyboard resize). The finger DID dwell long enough
+ // (`now` is the event's own timestamp, so a late-DELIVERED fast
+ // swipe doesn't land here) — promote before the slop check, or
+ // this move would hand a held press to the scroll bridge.
+ promoteToDpad();
+ // fall through to D-pad handling of this same move
+ } else {
+ if (movedPastSlop) {
+ // Moving before the long-press delay = scroll; the bridge owns it.
+ phase = 'ignored';
+ }
+ return { prevent: false };
+ }
+ }
+ if (phase !== 'dpad') return { prevent: false };
+
+ const dx = p.clientX - startX;
+ const dy = p.clientY - startY;
+ const nextDir = dpadDirection(dx, dy);
+ const distance = Math.max(Math.abs(dx), Math.abs(dy));
+ interval = dpadInterval(distance);
+ if (nextDir !== dir) {
+ dir = nextDir;
+ if (dir) {
+ // Fire immediately on engage/direction change; repeats follow.
+ deps.sendArrow(dir);
+ nextRepeatAt = now + interval;
+ }
+ deps.setDpad(true, dir);
+ }
+ // The finger is ours while in D-pad mode — never let it scroll the page.
+ return { prevent: true };
+ },
+
+ onTouchEnd(remainingTouches: number, now: number): void {
+ if (remainingTouches > 0) return; // wait for the last finger
+ const wasPhase = phase;
+ const touchCount = maxTouches;
+ const wasMultiMoved = multiMoved;
+ exitDpad();
+ phase = 'idle';
+ maxTouches = 0;
+ multiMoved = false;
+
+ if (wasPhase === 'ignored' || wasPhase === 'dpad') {
+ lastTap = null;
+ return;
+ }
+ if (touchCount > 1) {
+ if (
+ touchCount === 3 &&
+ !wasMultiMoved &&
+ now - pressAt <= MULTI_TAP_MS
+ ) {
+ deps.paste();
+ }
+ lastTap = null;
+ return;
+ }
+ if (wasPhase !== 'pressed' || now - pressAt >= LONG_PRESS_MS) {
+ lastTap = null;
+ return;
+ }
+ // A clean quick tap. Second one in time + place = Tab.
+ if (
+ lastTap &&
+ now - lastTap.at <= DOUBLE_TAP_MS &&
+ Math.abs(startX - lastTap.x) <= 2 * TAP_SLOP_PX &&
+ Math.abs(startY - lastTap.y) <= 2 * TAP_SLOP_PX
+ ) {
+ deps.sendTab();
+ lastTap = null; // a triple tap is not two Tabs
+ return;
+ }
+ lastTap = { at: now, x: startX, y: startY };
+ },
+
+ /** Run due work: long-press promotion and D-pad key repeats. */
+ onTimer(now: number): void {
+ if (phase === 'pressed' && now - pressAt >= LONG_PRESS_MS) {
+ promoteToDpad();
+ return;
+ }
+ if (phase === 'dpad' && dir && now >= nextRepeatAt) {
+ deps.sendArrow(dir);
+ nextRepeatAt = now + interval;
+ }
+ },
+
+ /** When the adapter should call onTimer next; null = no timer needed. */
+ nextTimerAt(): number | null {
+ if (phase === 'pressed') return pressAt + LONG_PRESS_MS;
+ if (phase === 'dpad' && dir) return nextRepeatAt;
+ return null;
+ },
+
+ /**
+ * Abort whatever is in flight (touchcancel, or React detach mid-gesture:
+ * touchend may never arrive once the element leaves the DOM). Releases
+ * the D-pad — clearing the scroll-bridge suppression — and stops key
+ * repeats. Also the touchcancel path: a cancelled sequence must never
+ * count as a tap or three-finger paste.
+ */
+ cancel(): void {
+ exitDpad();
+ phase = 'idle';
+ maxTouches = 0;
+ multiMoved = false;
+ lastTap = null;
+ },
+ };
+}
+
+export interface InstallGestureOptions {
+ sendArrow: (dir: ArrowKey) => void;
+ sendTab: () => void;
+ paste: () => void;
+}
+
+// Live gesture cancellers, one per terminal. React unmount cleanup calls
+// cancelActiveTerminalGesture so a D-pad running at detach time can't keep
+// its repeat timer (and the scroll-bridge suppression) alive.
+const activeGestureCancels = new WeakMap void>();
+
+export function cancelActiveTerminalGesture(terminal: Terminal): void {
+ activeGestureCancels.get(terminal)?.();
+}
+
+/**
+ * Bind the gesture controller to a live terminal element. Like the scroll
+ * bridge: attach once per created terminal; listeners live and die with the
+ * element, so no unmount cleanup is registered by the caller. Returns a
+ * disposer for tests.
+ */
+export function installTerminalTouchGestures(
+ terminal: Terminal,
+ options: InstallGestureOptions
+): () => void {
+ const el = terminal.element;
+ if (!el) return () => {};
+
+ // Minimal D-pad overlay: a centered badge showing the active direction.
+ // Plain arrows/plus only — fancier key-glyph codepoints render as tofu on
+ // Android/Linux (same reason the key bar uses phosphor icons).
+ const overlay = document.createElement('div');
+ overlay.className = 'xterm-dpad-overlay';
+ overlay.style.cssText =
+ 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);' +
+ 'z-index:20;display:none;padding:10px 14px;border-radius:9999px;' +
+ 'background:rgba(0,0,0,0.55);color:#fff;font-size:20px;line-height:1;' +
+ 'pointer-events:none;user-select:none;';
+ overlay.textContent = '+';
+ el.appendChild(overlay);
+
+ const DIR_GLYPH: Record = {
+ up: '↑',
+ down: '↓',
+ left: '←',
+ right: '→',
+ };
+
+ let timer: ReturnType | null = null;
+ let timerTargetAt: number | null = null;
+
+ const controller = createTouchGestureController({
+ isEnabled: () => !getTerminalMobileState(terminal).selectMode,
+ sendArrow: options.sendArrow,
+ sendTab: options.sendTab,
+ paste: options.paste,
+ setDpad: (active, dirNow) => {
+ patchTerminalMobileState(terminal, { dpadActive: active });
+ overlay.style.display = active ? 'block' : 'none';
+ overlay.textContent = dirNow ? DIR_GLYPH[dirNow] : '+';
+ },
+ });
+
+ const reschedule = () => {
+ const at = controller.nextTimerAt();
+ if (at === timerTargetAt) return; // same deadline — keep the timer
+ if (timer) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ timerTargetAt = at;
+ if (at === null) return;
+ timer = setTimeout(
+ () => {
+ timerTargetAt = null;
+ controller.onTimer(performance.now());
+ reschedule();
+ },
+ Math.max(0, at - performance.now())
+ );
+ };
+
+ const toPoint = (e: TouchEvent): GesturePoint => {
+ const t = e.touches[0] ?? e.changedTouches[0];
+ return {
+ touches: e.touches.length,
+ clientX: t?.clientX ?? 0,
+ clientY: t?.clientY ?? 0,
+ };
+ };
+
+ // e.timeStamp shares performance.now()'s monotonic origin and carries the
+ // moment the touch actually happened — not when a busy main thread got
+ // around to delivering it.
+ const onStart = (e: TouchEvent) => {
+ controller.onTouchStart(toPoint(e), e.timeStamp);
+ reschedule();
+ };
+ const onMove = (e: TouchEvent) => {
+ if (
+ controller.onTouchMove(toPoint(e), e.timeStamp).prevent &&
+ e.cancelable
+ ) {
+ e.preventDefault();
+ }
+ reschedule();
+ };
+ const onEnd = (e: TouchEvent) => {
+ controller.onTouchEnd(e.touches.length, e.timeStamp);
+ reschedule();
+ };
+ const onCancel = () => {
+ // A cancelled sequence (system gesture, palm rejection, browser
+ // interruption) must not fire tap/paste actions — and may never deliver
+ // another event for the remaining fingers, so don't wait for them.
+ controller.cancel();
+ reschedule();
+ };
+
+ el.addEventListener('touchstart', onStart, { passive: true });
+ el.addEventListener('touchmove', onMove, { passive: false });
+ el.addEventListener('touchend', onEnd, { passive: true });
+ el.addEventListener('touchcancel', onCancel, { passive: true });
+
+ activeGestureCancels.set(terminal, onCancel);
+
+ return () => {
+ if (timer) clearTimeout(timer);
+ activeGestureCancels.delete(terminal);
+ el.removeEventListener('touchstart', onStart);
+ el.removeEventListener('touchmove', onMove);
+ el.removeEventListener('touchend', onEnd);
+ el.removeEventListener('touchcancel', onCancel);
+ overlay.remove();
+ };
+}
diff --git a/packages/web-core/src/shared/lib/terminalTouchLayers.ts b/packages/web-core/src/shared/lib/terminalTouchLayers.ts
new file mode 100644
index 0000000000..7691426004
--- /dev/null
+++ b/packages/web-core/src/shared/lib/terminalTouchLayers.ts
@@ -0,0 +1,88 @@
+import type { Terminal } from '@xterm/xterm';
+
+import { isTouchDevice } from '@/shared/hooks/useIsMobile';
+import { keySequence } from './terminalKeySequences';
+import {
+ flashTerminalMobileStatus,
+ getTerminalMobileState,
+ patchTerminalMobileState,
+} from './terminalMobileState';
+import { pasteIntoTerminal, pasteTextIntoTerminal } from './terminalPaste';
+import { installTerminalTouchGestures } from './terminalTouchGestures';
+import { installTerminalTouchScroll } from './terminalTouchScroll';
+import { installTerminalTouchSelection } from './terminalTouchSelection';
+
+/**
+ * Install every touch layer for a freshly created terminal, in the one order
+ * that is correct — this function exists to make the ordering invariant
+ * impossible to violate from a component:
+ *
+ * 1. gestures, 2. selection, 3. scroll bridge (LAST)
+ *
+ * Listener order is attach order, so when a starved long-press promotes to
+ * D-pad inside a touchmove, the suppression flag is already set by the time
+ * the scroll bridge sees that same event. Attach the bridge first and every
+ * starved promotion leaks wheel steps to the PTY.
+ *
+ * Lifetime: attach once per created terminal; listeners live and die with
+ * `terminal.element` (an in-flight D-pad is additionally cancellable via
+ * cancelActiveTerminalGesture on React detach). Gestures/selection are gated
+ * on touch capability so non-touch sessions carry zero new listeners; the
+ * scroll bridge stays unconditional exactly as PR #22 shipped it (inert
+ * without touch events).
+ */
+export function installTerminalTouchLayers(
+ terminal: Terminal,
+ sendRaw: (data: string) => void
+): void {
+ if (isTouchDevice()) {
+ // A gesture-sent key counts as the ctrl latch's "one keystroke", same as
+ // a key-bar tap — otherwise ctrl, D-pad arrow, then a typed letter would
+ // surprise-fire a control combo.
+ const sendKey = (data: string) => {
+ sendRaw(data);
+ if (getTerminalMobileState(terminal).ctrlLatched) {
+ patchTerminalMobileState(terminal, { ctrlLatched: false });
+ }
+ };
+ installTerminalTouchGestures(terminal, {
+ sendArrow: (dir) =>
+ sendKey(keySequence(dir, terminal.modes.applicationCursorKeysMode)),
+ sendTab: () => sendKey('\t'),
+ paste: () =>
+ void pasteIntoTerminal(terminal, (message) =>
+ flashTerminalMobileStatus(terminal, message)
+ ),
+ });
+ installTerminalTouchSelection(terminal);
+
+ // Keyboard-open ergonomics: focusing the terminal on a touch device pops
+ // the system keyboard — jump to the prompt so it isn't hidden in
+ // scrollback while the viewport shrinks around it.
+ terminal.textarea?.addEventListener('focus', () =>
+ terminal.scrollToBottom()
+ );
+
+ // Route the OS paste callout / hardware Cmd+V through the provenance
+ // marker too — xterm's internal textarea paste handler would otherwise
+ // emit onData unmarked, and a 1-char clipboard while Ctrl is latched
+ // would be synthesized into a control code. Capture-phase on the ROOT
+ // element: at the textarea itself listeners run in registration order
+ // and xterm's (registered at open()) would fire first — both handlers
+ // running would paste twice. Ancestor capture runs before the target,
+ // and stopPropagation keeps the event from xterm's handler entirely.
+ // Touch-only: the latch can only be armed here, and desktop keeps
+ // xterm's native paste path.
+ terminal.element?.addEventListener(
+ 'paste',
+ (e) => {
+ const text = e.clipboardData?.getData('text');
+ e.preventDefault();
+ e.stopPropagation();
+ if (text) pasteTextIntoTerminal(terminal, text);
+ },
+ { capture: true }
+ );
+ }
+ installTerminalTouchScroll(terminal);
+}
diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts
index 60023c0b94..9a909d0720 100644
--- a/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts
+++ b/packages/web-core/src/shared/lib/terminalTouchScroll.test.ts
@@ -154,3 +154,40 @@ describe('createTouchScrollController (gesture guards)', () => {
expect(wheels).toEqual([-1]); // only the first gesture's single step
});
});
+
+describe('suppression (gesture layer / select mode)', () => {
+ function suppressibleController() {
+ const wheels: Array<1 | -1> = [];
+ let suppressed = false;
+ const ctrl = createTouchScrollController({
+ getMouseTrackingMode: () => 'vt200',
+ dispatchWheel: (dir) => wheels.push(dir),
+ isSuppressed: () => suppressed,
+ });
+ return { ctrl, wheels, setSuppressed: (v: boolean) => (suppressed = v) };
+ }
+
+ it('stands down while suppressed and stays down for the whole sequence', () => {
+ const { ctrl, wheels, setSuppressed } = suppressibleController();
+ ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 });
+ // D-pad engaged after a long press: the bridge must not turn the drag
+ // into wheel scrolling…
+ setSuppressed(true);
+ const r = ctrl.onTouchMove({
+ touches: 1,
+ clientX: 0,
+ clientY: 3 * WHEEL_STEP_PX,
+ });
+ expect(r.prevent).toBe(false);
+ expect(wheels.length).toBe(0);
+ // …even if suppression lifts mid-sequence (finger still down).
+ setSuppressed(false);
+ ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: 6 * WHEEL_STEP_PX });
+ expect(wheels.length).toBe(0);
+ // A fresh gesture after all fingers lift scrolls again.
+ ctrl.onTouchEnd(0);
+ ctrl.onTouchStart({ touches: 1, clientX: 0, clientY: 0 });
+ ctrl.onTouchMove({ touches: 1, clientX: 0, clientY: WHEEL_STEP_PX });
+ expect(wheels).toEqual([-1]);
+ });
+});
diff --git a/packages/web-core/src/shared/lib/terminalTouchScroll.ts b/packages/web-core/src/shared/lib/terminalTouchScroll.ts
index eadb23be78..6ab49cb2cf 100644
--- a/packages/web-core/src/shared/lib/terminalTouchScroll.ts
+++ b/packages/web-core/src/shared/lib/terminalTouchScroll.ts
@@ -1,5 +1,7 @@
import type { Terminal } from '@xterm/xterm';
+import { getTerminalMobileState } from './terminalMobileState';
+
/**
* Touch → wheel scroll bridge for xterm.js 5.5.
*
@@ -69,6 +71,12 @@ export interface TouchScrollDeps {
getMouseTrackingMode: () => string;
/** Dispatch one line-wheel: +1 = scroll down (toward newer), -1 = scroll up. */
dispatchWheel: (direction: 1 | -1, clientX: number, clientY: number) => void;
+ /**
+ * Another touch consumer owns the gesture (D-pad after a long-press, select
+ * mode). Checked per move; once true the bridge stands down for the rest of
+ * the touch sequence.
+ */
+ isSuppressed?: () => boolean;
}
export interface TouchMoveResult {
@@ -109,6 +117,14 @@ export function createTouchScrollController(deps: TouchScrollDeps) {
}
if (axis === 'ignore') return { prevent: false };
+ if (deps.isSuppressed?.()) {
+ // The gesture layer (D-pad) or select mode took this touch sequence —
+ // never turn its drag into wheel scrolling.
+ axis = 'ignore';
+ accumulated = 0;
+ return { prevent: false };
+ }
+
if (axis === 'undecided') {
axis = decideAxis(p.clientX - startX, p.clientY - startY);
if (axis !== 'vertical') return { prevent: false };
@@ -168,6 +184,10 @@ export function installTerminalTouchScroll(terminal: Terminal): () => void {
const controller = createTouchScrollController({
getMouseTrackingMode: () => terminal.modes.mouseTrackingMode,
+ isSuppressed: () => {
+ const state = getTerminalMobileState(terminal);
+ return state.dpadActive || state.selectMode;
+ },
dispatchWheel: (direction, clientX, clientY) => {
const key = `${clientX},${clientY}`;
if (key !== lastKey) {
diff --git a/packages/web-core/src/shared/lib/terminalTouchSelection.test.ts b/packages/web-core/src/shared/lib/terminalTouchSelection.test.ts
new file mode 100644
index 0000000000..16891d126f
--- /dev/null
+++ b/packages/web-core/src/shared/lib/terminalTouchSelection.test.ts
@@ -0,0 +1,62 @@
+import { describe, it, expect } from 'vitest';
+
+import { linearSelection, touchToCell } from './terminalTouchSelection';
+
+const rect = { left: 10, top: 20, width: 800, height: 480 };
+const cols = 80; // cell width 10
+const rows = 24; // cell height 20
+
+describe('touchToCell', () => {
+ it('maps a touch point to its cell', () => {
+ expect(touchToCell(10, 20, rect, cols, rows)).toEqual({ col: 0, row: 0 });
+ expect(touchToCell(25, 45, rect, cols, rows)).toEqual({ col: 1, row: 1 });
+ expect(touchToCell(809, 499, rect, cols, rows)).toEqual({
+ col: 79,
+ row: 23,
+ });
+ });
+
+ it('clamps points outside the screen rect', () => {
+ expect(touchToCell(0, 0, rect, cols, rows)).toEqual({ col: 0, row: 0 });
+ expect(touchToCell(2000, 2000, rect, cols, rows)).toEqual({
+ col: 79,
+ row: 23,
+ });
+ });
+});
+
+describe('linearSelection', () => {
+ it('selects forward within a line (inclusive)', () => {
+ expect(
+ linearSelection({ col: 2, row: 5 }, { col: 6, row: 5 }, cols)
+ ).toEqual({ col: 2, row: 5, length: 5 });
+ });
+
+ it('normalizes a backwards drag', () => {
+ expect(
+ linearSelection({ col: 6, row: 5 }, { col: 2, row: 5 }, cols)
+ ).toEqual({ col: 2, row: 5, length: 5 });
+ });
+
+ it('spans lines', () => {
+ expect(
+ linearSelection({ col: 78, row: 3 }, { col: 1, row: 4 }, cols)
+ ).toEqual({ col: 78, row: 3, length: 4 });
+ });
+
+ it('a single cell has length 1', () => {
+ expect(
+ linearSelection({ col: 4, row: 2 }, { col: 4, row: 2 }, cols)
+ ).toEqual({ col: 4, row: 2, length: 1 });
+ });
+});
+
+describe('touchToCell — degenerate rects (review round 1)', () => {
+ it('returns null instead of NaN cells for zero-sized rects', () => {
+ const zero = { left: 0, top: 0, width: 0, height: 0 };
+ expect(touchToCell(0, 0, zero, cols, rows)).toBeNull();
+ expect(touchToCell(10, 10, { ...rect, width: 0 }, cols, rows)).toBeNull();
+ expect(touchToCell(10, 10, { ...rect, height: 0 }, cols, rows)).toBeNull();
+ expect(touchToCell(10, 10, rect, 0, rows)).toBeNull();
+ });
+});
diff --git a/packages/web-core/src/shared/lib/terminalTouchSelection.ts b/packages/web-core/src/shared/lib/terminalTouchSelection.ts
new file mode 100644
index 0000000000..24897bcd1a
--- /dev/null
+++ b/packages/web-core/src/shared/lib/terminalTouchSelection.ts
@@ -0,0 +1,199 @@
+import type { Terminal } from '@xterm/xterm';
+
+import {
+ flashTerminalMobileStatus,
+ getTerminalMobileState,
+} from './terminalMobileState';
+
+/**
+ * Touch drag-selection for select mode.
+ *
+ * xterm.js has no touch selection at all, and in CLI mode tmux's mouse
+ * tracking would eat synthetic mouse events anyway. So select mode does it
+ * client-side: map touch coordinates to buffer cells and drive
+ * `terminal.select()` directly over the visible buffer. No events are
+ * forwarded to the application — tmux never knows a selection happened.
+ *
+ * Active only while `selectMode` is on (toggled from TerminalMobileControls);
+ * the gesture layer and scroll bridge stand down for the same flag. The
+ * finished selection is copied exactly once on release — the per-change
+ * auto-copy in XTermInstance is suppressed during select mode so a drag
+ * doesn't hammer the clipboard with intermediate selections.
+ *
+ * Listeners are CAPTURE-phase and stop propagation while select mode is on:
+ * xterm's own viewport touch handlers (which scroll local scrollback when
+ * mouse tracking is off — plain shell terminals) live on child elements and
+ * would otherwise scroll the buffer underneath the selection drag.
+ */
+
+export interface CellPoint {
+ col: number;
+ row: number;
+}
+
+/**
+ * Map a touch point to a 0-based cell within the screen rect (clamped).
+ * Returns null for a degenerate rect (hidden/zero-sized pane mid-layout) —
+ * dividing by it would produce NaN cells and corrupt xterm's selection.
+ */
+export function touchToCell(
+ clientX: number,
+ clientY: number,
+ rect: { left: number; top: number; width: number; height: number },
+ cols: number,
+ rows: number
+): CellPoint | null {
+ if (rect.width <= 0 || rect.height <= 0 || cols <= 0 || rows <= 0) {
+ return null;
+ }
+ const cellW = rect.width / cols;
+ const cellH = rect.height / rows;
+ const col = Math.floor((clientX - rect.left) / cellW);
+ const row = Math.floor((clientY - rect.top) / cellH);
+ return {
+ col: Math.min(Math.max(col, 0), cols - 1),
+ row: Math.min(Math.max(row, 0), rows - 1),
+ };
+}
+
+/**
+ * Normalize an anchor→focus drag (absolute buffer rows) into the
+ * `terminal.select(column, row, length)` triple. Inclusive of both endpoints.
+ */
+export function linearSelection(
+ anchor: CellPoint,
+ focus: CellPoint,
+ cols: number
+): { col: number; row: number; length: number } {
+ const a = anchor.row * cols + anchor.col;
+ const b = focus.row * cols + focus.col;
+ const start = Math.min(a, b);
+ const end = Math.max(a, b);
+ return {
+ col: start % cols,
+ row: Math.floor(start / cols),
+ length: end - start + 1,
+ };
+}
+
+/**
+ * Bind select-mode touch handling to a live terminal element. Attach once per
+ * created terminal (creation branch); listeners die with the element on
+ * dispose. Returns a disposer for tests.
+ */
+export function installTerminalTouchSelection(terminal: Terminal): () => void {
+ const el = terminal.element;
+ if (!el) return () => {};
+ const screen =
+ (el.querySelector('.xterm-screen') as HTMLElement | null) ?? el;
+
+ let anchor: CellPoint | null = null;
+ let lastSel: { col: number; row: number; length: number } | null = null;
+
+ const cellFromTouch = (t: {
+ clientX: number;
+ clientY: number;
+ }): CellPoint | null => {
+ const rect = screen.getBoundingClientRect();
+ const cell = touchToCell(
+ t.clientX,
+ t.clientY,
+ rect,
+ terminal.cols,
+ terminal.rows
+ );
+ if (!cell) return null;
+ // Anchor rows to the ABSOLUTE buffer position at gesture time so the
+ // selection targets what the user sees even with scrollback offset.
+ return { col: cell.col, row: cell.row + terminal.buffer.active.viewportY };
+ };
+
+ const onStart = (e: TouchEvent) => {
+ if (!getTerminalMobileState(terminal).selectMode) return;
+ // Select mode owns the touch: keep it from xterm's own viewport touch
+ // scrolling (child listeners) and from browser default scrolling.
+ e.stopPropagation();
+ if (e.cancelable) e.preventDefault();
+ if (e.touches.length !== 1) {
+ // Either a fresh multi-finger gesture (anchor is already null) or a
+ // stray extra finger mid-drag (palm contact) — keep the anchor and
+ // ignore it; the drag resumes when the stray finger lifts.
+ return;
+ }
+ anchor = cellFromTouch(e.touches[0]);
+ lastSel = null;
+ terminal.clearSelection();
+ };
+
+ const onMove = (e: TouchEvent) => {
+ if (!getTerminalMobileState(terminal).selectMode) return;
+ e.stopPropagation();
+ if (e.cancelable) e.preventDefault();
+ if (!anchor || e.touches.length !== 1) return;
+ const focus = cellFromTouch(e.touches[0]);
+ if (!focus) return;
+ const sel = linearSelection(anchor, focus, terminal.cols);
+ // Only touch xterm's selection service when the target cell changed —
+ // touchmove fires at 60-120Hz, cells change far less often.
+ if (
+ lastSel &&
+ lastSel.col === sel.col &&
+ lastSel.row === sel.row &&
+ lastSel.length === sel.length
+ ) {
+ return;
+ }
+ lastSel = sel;
+ terminal.select(sel.col, sel.row, sel.length);
+ };
+
+ const onEnd = (e: TouchEvent) => {
+ if (e.touches.length > 0) return; // wait for the last finger
+ // Copy exactly once, on release. Direct clipboard only — deliberately
+ // NOT writeClipboardViaBridge: its fallback posts the text to
+ // window.parent with targetOrigin '*', and terminal selections can hold
+ // secrets; an untrusted framing page must never receive them. Failure
+ // is surfaced instead (the selection stays — the Copy button retries).
+ if (
+ anchor !== null &&
+ getTerminalMobileState(terminal).selectMode &&
+ terminal.hasSelection()
+ ) {
+ const text = terminal.getSelection();
+ if (text && navigator.clipboard?.writeText) {
+ navigator.clipboard.writeText(text).then(
+ () => flashTerminalMobileStatus(terminal, 'Copied selection'),
+ () => flashTerminalMobileStatus(terminal, 'Copy blocked')
+ );
+ }
+ }
+ anchor = null;
+ lastSel = null;
+ };
+
+ const onCancel = () => {
+ // A cancelled sequence (system gesture, palm rejection) fires nothing —
+ // clobbering the clipboard with a partial selection would be worse than
+ // no copy. The selection stays visible; Copy can still grab it.
+ anchor = null;
+ lastSel = null;
+ };
+
+ el.addEventListener('touchstart', onStart, {
+ passive: false,
+ capture: true,
+ });
+ el.addEventListener('touchmove', onMove, { passive: false, capture: true });
+ el.addEventListener('touchend', onEnd, { passive: true, capture: true });
+ el.addEventListener('touchcancel', onCancel, {
+ passive: true,
+ capture: true,
+ });
+
+ return () => {
+ el.removeEventListener('touchstart', onStart, { capture: true });
+ el.removeEventListener('touchmove', onMove, { capture: true });
+ el.removeEventListener('touchend', onEnd, { capture: true });
+ el.removeEventListener('touchcancel', onCancel, { capture: true });
+ };
+}