From cc14a0ade31625dc484fdf04ed84bf641d44abd4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 7 May 2026 21:24:05 -0400 Subject: [PATCH] feat(mobile): terminal text selection, copy, and paste (#1553) Co-authored-by: Orca --- .../app/h/[hostId]/session/[worktreeId].tsx | 282 +++++++- mobile/package.json | 1 + mobile/pnpm-lock.yaml | 16 + mobile/src/platform/haptics.ts | 32 + mobile/src/terminal/TerminalWebView.tsx | 642 +++++++++++++++++- 5 files changed, 962 insertions(+), 11 deletions(-) diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 57f9cfe9f..ef5300b5f 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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} /> ) @@ -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>(new Map()) + const [selectModeActive, setSelectModeActive] = useState(false) + const [canPaste, setCanPaste] = useState(false) + const [toastMessage, setToastMessage] = useState(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>(new Map()) + const initialModesSeenRef = useRef>(new Set()) const deviceTokenRef = useRef(null) const clientRef = useRef(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 | null>(null) + const repeatIntervalRef = useRef | 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 && ( + + {toastMessage} + + )} )} @@ -1115,6 +1335,24 @@ export default function SessionScreen() { )} + {canPaste && ( + [ + styles.accessoryKey, + pressed && styles.accessoryKeyPressed, + !canSend && styles.accessoryKeyDisabled + ]} + disabled={!canSend} + onPress={() => void handlePaste()} + accessibilityLabel="Paste from clipboard" + > + + Paste + + + )} {ACCESSORY_KEYS.map((key) => ( 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}`} > {}) } } + +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(() => {}) + } +} diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 56e5c897d..21f438bdd 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -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 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 = ` 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; }
+
+
+
+
+ + +
+