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:
parent
9f3cd6b934
commit
c0f4bdd020
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue