diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 8aca3e538..5bd76d65e 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -31,6 +31,7 @@ import { FileText, GitBranch, Globe, + Keyboard as KeyboardIcon, Mic, Monitor, Plus, @@ -59,6 +60,10 @@ import { type TerminalWebViewHandle } from '../../../../src/terminal/TerminalWebView' import { TERMINAL_ACCESSORY_KEYS } from '../../../../src/terminal/terminal-accessory-keys' +import { + getTerminalLiveSpecialKeyBytes, + isTerminalLiveInputWithinByteLimit +} from '../../../../src/terminal/terminal-live-input' import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input' import { MobileBrowserPane, type MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane' import { isBlankBrowserUrl, normalizeBrowserUrl } from '../../../../src/browser/browser-url' @@ -335,7 +340,8 @@ function TerminalPaneView({ onModesChanged, onKeyboardAvoidanceMetrics, onHaptic, - onTerminalInput + onTerminalInput, + onTerminalTap }: { handle: string active: boolean @@ -350,6 +356,7 @@ function TerminalPaneView({ onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput: (handle: string, bytes: string) => void + onTerminalTap: (handle: string) => void }) { const setRef = useCallback( (ref: TerminalWebViewHandle | null) => { @@ -379,6 +386,7 @@ function TerminalPaneView({ onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)} onHaptic={onHaptic} onTerminalInput={(bytes) => onTerminalInput(handle, bytes)} + onTerminalTap={() => onTerminalTap(handle)} /> ) @@ -679,6 +687,10 @@ export default function SessionScreen() { const sessionTabsRef = useRef([]) const [terminalsLoaded, setTerminalsLoaded] = useState(false) const [input, setInput] = useState('') + const [liveInputCapture, setLiveInputCapture] = useState('') + const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( + () => new Set() + ) const [activeHandle, setActiveHandle] = useState(null) const [activeSessionTabId, setActiveSessionTabId] = useState(null) const activeSessionTabIdRef = useRef(null) @@ -745,6 +757,7 @@ export default function SessionScreen() { const viewportRef = useRef<{ cols: number; rows: number } | null>(null) const viewportMeasuredRef = useRef(false) const terminalRefs = useRef>(new Map()) + const liveInputRef = useRef(null) const terminalUnsubsRef = useRef void>>(new Map()) const subscribingHandlesRef = useRef>(new Set()) const initializedHandlesRef = useRef>(new Set()) @@ -781,6 +794,7 @@ export default function SessionScreen() { activeSessionTab?.type !== 'markdown' && activeSessionTab?.type !== 'file' && activeSessionTab?.type !== 'browser' + const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null) useEffect(() => { @@ -1818,6 +1832,8 @@ export default function SessionScreen() { terminalsRef.current = [] setSessionTabs([]) setActiveSessionTabId(null) + setLiveInputCapture('') + setLiveInputTerminalHandles(new Set()) setMarkdownDocs(new Map()) setFileDocs(new Map()) }, [clearTerminalCache, worktreeId]) @@ -2146,6 +2162,117 @@ export default function SessionScreen() { } } + const sendLiveTerminalInput = useCallback( + (handle: string, bytes: string) => { + if (bytes.length === 0) return + if (!isTerminalLiveInputWithinByteLimit(bytes)) { + triggerError() + showToast('Input too large (max 256 KiB)', 1500) + return + } + const rpc = clientRef.current + if ( + !rpc || + connStateRef.current !== 'connected' || + handle !== activeHandleRef.current || + activeSessionTabTypeRef.current !== 'terminal' + ) { + return + } + void rpc + .sendRequest('terminal.send', { + terminal: handle, + text: bytes, + enter: false, + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }) + .catch(() => { + // Transient failure + }) + }, + [showToast] + ) + + const focusLiveInput = useCallback(() => { + if (!canSend || !liveInputEnabled) return + liveInputRef.current?.focus() + }, [canSend, liveInputEnabled]) + + const handleTerminalTap = useCallback( + (handle: string) => { + if (handle !== activeHandleRef.current) return + focusLiveInput() + }, + [focusLiveInput] + ) + + const toggleLiveInput = useCallback(() => { + if (!activeHandle) return + const nextEnabled = !liveInputTerminalHandles.has(activeHandle) + setLiveInputTerminalHandles((prev) => { + const next = new Set(prev) + if (nextEnabled) { + next.add(activeHandle) + } else { + next.delete(activeHandle) + } + return next + }) + setLiveInputCapture('') + if (nextEnabled) { + setTimeout(() => liveInputRef.current?.focus(), 50) + } else { + liveInputRef.current?.blur() + } + }, [activeHandle, liveInputTerminalHandles]) + + const handleLiveInputChange = useCallback( + (text: string) => { + if (!activeHandle) { + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + return + } + if (!liveInputTerminalHandles.has(activeHandle)) { + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + return + } + if (text.length > 0) { + sendLiveTerminalInput(activeHandle, text) + } + setLiveInputCapture('') + // Why: the field is only a keyboard capture surface. Clearing the + // native value prevents subsequent phone-keyboard events from replaying + // already-sent characters when React state remains the empty string. + liveInputRef.current?.setNativeProps({ text: '' }) + }, + [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput] + ) + + const handleLiveInputKeyPress = useCallback( + (event: { nativeEvent: { key: string } }) => { + if (!activeHandle) return + if (!liveInputTerminalHandles.has(activeHandle)) return + const bytes = getTerminalLiveSpecialKeyBytes(event.nativeEvent.key) + if (!bytes) return + sendLiveTerminalInput(activeHandle, bytes) + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + }, + [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput] + ) + + const handleLiveInputSubmit = useCallback(() => { + if (!activeHandle) return + if (!liveInputTerminalHandles.has(activeHandle)) return + sendLiveTerminalInput(activeHandle, '\r') + setLiveInputCapture('') + liveInputRef.current?.setNativeProps({ text: '' }) + }, [activeHandle, liveInputTerminalHandles, sendLiveTerminalInput]) + const allowTerminalGestureInput = useCallback( (handle: string, sequenceCount: number): boolean => { const now = Date.now() @@ -3085,6 +3212,7 @@ export default function SessionScreen() { onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics} onHaptic={handleHaptic} onTerminalInput={handleTerminalInput} + onTerminalTap={handleTerminalTap} /> ))} {toastMessage && ( @@ -3136,6 +3264,31 @@ export default function SessionScreen() { /> )} + [ + styles.accessoryKey, + liveInputEnabled && styles.accessoryKeyActive, + pressed && styles.accessoryKeyPressed, + !canSend && styles.accessoryKeyDisabled + ]} + disabled={!canSend} + onPress={toggleLiveInput} + accessibilityLabel={ + liveInputEnabled + ? 'Switch to buffered command input' + : 'Switch to live terminal input' + } + > + + Live + + {canPaste && ( [ @@ -3223,72 +3376,107 @@ export default function SessionScreen() { {/* Input bar */} - - void handleSend()} - /> + {liveInputEnabled ? ( { - if (dictation.isProcessing) { - void dictation.cancel() - } else if (dictation.isStarting) { - return - } else if (dictation.isRecording) { - void dictation.stop() - } else { - void dictation.start().catch((err) => { - triggerError() - showToast(err instanceof Error ? err.message : String(err)) - }) - } - }} - onLongPress={() => { - if (dictation.isRecording || dictation.isProcessing) { - void dictation.cancel() - } - }} - accessibilityLabel={ - dictation.isRecording - ? 'Stop voice dictation' - : dictation.isProcessing - ? 'Cancel voice dictation' - : dictation.isStarting - ? 'Starting voice dictation' - : 'Start voice dictation' - } + onPress={focusLiveInput} + accessibilityLabel="Focus live terminal input" > - {dictation.isProcessing ? ( - - ) : dictation.isStarting || dictation.isRecording ? ( - - ) : ( - - )} + + + Live + + + Keyboard input goes to terminal + + - void handleSend()} - accessibilityLabel="Send command" - > - - - + ) : ( + + void handleSend()} + /> + { + if (dictation.isProcessing) { + void dictation.cancel() + } else if (dictation.isStarting) { + return + } else if (dictation.isRecording) { + void dictation.stop() + } else { + void dictation.start().catch((err) => { + triggerError() + showToast(err instanceof Error ? err.message : String(err)) + }) + } + }} + onLongPress={() => { + if (dictation.isRecording || dictation.isProcessing) { + void dictation.cancel() + } + }} + accessibilityLabel={ + dictation.isRecording + ? 'Stop voice dictation' + : dictation.isProcessing + ? 'Cancel voice dictation' + : dictation.isStarting + ? 'Starting voice dictation' + : 'Start voice dictation' + } + > + {dictation.isProcessing ? ( + + ) : dictation.isStarting || dictation.isRecording ? ( + + ) : ( + + )} + + void handleSend()} + accessibilityLabel="Send command" + > + + + + )} )} @@ -4005,6 +4193,9 @@ const styles = StyleSheet.create({ accessoryKeyPressed: { backgroundColor: colors.borderSubtle }, + accessoryKeyActive: { + backgroundColor: colors.accentBlue + }, customAccessoryKey: { borderWidth: 1, borderColor: colors.borderSubtle @@ -4017,6 +4208,10 @@ const styles = StyleSheet.create({ fontSize: 12, fontFamily: typography.monoFamily }, + accessoryKeyTextActive: { + color: colors.textPrimary, + fontWeight: '700' + }, accessoryKeyTextDisabled: { color: colors.textMuted }, @@ -4040,6 +4235,37 @@ const styles = StyleSheet.create({ fontFamily: typography.monoFamily, marginRight: spacing.sm }, + liveInputBar: { + gap: spacing.sm + }, + liveInputBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + backgroundColor: colors.accentBlue, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radii.button + }, + liveInputBadgeText: { + color: colors.textPrimary, + fontSize: 12, + fontWeight: '700', + fontFamily: typography.monoFamily + }, + liveInputHint: { + flex: 1, + color: colors.textSecondary, + fontSize: typography.metaSize, + fontFamily: typography.monoFamily + }, + liveInputCapture: { + position: 'absolute', + opacity: 0, + width: 1, + height: 1, + color: colors.textPrimary + }, sendButton: { backgroundColor: colors.bgRaised, width: 34, diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 164692fa2..97de19e65 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -31,6 +31,7 @@ export type TerminalSelectionEvents = { onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput?: (bytes: string) => void + onTerminalTap?: () => void } export type TerminalWebViewHandle = { @@ -1538,6 +1539,9 @@ const XTERM_HTML = ` return; } if (dispatch.mode === 'surface') { + if (e.touches.length === 0 && longPressOrigin && selMode !== 'select') { + notify({ type: 'terminal-tap' }); + } clearLongPress(); if (e.touches.length === 0) { dispatch.mode = 'idle'; @@ -1797,7 +1801,8 @@ export const TerminalWebView = forwardRef(function onModesChanged, onKeyboardAvoidanceMetrics, onHaptic, - onTerminalInput + onTerminalInput, + onTerminalTap }, ref ) { @@ -1900,6 +1905,8 @@ export const TerminalWebView = forwardRef(function } else if (msg.type === 'terminal-input') { const bytes = typeof msg.bytes === 'string' ? msg.bytes : '' if (bytes.length > 0) onTerminalInput?.(bytes) + } else if (msg.type === 'terminal-tap') { + onTerminalTap?.() } else if (msg.type === 'keyboard-avoidance-metrics') { const cursorY = typeof msg.cursorY === 'number' ? msg.cursorY : 0 const rows = typeof msg.rows === 'number' ? msg.rows : 0 @@ -1932,7 +1939,8 @@ export const TerminalWebView = forwardRef(function onModesChanged, onKeyboardAvoidanceMetrics, onHaptic, - onTerminalInput + onTerminalInput, + onTerminalTap ] ) diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts new file mode 100644 index 000000000..0bad02da6 --- /dev/null +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_LIVE_INPUT_MAX_BYTES, + getTerminalLiveSpecialKeyBytes, + isTerminalLiveInputWithinByteLimit +} from './terminal-live-input' + +describe('terminal live input', () => { + it('maps phone keyboard special keys to PTY bytes', () => { + expect(getTerminalLiveSpecialKeyBytes('Backspace')).toBe('\x7f') + expect(getTerminalLiveSpecialKeyBytes('Enter')).toBeNull() + expect(getTerminalLiveSpecialKeyBytes('a')).toBeNull() + }) + + it('enforces the paste-sized byte budget', () => { + expect(isTerminalLiveInputWithinByteLimit('hello')).toBe(true) + expect(isTerminalLiveInputWithinByteLimit('x'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES))).toBe(true) + expect(isTerminalLiveInputWithinByteLimit('x'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES + 1))).toBe( + false + ) + expect( + isTerminalLiveInputWithinByteLimit('é'.repeat(TERMINAL_LIVE_INPUT_MAX_BYTES / 2 + 1)) + ).toBe(false) + }) +}) diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts new file mode 100644 index 000000000..87b6544fc --- /dev/null +++ b/mobile/src/terminal/terminal-live-input.ts @@ -0,0 +1,19 @@ +const TERMINAL_LIVE_INPUT_MAX_BYTES = 256 * 1024 + +const encoder = new TextEncoder() + +export function getTerminalLiveSpecialKeyBytes(key: string): string | null { + if (key === 'Backspace') { + return '\x7f' + } + return null +} + +export function isTerminalLiveInputWithinByteLimit( + text: string, + maxBytes = TERMINAL_LIVE_INPUT_MAX_BYTES +): boolean { + return encoder.encode(text).byteLength <= maxBytes +} + +export { TERMINAL_LIVE_INPUT_MAX_BYTES }