diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 45b82de60..8dbfb932c 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -99,8 +99,10 @@ import { } from '../../../../src/terminal/terminal-accessory-layout' import { clearTerminalLiveInputFocusTimer, + defaultTerminalLiveInputHandles, getTerminalLiveSpecialKeyBytes, isTerminalLiveInputWithinByteLimit, + pruneTerminalLiveInputHandles, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' import { @@ -937,6 +939,8 @@ export default function SessionScreen() { const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState>( () => new Set() ) + const liveInputTerminalHandlesRef = useRef>(new Set()) + const defaultedLiveInputTerminalHandlesRef = useRef>(new Set()) const [activeHandle, setActiveHandle] = useState(null) const [activeSessionTabId, setActiveSessionTabId] = useState(null) const activeSessionTabIdRef = useRef(null) @@ -1111,6 +1115,7 @@ export default function SessionScreen() { sessionTabsRef.current = sessionTabs activeSessionTabIdRef.current = activeSessionTabId markdownDocsRef.current = markdownDocs + liveInputTerminalHandlesRef.current = liveInputTerminalHandles const reconciledCreateWarningState = reconcileMobileSessionCreateWarningState( createWarningState, initialCreateWarning @@ -1176,6 +1181,52 @@ export default function SessionScreen() { [clearToastHideTimer] ) + // Why: direct input is now the mobile default, but only once per discovered + // handle so a user's buffered-mode toggle survives tab/list refreshes. + const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => { + const result = defaultTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + handles + ) + if (!result.changed) { + return + } + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + }, []) + + const pruneTerminalHandlesFromLiveInput = useCallback((liveHandles: ReadonlySet) => { + const result = pruneTerminalLiveInputHandles( + liveInputTerminalHandlesRef.current, + defaultedLiveInputTerminalHandlesRef.current, + liveHandles + ) + if (!result.changed) { + return + } + const nextEnabledHandles = new Set(result.enabledHandles) + const nextDefaultedHandles = new Set(result.defaultedHandles) + liveInputTerminalHandlesRef.current = nextEnabledHandles + defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles + setLiveInputTerminalHandles(nextEnabledHandles) + }, []) + + const clearTerminalLiveInputDefault = useCallback( + (handle: string) => { + const liveHandles = new Set([ + ...liveInputTerminalHandlesRef.current, + ...defaultedLiveInputTerminalHandlesRef.current + ]) + liveHandles.delete(handle) + pruneTerminalHandlesFromLiveInput(liveHandles) + }, + [pruneTerminalHandlesFromLiveInput] + ) + const dictation = useMobileDictation({ client, enabled: canSend, @@ -1613,11 +1664,16 @@ export default function SessionScreen() { } const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle)) + // Why: terminal.list is the lifetime signal; session-tab snapshots can lag + // mobile-created tabs and must not erase a user's buffered-mode opt-out. + pruneTerminalHandlesFromLiveInput(liveHandles) + defaultTerminalHandlesToLiveInput([...liveHandles]) for (const handle of Array.from(terminalUnsubsRef.current.keys())) { if (!liveHandles.has(handle)) { unsubscribeTerminal(handle) terminalRefs.current.delete(handle) initializedHandlesRef.current.delete(handle) + clearTerminalLiveInputDefault(handle) setTerminalKeyboardMetrics((prev) => { if (!prev.has(handle)) { return prev @@ -1662,7 +1718,15 @@ export default function SessionScreen() { fetchTerminalsInFlightRef.current = false } }, - [client, worktreeId, subscribeToTerminal, unsubscribeTerminal] + [ + client, + worktreeId, + clearTerminalLiveInputDefault, + defaultTerminalHandlesToLiveInput, + pruneTerminalHandlesFromLiveInput, + subscribeToTerminal, + unsubscribeTerminal + ] ) const applySessionTabs = useCallback( @@ -1703,6 +1767,8 @@ export default function SessionScreen() { // render loop where the subscription effect tears down and replays itself. setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs)) const terminalTabs = getTerminalRecordsFromSessionTabs(nextTabs) + const terminalTabHandles = terminalTabs.map((terminal) => terminal.handle) + defaultTerminalHandlesToLiveInput(terminalTabHandles) const mergedTerminalsForActive = mergeTerminalRecordsByCurrentOrder( terminalTabs, terminalsRef.current @@ -1797,7 +1863,7 @@ export default function SessionScreen() { setActiveHandle(null) } }, - [subscribeToTerminal, unsubscribeTerminal] + [defaultTerminalHandlesToLiveInput, subscribeToTerminal, unsubscribeTerminal] ) const readMarkdownTab = useCallback( @@ -2588,6 +2654,8 @@ export default function SessionScreen() { setSessionTabs([]) setActiveSessionTabId(null) setLiveInputCapture('') + liveInputTerminalHandlesRef.current = new Set() + defaultedLiveInputTerminalHandlesRef.current = new Set() setLiveInputTerminalHandles(new Set()) setMarkdownDocs(new Map()) setFileDocs(new Map()) @@ -2595,7 +2663,7 @@ export default function SessionScreen() { return () => { clearDelayedActionTimers() } - }, [clearDelayedActionTimers, clearTerminalCache, worktreeId]) + }, [clearDelayedActionTimers, clearTerminalCache, hostId, worktreeId]) useEffect(() => { if (connState !== 'connected') { @@ -2784,6 +2852,7 @@ export default function SessionScreen() { pendingActiveSessionTabIdRef.current = matchingTab?.id ?? null pendingActiveTerminalHandleRef.current = handle activeSessionTabTypeRef.current = 'terminal' + defaultTerminalHandlesToLiveInput([handle]) setActiveSessionTabId(matchingTab?.id ?? null) const prev = activeHandleRef.current activeHandleRef.current = handle @@ -2811,7 +2880,14 @@ export default function SessionScreen() { } } }, - [client, sessionTabs, subscribeToTerminal, unsubscribeTerminal, worktreeId] + [ + client, + defaultTerminalHandlesToLiveInput, + sessionTabs, + subscribeToTerminal, + unsubscribeTerminal, + worktreeId + ] ) const switchSessionTab = useCallback( @@ -3171,6 +3247,7 @@ export default function SessionScreen() { } else { next.delete(activeHandle) } + liveInputTerminalHandlesRef.current = next return next }) setLiveInputCapture('') @@ -3814,6 +3891,7 @@ export default function SessionScreen() { }) if (typeof created.terminal === 'string') { const createdHandle = created.terminal + defaultTerminalHandlesToLiveInput([createdHandle]) activeHandleRef.current = createdHandle setActiveHandle(createdHandle) setTerminals((prev) => { @@ -4057,6 +4135,7 @@ export default function SessionScreen() { unsubscribeTerminal(target.handle) terminalRefs.current.delete(target.handle) initializedHandlesRef.current.delete(target.handle) + clearTerminalLiveInputDefault(target.handle) const next = terminals.filter((terminal) => terminal.handle !== target.handle) setTerminals(next) terminalsRef.current = next @@ -4089,6 +4168,7 @@ export default function SessionScreen() { unsubscribeTerminal(tab.terminal) terminalRefs.current.delete(tab.terminal) initializedHandlesRef.current.delete(tab.terminal) + clearTerminalLiveInputDefault(tab.terminal) } setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id)) // Why: tombstone the closed tab and rely on the subscription/poll diff --git a/mobile/mobile-terminal-direct-input-default.md b/mobile/mobile-terminal-direct-input-default.md new file mode 100644 index 000000000..009d46530 --- /dev/null +++ b/mobile/mobile-terminal-direct-input-default.md @@ -0,0 +1,83 @@ +# Mobile Terminal Direct Input Default + +## Context + +Orca Mobile currently has two terminal input modes: + +- Buffered command input: a visible command text field sends its content with Enter. +- Direct terminal input: a hidden capture field forwards keyboard bytes directly to the PTY. + +Buffered input is safer for composing a full shell command, but it is awkward for terminal-native +flows: shells, TUIs, REPLs, editors, prompts, and remote SSH sessions all expect keystrokes to land +immediately. The mobile terminal already supports direct input via a per-terminal toggle; this +change makes direct input the default mode when a terminal is first seen on mobile. + +## Goals + +- Make first-seen mobile terminal tabs start in direct terminal input mode. +- Keep the existing accessory toggle so users can switch an individual terminal back to buffered + command input. +- Preserve that manual opt-out while the terminal remains open and the session tab list refreshes. +- Keep behavior local to the mobile client. Do not add host/runtime state or desktop-visible + preferences for this small default change. +- Avoid opening the keyboard automatically just because a terminal becomes active. Tapping the + terminal should focus the direct input capture, matching the current live-input interaction model. + +## Non-goals + +- Removing buffered command input. +- Changing `terminal.send`, mobile subscription, or PTY sizing semantics. +- Persisting a user preference across app launches. +- Changing accessory keys, dictation, paste, terminal gesture input, or mouse-aware TUI routing. + +## Design + +The mobile session screen keeps `liveInputTerminalHandles`, a set of terminal handles whose input +bar is in direct mode. Today the set starts empty, so every terminal defaults to the buffered command +box. + +The new behavior adds a companion "default already applied" set. When the mobile client discovers +terminal handles through session tab snapshots, `terminal.list`, or local terminal creation, it adds +only never-before-defaulted handles to `liveInputTerminalHandles`. If a user toggles a handle back to +buffered input, the handle stays in the defaulted set, so future tab refreshes do not flip it back to +direct input. + +Handle cleanup uses `terminal.list` as the terminal lifetime signal. Session tab snapshots can lag +locally created or recently closed terminal tabs, so they should default never-before-seen handles but +should not prune the live/defaulted sets. + +This keeps the default one-shot per handle: + +1. New handle appears. +2. Mobile marks it direct input by default. +3. User can toggle it to buffered input. +4. Snapshot/list refreshes preserve the user's choice. +5. Worktree route reset clears the default tracking for the next session scope. + +## UI Behavior + +When a terminal is in direct mode, the existing live input bar remains: + +- The accessory direct-input icon is active. +- The bar says keyboard input goes directly to the terminal. +- Tapping the terminal focuses the hidden capture input. +- The buffered command box remains available by pressing the same mode toggle. + +When a terminal is in buffered mode, the existing command field remains unchanged. + +## SSH And Provider Notes + +SSH sessions are a primary reason for this default. Direct mode avoids local composition assumptions +and sends the same PTY bytes regardless of whether the shell is local or remote. The change does not +touch source-control provider behavior. + +## Validation + +- Unit test the one-shot default merge: + - first-seen handles are enabled and marked defaulted; + - an already defaulted handle is not re-enabled after manual opt-out; + - newly discovered handles are still enabled. +- Unit test stale-handle pruning from the terminal lifetime list. +- Run focused mobile terminal tests. +- Launch Orca Mobile in the iOS simulator, reach the session terminal screen, and capture a + screenshot showing the direct-input bar as the default terminal input surface. diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts index 3b60155b8..d5eb9f5d9 100644 --- a/mobile/src/terminal/terminal-live-input.test.ts +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { TERMINAL_LIVE_INPUT_MAX_BYTES, clearTerminalLiveInputFocusTimer, + defaultTerminalLiveInputHandles, getTerminalLiveSpecialKeyBytes, isTerminalLiveInputWithinByteLimit, + pruneTerminalLiveInputHandles, scheduleTerminalLiveInputFocus, type TerminalLiveInputFocusTimerRef } from './terminal-live-input' @@ -34,6 +36,57 @@ describe('terminal live input', () => { ).toBe(false) }) + it('defaults first-seen terminal handles to live input once', () => { + const firstPass = defaultTerminalLiveInputHandles(new Set(), new Set(), ['pty-1']) + + expect(firstPass.changed).toBe(true) + expect([...firstPass.enabledHandles]).toEqual(['pty-1']) + expect([...firstPass.defaultedHandles]).toEqual(['pty-1']) + + const manuallyDisabled = new Set() + const secondPass = defaultTerminalLiveInputHandles( + manuallyDisabled, + firstPass.defaultedHandles, + ['pty-1', 'pty-2'] + ) + + expect(secondPass.changed).toBe(true) + expect([...secondPass.enabledHandles]).toEqual(['pty-2']) + expect([...secondPass.defaultedHandles]).toEqual(['pty-1', 'pty-2']) + }) + + it('does not allocate new live input sets when no handles need defaults', () => { + const enabled = new Set(['pty-1']) + const defaulted = new Set(['pty-1']) + const result = defaultTerminalLiveInputHandles(enabled, defaulted, ['pty-1']) + + expect(result.changed).toBe(false) + expect(result.enabledHandles).toBe(enabled) + expect(result.defaultedHandles).toBe(defaulted) + }) + + it('prunes terminal handles that disappear from session snapshots', () => { + const result = pruneTerminalLiveInputHandles( + new Set(['pty-1', 'pty-stale']), + new Set(['pty-1', 'pty-2', 'pty-stale']), + new Set(['pty-1', 'pty-2']) + ) + + expect(result.changed).toBe(true) + expect([...result.enabledHandles]).toEqual(['pty-1']) + expect([...result.defaultedHandles]).toEqual(['pty-1', 'pty-2']) + }) + + it('does not allocate pruned live input sets when every tracked handle is live', () => { + const enabled = new Set(['pty-1']) + const defaulted = new Set(['pty-1']) + const result = pruneTerminalLiveInputHandles(enabled, defaulted, new Set(['pty-1', 'pty-2'])) + + expect(result.changed).toBe(false) + expect(result.enabledHandles).toBe(enabled) + expect(result.defaultedHandles).toBe(defaulted) + }) + it('replaces pending deferred focus work', () => { vi.useFakeTimers() const timerRef = createTimerRef() diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts index f3e15fea2..0cf72e8da 100644 --- a/mobile/src/terminal/terminal-live-input.ts +++ b/mobile/src/terminal/terminal-live-input.ts @@ -6,6 +6,14 @@ export type TerminalLiveInputFocusTimerRef = { current: ReturnType | null } +export type TerminalLiveInputDefaultResult = { + enabledHandles: ReadonlySet + defaultedHandles: ReadonlySet + changed: boolean +} + +export type TerminalLiveInputPruneResult = TerminalLiveInputDefaultResult + export function getTerminalLiveSpecialKeyBytes(key: string): string | null { if (key === 'Backspace') { return '\x7f' @@ -20,6 +28,70 @@ export function isTerminalLiveInputWithinByteLimit( return encoder.encode(text).byteLength <= maxBytes } +export function defaultTerminalLiveInputHandles( + enabledHandles: ReadonlySet, + defaultedHandles: ReadonlySet, + terminalHandles: readonly string[] +): TerminalLiveInputDefaultResult { + let nextEnabledHandles: Set | null = null + let nextDefaultedHandles: Set | null = null + + for (const handle of terminalHandles) { + if (defaultedHandles.has(handle)) { + continue + } + nextEnabledHandles ??= new Set(enabledHandles) + nextDefaultedHandles ??= new Set(defaultedHandles) + nextEnabledHandles.add(handle) + nextDefaultedHandles.add(handle) + } + + if (!nextEnabledHandles || !nextDefaultedHandles) { + return { enabledHandles, defaultedHandles, changed: false } + } + + return { + enabledHandles: nextEnabledHandles, + defaultedHandles: nextDefaultedHandles, + changed: true + } +} + +export function pruneTerminalLiveInputHandles( + enabledHandles: ReadonlySet, + defaultedHandles: ReadonlySet, + liveTerminalHandles: ReadonlySet +): TerminalLiveInputPruneResult { + let nextEnabledHandles: Set | null = null + let nextDefaultedHandles: Set | null = null + + for (const handle of enabledHandles) { + if (liveTerminalHandles.has(handle)) { + continue + } + nextEnabledHandles ??= new Set(enabledHandles) + nextEnabledHandles.delete(handle) + } + + for (const handle of defaultedHandles) { + if (liveTerminalHandles.has(handle)) { + continue + } + nextDefaultedHandles ??= new Set(defaultedHandles) + nextDefaultedHandles.delete(handle) + } + + if (!nextEnabledHandles && !nextDefaultedHandles) { + return { enabledHandles, defaultedHandles, changed: false } + } + + return { + enabledHandles: nextEnabledHandles ?? enabledHandles, + defaultedHandles: nextDefaultedHandles ?? defaultedHandles, + changed: true + } +} + export function clearTerminalLiveInputFocusTimer(timerRef: TerminalLiveInputFocusTimerRef): void { if (timerRef.current === null) { return