Default first-seen mobile terminal tabs to direct input mode (#6509)

Automatically enable direct (live) terminal input for new terminal
handles on mobile, while allowing users to opt out back to buffered
input.

- Track defaulted handles to ensure list refreshes preserve manual
  buffered-mode choices.
- Prune tracked handles from live input sets when terminals are closed.
- Add unit tests for defaulting and pruning logic.
- Include a design doc detailing goals and implementation notes.
This commit is contained in:
Jinjing 2026-06-27 12:12:46 -07:00 committed by GitHub
parent 9f3cd6b934
commit c0f4bdd020
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 292 additions and 4 deletions

View File

@ -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<Set<string>>(
() => new Set()
)
const liveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
const defaultedLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
const [activeHandle, setActiveHandle] = useState<string | null>(null)
const [activeSessionTabId, setActiveSessionTabId] = useState<string | null>(null)
const activeSessionTabIdRef = useRef<string | null>(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<string>) => {
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

View File

@ -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.

View File

@ -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<string>()
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()

View File

@ -6,6 +6,14 @@ export type TerminalLiveInputFocusTimerRef = {
current: ReturnType<typeof setTimeout> | null
}
export type TerminalLiveInputDefaultResult = {
enabledHandles: ReadonlySet<string>
defaultedHandles: ReadonlySet<string>
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<string>,
defaultedHandles: ReadonlySet<string>,
terminalHandles: readonly string[]
): TerminalLiveInputDefaultResult {
let nextEnabledHandles: Set<string> | null = null
let nextDefaultedHandles: Set<string> | 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<string>,
defaultedHandles: ReadonlySet<string>,
liveTerminalHandles: ReadonlySet<string>
): TerminalLiveInputPruneResult {
let nextEnabledHandles: Set<string> | null = null
let nextDefaultedHandles: Set<string> | 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