feat(mobile): terminal text selection, copy, and paste (#1553)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-07 21:24:05 -04:00 committed by GitHub
parent 49b250a0f3
commit cc14a0ade3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 962 additions and 11 deletions

View File

@ -1,4 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Animated, AppState, type AppStateStatus } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {
View,
Text,
@ -19,9 +21,16 @@ import type { RpcClient } from '../../../../src/transport/rpc-client'
import { loadHosts } from '../../../../src/transport/host-store'
import { useHostClient } from '../../../../src/transport/client-context'
import type { ConnectionState, RpcSuccess } from '../../../../src/transport/types'
import { triggerMediumImpact } from '../../../../src/platform/haptics'
import {
triggerMediumImpact,
triggerSelection,
triggerSuccess,
triggerError,
triggerEdgeBump
} from '../../../../src/platform/haptics'
import {
TerminalWebView,
type TerminalModes,
type TerminalWebViewHandle
} from '../../../../src/terminal/TerminalWebView'
import { StatusDot } from '../../../../src/components/StatusDot'
@ -49,15 +58,22 @@ type TerminalCreateResult = {
type MobileDisplayMode = 'auto' | 'phone' | 'desktop'
type AccessoryKey = { label: string; bytes: string; accessibilityLabel?: string }
type AccessoryKey = {
label: string
bytes: string
accessibilityLabel?: string
repeatable?: boolean
}
const ACCESSORY_KEYS: AccessoryKey[] = [
{ label: 'Esc', bytes: '\x1b' },
{ label: 'Tab', bytes: '\t' },
{ label: '↑', bytes: '\x1b[A' },
{ label: '↓', bytes: '\x1b[B' },
{ label: '←', bytes: '\x1b[D' },
{ label: '→', bytes: '\x1b[C' },
{ label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
{ label: 'Del', bytes: '\x1b[3~', accessibilityLabel: 'Forward delete', repeatable: true },
{ label: '↑', bytes: '\x1b[A', repeatable: true },
{ label: '↓', bytes: '\x1b[B', repeatable: true },
{ label: '←', bytes: '\x1b[D', repeatable: true },
{ label: '→', bytes: '\x1b[C', repeatable: true },
{ label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
{ label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
{ label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
@ -82,12 +98,22 @@ function TerminalPaneView({
handle,
active,
onRef,
onWebReady
onWebReady,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onHaptic
}: {
handle: string
active: boolean
onRef: (handle: string, ref: TerminalWebViewHandle | null) => void
onWebReady: (handle: string) => void
onSelectionMode: (handle: string, active: boolean) => void
onSelectionCopy: (handle: string, text: string) => void
onSelectionEvicted: (handle: string) => void
onModesChanged: (handle: string, modes: TerminalModes) => void
onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
}) {
const setRef = useCallback(
(ref: TerminalWebViewHandle | null) => {
@ -105,6 +131,11 @@ function TerminalPaneView({
ref={setRef}
style={styles.terminalWebView}
onWebReady={() => onWebReady(handle)}
onSelectionMode={(a) => onSelectionMode(handle, a)}
onSelectionCopy={(t) => onSelectionCopy(handle, t)}
onSelectionEvicted={() => onSelectionEvicted(handle)}
onModesChanged={(m) => onModesChanged(handle, m)}
onHaptic={onHaptic}
/>
</View>
)
@ -146,6 +177,14 @@ export default function SessionScreen() {
// Why: server-authoritative display mode per terminal. The runtime is the
// single source of truth — this state is populated from subscribe responses.
const [terminalModes, setTerminalModes] = useState<Map<string, MobileDisplayMode>>(new Map())
const [selectModeActive, setSelectModeActive] = useState(false)
const [canPaste, setCanPaste] = useState(false)
const [toastMessage, setToastMessage] = useState<string | null>(null)
const toastOpacityRef = useRef(new Animated.Value(0))
// Why: WebView pushes terminal modes (bracketed-paste, alt-screen) on every
// change so paste reads a synchronous snapshot — no round-trip required.
const ptyModesRef = useRef<Map<string, TerminalModes>>(new Map())
const initialModesSeenRef = useRef<Set<string>>(new Set())
const deviceTokenRef = useRef<string | null>(null)
const clientRef = useRef<RpcClient | null>(null)
// Why: measured once from TerminalWebView on mount, then passed with every
@ -845,6 +884,174 @@ export default function SessionScreen() {
}
}
// Why: press-and-hold key repeat for keys flagged repeatable (arrows,
// backspace, forward-delete). Matches iOS keyboard cadence: instant first
// fire, then ~400ms before the second, then ~45ms between subsequent
// repeats. Non-repeatable keys (Tab, Esc, Ctrl-*) intentionally fire once
// because holding them is destructive or meaningless.
const repeatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const repeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const stopAccessoryRepeat = useCallback(() => {
if (repeatTimeoutRef.current) {
clearTimeout(repeatTimeoutRef.current)
repeatTimeoutRef.current = null
}
if (repeatIntervalRef.current) {
clearInterval(repeatIntervalRef.current)
repeatIntervalRef.current = null
}
}, [])
const startAccessoryRepeat = useCallback(
(bytes: string) => {
stopAccessoryRepeat()
repeatTimeoutRef.current = setTimeout(() => {
repeatIntervalRef.current = setInterval(() => {
void handleAccessoryKey(bytes)
}, 45)
}, 400)
},
[stopAccessoryRepeat]
)
useEffect(() => {
return () => stopAccessoryRepeat()
}, [stopAccessoryRepeat])
const showToast = useCallback((message: string, durationMs = 1200) => {
setToastMessage(message)
Animated.timing(toastOpacityRef.current, {
toValue: 1,
duration: 150,
useNativeDriver: true
}).start(() => {
setTimeout(() => {
Animated.timing(toastOpacityRef.current, {
toValue: 0,
duration: 200,
useNativeDriver: true
}).start(() => setToastMessage(null))
}, durationMs)
})
}, [])
const handleSelectionMode = useCallback((handle: string, active: boolean) => {
if (handle !== activeHandleRef.current) return
setSelectModeActive(active)
if (active) Keyboard.dismiss()
}, [])
const handleSelectionCopy = useCallback(
async (handle: string, text: string) => {
if (handle !== activeHandleRef.current) return
if (!text || text.length === 0) {
terminalRefs.current.get(handle)?.cancelSelect()
return
}
try {
await Clipboard.setStringAsync(text)
triggerSuccess()
// Why: Android 13+ shows its own system "Copied to clipboard" toast on
// every clipboard write, so our toast would be redundant; iOS shows
// nothing on copy (it only banners on paste), so the in-app toast is
// the only success signal there.
if (Platform.OS === 'ios') showToast('Copied')
terminalRefs.current.get(handle)?.cancelSelect()
} catch (e) {
triggerError()
const err = e as { name?: string; message?: string }
// eslint-disable-next-line no-console
console.warn('[mobile-clip] setString failed', {
name: err.name,
message: err.message
})
showToast("Couldn't copy", 1500)
}
},
[showToast]
)
const handleSelectionEvicted = useCallback(
(handle: string) => {
if (handle !== activeHandleRef.current) return
// eslint-disable-next-line no-console
console.warn('[mobile-clip] selection evicted')
showToast('Selection cleared (scrolled out of buffer)', 1500)
setSelectModeActive(false)
},
[showToast]
)
const handleModesChanged = useCallback((handle: string, modes: TerminalModes) => {
ptyModesRef.current.set(handle, modes)
initialModesSeenRef.current.add(handle)
}, [])
const handleHaptic = useCallback((kind: 'selection' | 'success' | 'error' | 'edge-bump') => {
if (kind === 'selection') triggerSelection()
else if (kind === 'success') triggerSuccess()
else if (kind === 'error') triggerError()
else if (kind === 'edge-bump') triggerEdgeBump()
}, [])
const handlePaste = useCallback(async () => {
if (!client || !activeHandle || !canSend) return
try {
const text = await Clipboard.getStringAsync()
if (text.length === 0) return
const modes = ptyModesRef.current.get(activeHandle) || {
bracketedPasteMode: false,
altScreen: false
}
const wrap = modes.bracketedPasteMode && !modes.altScreen
const payload = wrap ? `\x1b[200~${text}\x1b[201~` : text
const wrappedBytes = new TextEncoder().encode(payload).byteLength
if (wrappedBytes > 256 * 1024) {
triggerError()
// eslint-disable-next-line no-console
console.warn('[mobile-clip] paste oversized', { wrappedBytes })
showToast('Paste too large (max 256 KiB)', 1500)
return
}
await client.sendRequest('terminal.send', {
terminal: activeHandle,
text: payload,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
triggerSelection()
void Clipboard.hasStringAsync().then(setCanPaste)
} catch (e) {
triggerError()
const err = e as { name?: string; message?: string }
const isDisconnected = connState !== 'connected'
// eslint-disable-next-line no-console
console.warn('[mobile-clip] paste failed', { name: err.name, message: err.message })
if (isDisconnected) showToast('Paste failed (disconnected)', 1500)
}
}, [client, activeHandle, canSend, connState, showToast])
// Why: refresh canPaste on mount, AppState active, after paste.
useEffect(() => {
let mounted = true
const refresh = () => {
void Clipboard.hasStringAsync().then((has) => {
if (mounted) setCanPaste(has)
})
}
refresh()
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
if (s === 'active') refresh()
else if (selectModeActive && activeHandleRef.current) {
terminalRefs.current.get(activeHandleRef.current)?.cancelSelect()
}
})
return () => {
mounted = false
sub.remove()
}
}, [selectModeActive])
async function handleCreateTerminal() {
if (!client || creating) return
@ -1078,8 +1285,21 @@ export default function SessionScreen() {
active={terminal.handle === activeHandle}
onRef={setTerminalWebViewRef}
onWebReady={handleTerminalWebReady}
onSelectionMode={handleSelectionMode}
onSelectionCopy={handleSelectionCopy}
onSelectionEvicted={handleSelectionEvicted}
onModesChanged={handleModesChanged}
onHaptic={handleHaptic}
/>
))}
{toastMessage && (
<Animated.View
pointerEvents="none"
style={[styles.toast, { opacity: toastOpacityRef.current }]}
>
<Text style={styles.toastText}>{toastMessage}</Text>
</Animated.View>
)}
</View>
)}
@ -1115,6 +1335,24 @@ export default function SessionScreen() {
<Smartphone size={14} color={canSend ? colors.textSecondary : colors.textMuted} />
)}
</Pressable>
{canPaste && (
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => void handlePaste()}
accessibilityLabel="Paste from clipboard"
>
<Text
style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}
>
Paste
</Text>
</Pressable>
)}
{ACCESSORY_KEYS.map((key) => (
<Pressable
key={key.label}
@ -1124,7 +1362,18 @@ export default function SessionScreen() {
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => void handleAccessoryKey(key.bytes)}
onPressIn={() => {
if (!key.repeatable) return
void handleAccessoryKey(key.bytes)
startAccessoryRepeat(key.bytes)
}}
onPressOut={() => {
if (key.repeatable) stopAccessoryRepeat()
}}
onPress={() => {
if (key.repeatable) return
void handleAccessoryKey(key.bytes)
}}
accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`}
>
<Text
@ -1391,6 +1640,23 @@ const styles = StyleSheet.create({
terminalWebView: {
flex: 1
},
toast: {
position: 'absolute',
bottom: spacing.lg,
alignSelf: 'center',
left: 0,
right: 0,
alignItems: 'center'
},
toastText: {
backgroundColor: 'rgba(20, 22, 39, 0.92)',
color: colors.textPrimary,
fontSize: 13,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.button,
overflow: 'hidden'
},
emptyState: {
flex: 1,
alignItems: 'center',

View File

@ -16,6 +16,7 @@
"expo": "^55.0.23",
"expo-build-properties": "^55.0.13",
"expo-camera": "^55.0.18",
"expo-clipboard": "^55.0.13",
"expo-constants": "^55.0.16",
"expo-crypto": "^55.0.14",
"expo-haptics": "^55.0.14",

View File

@ -20,6 +20,9 @@ importers:
expo-camera:
specifier: ^55.0.18
version: 55.0.18(@types/emscripten@1.41.5)(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-clipboard:
specifier: ^55.0.13
version: 55.0.13(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
expo-constants:
specifier: ^55.0.16
version: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))
@ -3077,6 +3080,13 @@ packages:
react-native-web:
optional: true
expo-clipboard@55.0.13:
resolution: {integrity: sha512-PrOmmuVsGW4bAkNQmGKtxMXj3invsfN+jfIKmQxHwE/dn7ODqwFWviUTa+PMUjP3XZmYCDLyu/i0GLeu7HF9Ew==}
peerDependencies:
expo: '*'
react: '*'
react-native: '*'
expo-constants@55.0.16:
resolution: {integrity: sha512-Z15/No94UHoogD+pulxjudGAeOHTEIWZgb/vnX48Wx5D+apWTeCbnKxQZZtGQlosvduYL5kaic2/W8U+NHfBQQ==}
peerDependencies:
@ -9262,6 +9272,12 @@ snapshots:
transitivePeerDependencies:
- '@types/emscripten'
expo-clipboard@55.0.13(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6):
dependencies:
expo: 55.0.23(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.5(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
react: 19.2.6
react-native: 0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)
expo-constants@55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6)):
dependencies:
'@expo/env': 2.1.2

View File

@ -11,3 +11,35 @@ export function triggerMediumImpact(): void {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
}
}
export function triggerSelection(): void {
if (Platform.OS === 'android') {
void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Gesture_Start).catch(() => {})
} else {
void Haptics.selectionAsync().catch(() => {})
}
}
export function triggerSuccess(): void {
if (Platform.OS === 'android') {
void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Confirm).catch(() => {})
} else {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
}
}
export function triggerError(): void {
if (Platform.OS === 'android') {
void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Reject).catch(() => {})
} else {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {})
}
}
export function triggerEdgeBump(): void {
if (Platform.OS === 'android') {
void Haptics.performAndroidHapticsAsync(Haptics.AndroidHaptics.Clock_Tick).catch(() => {})
} else {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {})
}
}

View File

@ -4,12 +4,27 @@ import { WebView } from 'react-native-webview'
import type { WebViewMessageEvent } from 'react-native-webview'
import { colors } from '../theme/mobile-theme'
export type TerminalModes = {
bracketedPasteMode: boolean
altScreen: boolean
}
export type TerminalSelectionEvents = {
onSelectionMode?: (active: boolean) => void
onSelectionCopy?: (text: string) => void
onSelectionEvicted?: () => void
onModesChanged?: (modes: TerminalModes) => void
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
}
export type TerminalWebViewHandle = {
write: (data: string) => void
init: (cols: number, rows: number, initialData?: string) => void
clear: () => void
measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null>
resetZoom: () => void
cancelSelect: () => void
doSelectAll: () => void
// Why: lets callers await the WebView-side `init` rAF chain (term.open
// → renderService population → first paint) so a follow-up measure
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
@ -20,7 +35,7 @@ export type TerminalWebViewHandle = {
type Props = {
style?: StyleProp<ViewStyle>
onWebReady?: () => void
}
} & TerminalSelectionEvents
type TerminalMessage =
| { type: 'write'; id?: number; data: string }
@ -28,6 +43,8 @@ type TerminalMessage =
| { type: 'clear'; id?: number }
| { type: 'measure'; id?: number; containerHeight?: number }
| { type: 'reset-zoom'; id?: number }
| { type: 'cancel-select'; id?: number }
| { type: 'do-select-all'; id?: number }
// Why: TUI apps (Claude Code / Ink) emit escape codes with absolute cursor
// positioning designed for the desktop's terminal dimensions (~150+ cols).
@ -63,12 +80,90 @@ const XTERM_HTML = `<!DOCTYPE html>
display: inline-block;
}
.xterm { -webkit-user-select: none; user-select: none; }
/* Why: selection overlay sits in unscaled viewport coords, above the
transformed surface, so handle hit areas and Copy menu positions
don't depend on getTotalScale() for their on-screen size. */
#selection-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
z-index: 10;
display: none;
}
#selection-overlay.active { display: block; }
.sel-handle {
position: absolute;
width: 44px; height: 44px;
margin-left: -22px; margin-top: -22px;
pointer-events: auto;
background: transparent;
}
.sel-handle::before {
content: '';
position: absolute;
left: 50%; top: 22px;
transform: translateX(-50%);
width: 14px; height: 14px;
background: #7aa2f7;
border-radius: 50%;
border: 2px solid #c0caf5;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.sel-handle.start::before { top: 8px; }
.sel-handle.start::after {
content: '';
position: absolute;
left: 50%; top: 22px;
transform: translateX(-50%);
width: 2px; height: 16px;
background: #7aa2f7;
}
.sel-handle.end::before { top: 22px; }
.sel-handle.end::after {
content: '';
position: absolute;
left: 50%; top: 6px;
transform: translateX(-50%);
width: 2px; height: 16px;
background: #7aa2f7;
}
#sel-menu {
position: absolute;
pointer-events: auto;
background: #2a2f4a;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
display: flex;
overflow: hidden;
transform: translateY(-100%);
margin-top: -12px;
user-select: none;
-webkit-user-select: none;
}
#sel-menu button {
background: transparent;
border: none;
color: #c0caf5;
font: 600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
padding: 10px 16px;
cursor: pointer;
}
#sel-menu button:active { background: #414868; }
#sel-menu button + button { border-left: 1px solid #414868; }
</style>
</head>
<body>
<div id="terminal-container">
<div id="terminal-surface"></div>
</div>
<div id="selection-overlay">
<div id="sel-handle-start" class="sel-handle start"></div>
<div id="sel-handle-end" class="sel-handle end"></div>
<div id="sel-menu">
<button id="sel-menu-copy">Copy</button>
<button id="sel-menu-all">Select All</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.198/lib/xterm.min.js"></script>
<script>
(function() {
@ -137,6 +232,7 @@ const XTERM_HTML = `<!DOCTYPE html>
function updateTransform() {
surface.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + getTotalScale() + ')';
if (selMode === 'select') repositionOverlay();
}
function getCellHeight() {
@ -282,6 +378,7 @@ const XTERM_HTML = `<!DOCTYPE html>
vpWidth: vpW
});
}
repositionOverlay();
}
function isAltScreenActive(data) {
@ -378,6 +475,11 @@ const XTERM_HTML = `<!DOCTYPE html>
writeQueue.push(replayData);
}
// Why: reset eviction tracking + attach observers for the new term.
resetEvictionCounter();
cancelSelect();
attachTermObservers();
requestAnimationFrame(function() {
if (gen !== terminalGeneration) return;
ready = true;
@ -482,13 +584,496 @@ const XTERM_HTML = `<!DOCTYPE html>
afterDrainCallbacks = [];
writesDraining = false;
if (term) { term.clear(); term.reset(); }
resetEvictionCounter();
if (selMode === 'select') {
notify({ type: 'selection-evicted' });
cancelSelect();
}
} else if (msg.type === 'measure') {
measureFitDimensions(msg.containerHeight);
} else if (msg.type === 'reset-zoom') {
applyFitScale('reset-zoom-msg');
} else if (msg.type === 'cancel-select') {
if (selMode === 'select') cancelSelect();
} else if (msg.type === 'do-select-all') {
if (term) {
try {
term.selectAll();
var b = term.buffer.active;
if (selMode !== 'select') {
selMode = 'select';
selectionOverlay.classList.add('active');
notify({ type: 'set-select-mode', enabled: true });
}
sel = {
anchor: { col: 0, row: 0 },
focus: { col: term.cols - 1, row: b.length - 1 },
activeHandle: null
};
repositionOverlay();
} catch (e) {}
}
}
}
// ============================================================
// SELECTION MODE (long-press → handles → Copy)
// ============================================================
var WORD_RE = /[\\p{L}\\p{N}_./:@~+=?&#%-]/u;
var LONG_PRESS_MS = 500;
var LONG_PRESS_SLOP = 10;
var EDGE_SCROLL_PX = 40;
var EDGE_SCROLL_INTERVAL = 60;
var selectionOverlay = document.getElementById('selection-overlay');
var handleStart = document.getElementById('sel-handle-start');
var handleEnd = document.getElementById('sel-handle-end');
var selMenu = document.getElementById('sel-menu');
var btnCopy = document.getElementById('sel-menu-copy');
var btnSelAll = document.getElementById('sel-menu-all');
// mode: 'navigate' | 'select'
var selMode = 'navigate';
var sel = null; // { anchor:{col,row}, focus:{col,row}, activeHandle:null|'start'|'end' }
var longPressTimer = null;
var longPressOrigin = null; // {x,y, identifier}
var edgeScrollTimer = null;
var edgeScrollDir = 0;
// Eviction watchdog: linesEverWritten counts onLineFeed since last init.
// Once buffer is full, every onLineFeed evicts the top row in xterm and
// we mirror that by decrementing stored absolute rows.
var linesEverWritten = 0;
function resetEvictionCounter() { linesEverWritten = 0; }
function isBufferFull() {
if (!term) return false;
return linesEverWritten >= 5000 + (term.rows || 0);
}
function checkEviction() {
if (selMode !== 'select' || !sel) return;
var oldest = Math.min(sel.anchor.row, sel.focus.row);
if (oldest < 0) {
notify({ type: 'selection-evicted' });
cancelSelect();
}
}
function logFeedAndEvict() {
linesEverWritten++;
if (selMode === 'select' && sel && isBufferFull()) {
sel.anchor.row -= 1;
sel.focus.row -= 1;
checkEviction();
repositionOverlay();
}
}
function emitModesIfChanged() {
if (!term) return;
var bp = !!(term.modes && term.modes.bracketedPasteMode);
var alt = false;
try { alt = term.buffer && term.buffer.active && term.buffer.active.type === 'alternate'; } catch (e) {}
if (bp !== lastEmittedModes.bracketedPasteMode || alt !== lastEmittedModes.altScreen) {
lastEmittedModes = { bracketedPasteMode: bp, altScreen: alt };
notify({ type: 'modes', bracketedPasteMode: bp, altScreen: alt });
}
}
var lastEmittedModes = { bracketedPasteMode: false, altScreen: false };
function attachTermObservers() {
if (!term) return;
try { term.onLineFeed(logFeedAndEvict); } catch (e) {}
// Why: emit modes on every parsed write so RN's mirror stays current
// without round-trip; covers \\x1b[?2004h/l and alt-screen toggles.
try { term.onWriteParsed && term.onWriteParsed(emitModesIfChanged); } catch (e) {}
// Initial emit once buffer settles.
afterWritesDrained(function() { emitModesIfChanged(); });
}
function viewportToCell(clientX, clientY) {
if (!term) return null;
var cellW = getCellWidth();
var cellH = getCellHeight();
if (cellW <= 0 || cellH <= 0) return null;
var total = getTotalScale();
if (total <= 0) total = 1;
var sx = (clientX - panX) / total;
var sy = (clientY - panY) / total;
var col = Math.floor(sx / cellW);
var viewportRow = Math.floor(sy / cellH);
if (col < 0) col = 0;
if (col > term.cols - 1) col = term.cols - 1;
if (viewportRow < 0) viewportRow = 0;
if (viewportRow > term.rows - 1) viewportRow = term.rows - 1;
var viewportY = term.buffer.active.viewportY;
return { col: col, row: viewportRow + viewportY };
}
function cellToViewportPx(col, absRow) {
if (!term) return { x: 0, y: 0 };
var cellW = getCellWidth();
var cellH = getCellHeight();
var viewportRow = absRow - term.buffer.active.viewportY;
var sx = col * cellW;
var sy = viewportRow * cellH;
var total = getTotalScale();
return { x: sx * total + panX, y: sy * total + panY };
}
function getLineText(absRow) {
if (!term) return '';
var line = term.buffer.active.getLine(absRow);
if (!line) return '';
return line.translateToString(false);
}
function seedWordSelection(col, absRow) {
var line = getLineText(absRow);
if (!line) {
sel = { anchor: { col: col, row: absRow }, focus: { col: col, row: absRow }, activeHandle: null };
applyXtermSelection();
return;
}
var s = col;
var e = col;
if (col >= 0 && col < line.length && WORD_RE.test(line[col])) {
while (s > 0 && WORD_RE.test(line[s - 1])) s--;
while (e < line.length - 1 && WORD_RE.test(line[e + 1])) e++;
}
sel = {
anchor: { col: s, row: absRow },
focus: { col: e, row: absRow },
activeHandle: null
};
applyXtermSelection();
}
function isStartFirst(a, b) {
if (a.row !== b.row) return a.row < b.row;
return a.col <= b.col;
}
function selRange() {
if (!sel) return null;
if (isStartFirst(sel.anchor, sel.focus)) return { start: sel.anchor, end: sel.focus };
return { start: sel.focus, end: sel.anchor };
}
function applyXtermSelection() {
if (!term || !sel) return;
var r = selRange();
if (!r) return;
// Why: term.select(col, row, length) takes a buffer-absolute row,
// not a viewport-relative one. Subtracting viewportY here drifts the
// selection by the scrollback height — handles render where the user
// pressed (their math is independent), but xterm highlights an
// off-screen scrollback region and copies the wrong text.
var length;
if (r.start.row === r.end.row) {
length = Math.max(1, r.end.col - r.start.col + 1);
} else {
var first = term.cols - r.start.col;
var middle = Math.max(0, r.end.row - r.start.row - 1) * term.cols;
var last = r.end.col + 1;
length = first + middle + last;
}
try { term.select(r.start.col, r.start.row, length); } catch (e) {}
}
function cancelSelect() {
selMode = 'navigate';
sel = null;
stopEdgeScroll();
if (term) {
try { term.clearSelection(); } catch (e) {}
// Why: some xterm renderers cache cells and skip repaint on
// clearSelection alone, leaving the previously-highlighted cells
// visually selected. Force a full refresh so the selection layer
// actually clears on screen.
try { term.refresh(0, term.rows - 1); } catch (e) {}
}
selectionOverlay.classList.remove('active');
notify({ type: 'set-select-mode', enabled: false });
}
function enterSelect(col, absRow) {
selMode = 'select';
seedWordSelection(col, absRow);
selectionOverlay.classList.add('active');
notify({ type: 'set-select-mode', enabled: true });
notify({ type: 'haptic', kind: 'selection' });
repositionOverlay();
}
function repositionOverlay() {
if (selMode !== 'select' || !sel || !term) return;
var r = selRange();
var sPx = cellToViewportPx(r.start.col, r.start.row);
var ePx = cellToViewportPx(r.end.col + 1, r.end.row);
var cellH = getCellHeight() * getTotalScale();
// Why: native iOS pattern — start handle anchors at the TOP of the
// first selected cell (dot above, stem covers the cell going down);
// end handle anchors at the BOTTOM of the last selected cell (dot
// below, stem covers the cell going up).
handleStart.style.left = sPx.x + 'px';
handleStart.style.top = sPx.y + 'px';
handleEnd.style.left = ePx.x + 'px';
handleEnd.style.top = (ePx.y + cellH) + 'px';
var startVisible = sPx.y >= 0 && sPx.y <= window.innerHeight;
var endVisible = ePx.y >= 0 && ePx.y <= window.innerHeight;
handleStart.style.visibility = startVisible ? 'visible' : 'hidden';
handleEnd.style.visibility = endVisible ? 'visible' : 'hidden';
var menuCenterX, menuY, vTransform, marginTop;
if (startVisible && sPx.y > 56) {
menuCenterX = sPx.x; menuY = sPx.y;
vTransform = 'translateY(-100%)';
marginTop = '-12px';
} else if (endVisible && ePx.y + cellH + 56 < window.innerHeight) {
menuCenterX = ePx.x; menuY = ePx.y + cellH;
vTransform = 'translateY(0)';
marginTop = '12px';
} else {
// selection covers full viewport — pin to visible center
menuCenterX = window.innerWidth / 2;
menuY = window.innerHeight / 2;
vTransform = 'translateY(-50%)';
marginTop = '0';
}
// Why: clamp horizontally so the pill stays fully visible when the
// selection sits near a screen edge. We position via plain left
// (no horizontal translate) so the clamp math is straightforward.
selMenu.style.transform = vTransform;
selMenu.style.marginTop = marginTop;
selMenu.style.top = menuY + 'px';
selMenu.style.left = '0px';
var EDGE_MARGIN = 8;
var menuW = selMenu.offsetWidth || 0;
var minLeft = EDGE_MARGIN;
var maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - menuW - EDGE_MARGIN);
var desiredLeft = menuCenterX - menuW / 2;
var clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft));
selMenu.style.left = clampedLeft + 'px';
}
function startEdgeScroll(dir) {
if (edgeScrollDir === dir) return;
edgeScrollDir = dir;
stopEdgeScroll();
edgeScrollTimer = setInterval(function() {
if (!term || edgeScrollDir === 0) return;
var beforeY = term.buffer.active.viewportY;
term.scrollLines(edgeScrollDir);
var afterY = term.buffer.active.viewportY;
if (beforeY === afterY) {
notify({ type: 'haptic', kind: 'edge-bump' });
stopEdgeScroll();
return;
}
repositionOverlay();
}, EDGE_SCROLL_INTERVAL);
}
function stopEdgeScroll() {
if (edgeScrollTimer) {
clearInterval(edgeScrollTimer);
edgeScrollTimer = null;
}
edgeScrollDir = 0;
}
function handleDragMove(handle, clientX, clientY) {
var c = viewportToCell(clientX, clientY);
if (!c || !sel) return;
if (handle === 'start') sel.anchor = c;
else sel.focus = c;
applyXtermSelection();
repositionOverlay();
if (clientY < EDGE_SCROLL_PX) startEdgeScroll(-1);
else if (clientY > window.innerHeight - EDGE_SCROLL_PX) startEdgeScroll(1);
else stopEdgeScroll();
}
// ============================================================
// LATCHING TOUCH DISPATCHER (document-level)
// ============================================================
var dispatch = { mode: 'idle', touchId: null, touchIds: null, longPressFingerInsideOverlay: false };
function touchById(touches, id) {
for (var i = 0; i < touches.length; i++) {
if (touches[i].identifier === id) return touches[i];
}
return null;
}
function targetInside(target, el) {
if (!target || !el) return false;
return el.contains(target);
}
function clearLongPress() {
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
longPressOrigin = null;
}
function armLongPress(touch) {
longPressOrigin = { x: touch.clientX, y: touch.clientY, identifier: touch.identifier };
longPressTimer = setTimeout(function() {
longPressTimer = null;
if (!longPressOrigin) return;
var c = viewportToCell(longPressOrigin.x, longPressOrigin.y);
if (!c) return;
enterSelect(c.col, c.row);
}, LONG_PRESS_MS);
}
function touchSlopExceeded(t) {
if (!longPressOrigin) return false;
var dx = Math.abs(t.clientX - longPressOrigin.x);
var dy = Math.abs(t.clientY - longPressOrigin.y);
return (dx + dy) > LONG_PRESS_SLOP;
}
// Why: existing surface handlers stay attached to surface but we wrap
// their entry to no-op when the dispatcher latches into select-drag.
function dispatcherShouldBlockSurface() {
return dispatch.mode === 'select-drag';
}
document.addEventListener('touchstart', function(e) {
var t = e.touches[0];
var target = e.target;
var onHandle = target === handleStart || target === handleEnd;
var inOverlay = targetInside(target, selectionOverlay);
var inSurface = targetInside(target, surface);
if (e.touches.length === 2) {
// pinch latch
if (selMode === 'select') {
notify({ type: 'mobile-clip-cancel-by-pinch' });
cancelSelect();
}
dispatch.mode = 'pinch';
dispatch.touchIds = [e.touches[0].identifier, e.touches[1].identifier];
clearLongPress();
return;
}
if (onHandle && selMode === 'select') {
// start handle drag
var handleName = (target === handleStart) ? 'start' : 'end';
sel.activeHandle = handleName;
dispatch.mode = 'select-drag';
dispatch.touchId = t.identifier;
e.preventDefault();
return;
}
if (inOverlay) {
// tap on menu pill — let the buttons' own handlers fire
return;
}
if (inSurface && selMode === 'select') {
// Why: tap-to-dismiss matches native iOS/Android — touching outside the
// selection clears it. We cancel immediately and latch to 'surface' so
// the same gesture still drives scroll/pan without a second touch.
cancelSelect();
dispatch.mode = 'surface';
dispatch.touchId = t.identifier;
return;
}
if (inSurface) {
dispatch.mode = 'surface';
dispatch.touchId = t.identifier;
armLongPress(t);
}
}, { capture: true, passive: false });
document.addEventListener('touchmove', function(e) {
if (dispatch.mode === 'select-drag') {
var t = touchById(e.touches, dispatch.touchId);
if (!t || !sel || !sel.activeHandle) return;
e.preventDefault();
handleDragMove(sel.activeHandle, t.clientX, t.clientY);
return;
}
if (dispatch.mode === 'surface' || dispatch.mode === 'pinch') {
// long-press slop check
if (longPressTimer && e.touches.length === 1) {
if (touchSlopExceeded(e.touches[0])) clearLongPress();
}
// existing surface handler will run from its own listener
}
}, { capture: true, passive: false });
document.addEventListener('touchend', function(e) {
if (dispatch.mode === 'select-drag') {
if (sel) sel.activeHandle = null;
stopEdgeScroll();
dispatch.mode = 'idle';
dispatch.touchId = null;
return;
}
if (dispatch.mode === 'pinch') {
if (e.touches.length < 2) {
dispatch.mode = (e.touches.length === 1) ? 'surface' : 'idle';
dispatch.touchIds = null;
if (e.touches.length === 1) dispatch.touchId = e.touches[0].identifier;
}
return;
}
if (dispatch.mode === 'surface') {
clearLongPress();
if (e.touches.length === 0) {
dispatch.mode = 'idle';
dispatch.touchId = null;
}
}
}, { capture: true, passive: true });
document.addEventListener('touchcancel', function() {
clearLongPress();
stopEdgeScroll();
if (dispatch.mode === 'select-drag') {
if (sel) sel.activeHandle = null;
}
dispatch.mode = 'idle';
dispatch.touchId = null;
dispatch.touchIds = null;
}, { capture: true, passive: true });
btnCopy.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (!term) return;
var text = term.getSelection ? term.getSelection() : '';
if (text && text.length > 0) {
notify({ type: 'selection', text: text });
} else {
cancelSelect();
}
});
btnSelAll.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (!term) return;
try {
term.selectAll();
var b = term.buffer.active;
sel = {
anchor: { col: 0, row: 0 },
focus: { col: term.cols - 1, row: b.length - 1 },
activeHandle: null
};
repositionOverlay();
} catch (err) {}
});
// Why: event listeners are registered once here (not inside init()) so
// they don't accumulate on re-init. They close over the mutable 'term'
// variable, so they always reference the current terminal instance.
@ -507,6 +1092,7 @@ const XTERM_HTML = `<!DOCTYPE html>
}
surface.addEventListener('touchstart', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (ts.momentumId) {
cancelAnimationFrame(ts.momentumId);
ts.momentumId = null;
@ -531,6 +1117,7 @@ const XTERM_HTML = `<!DOCTYPE html>
}, { capture: true, passive: true });
surface.addEventListener('touchmove', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
e.preventDefault();
e.stopPropagation();
@ -579,6 +1166,7 @@ const XTERM_HTML = `<!DOCTYPE html>
}, { capture: true, passive: false });
surface.addEventListener('touchend', function(e) {
if (dispatcherShouldBlockSurface()) return;
if (!term) return;
if (ts.isPinching && e.touches.length < 2) {
@ -638,6 +1226,7 @@ const XTERM_HTML = `<!DOCTYPE html>
// though there's now less vertical room and the fit ratio may differ.
applyFitScale('window-resize');
adjustRowsForViewport();
repositionOverlay();
clampPan();
updateTransform();
});
@ -653,7 +1242,15 @@ const XTERM_HTML = `<!DOCTYPE html>
</html>`
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function TerminalWebView(
{ style, onWebReady },
{
style,
onWebReady,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onHaptic
},
ref
) {
const webViewRef = useRef<WebView>(null)
@ -729,9 +1326,42 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
const tag = typeof msg.tag === 'string' ? msg.tag : '[fit]'
// eslint-disable-next-line no-console
console.log(tag, msg.payload)
} else if (msg.type === 'set-select-mode') {
onSelectionMode?.(!!msg.enabled)
} else if (msg.type === 'selection') {
const text = typeof msg.text === 'string' ? msg.text : ''
onSelectionCopy?.(text)
} else if (msg.type === 'selection-evicted') {
onSelectionEvicted?.()
} else if (msg.type === 'modes') {
onModesChanged?.({
bracketedPasteMode: !!msg.bracketedPasteMode,
altScreen: !!msg.altScreen
})
} else if (msg.type === 'haptic') {
const kind = msg.kind
if (
kind === 'selection' ||
kind === 'success' ||
kind === 'error' ||
kind === 'edge-bump'
) {
onHaptic?.(kind)
}
} else if (msg.type === 'mobile-clip-cancel-by-pinch') {
// eslint-disable-next-line no-console
console.warn('[mobile-clip] selection cancelled by pinch')
}
},
[flushPendingMessages, onWebReady]
[
flushPendingMessages,
onWebReady,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
onModesChanged,
onHaptic
]
)
const handleLoadStart = useCallback(() => {
@ -780,6 +1410,12 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
resetZoom() {
postMessage({ type: 'reset-zoom' })
},
cancelSelect() {
postMessage({ type: 'cancel-select' })
},
doSelectAll() {
postMessage({ type: 'do-select-all' })
},
async awaitReady(): Promise<void> {
// Why: returns the in-flight ready promise (set by init); resolves
// immediately if no init is pending. Capped at 3s so a stuck