fix(mobile): preserve terminal input mode on reentry (#7129)

Fixes #6972.\n\nPreserves mobile terminal buffered/live input mode across Android terminal re-entry and session refreshes. Includes follow-up hardening for pre-hydration preference edits and failed storage reads.
This commit is contained in:
Siddiqui Qamar
2026-07-02 23:22:30 -07:00
committed by GitHub
parent fa94065a81
commit f0278c116e
7 changed files with 540 additions and 69 deletions
+11 -69
View File
@@ -96,9 +96,7 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi
import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target'
import {
clearTerminalLiveInputFocusTimer,
defaultTerminalLiveInputHandles,
isTerminalLiveInputWithinByteLimit,
pruneTerminalLiveInputHandles,
scheduleTerminalLiveInputFocus
} from '../../../../src/terminal/terminal-live-input'
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
@@ -165,6 +163,7 @@ import {
} from '../../../../src/session/mobile-new-tab-agent-options'
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference'
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
@@ -896,11 +895,14 @@ export default function SessionScreen() {
const [terminalLinkOpenMode, setTerminalLinkOpenMode] =
useState<MobileTerminalLinkOpenMode>('orca-browser')
const [liveInputCapture, setLiveInputCapture] = useState('')
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState<Set<string>>(
() => new Set()
)
const liveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
const defaultedLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
const {
clearTerminalLiveInputDefault,
defaultTerminalHandlesToLiveInput,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
pruneTerminalHandlesFromLiveInput,
toggleTerminalLiveInput
} = useTerminalLiveInputModePreference({ hostId, worktreeId })
const [activeHandle, setActiveHandle] = useState<string | null>(null)
const [activeSessionTabId, setActiveSessionTabId] = useState<string | null>(null)
const activeSessionTabIdRef = useRef<string | null>(null)
@@ -1104,7 +1106,6 @@ export default function SessionScreen() {
sessionTabsRef.current = sessionTabs
activeSessionTabIdRef.current = activeSessionTabId
markdownDocsRef.current = markdownDocs
liveInputTerminalHandlesRef.current = liveInputTerminalHandles
const reconciledCreateWarningState = reconcileMobileSessionCreateWarningState(
createWarningState,
initialCreateWarning
@@ -1170,52 +1171,6 @@ 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,
@@ -2685,9 +2640,6 @@ export default function SessionScreen() {
setSessionTabs([])
setActiveSessionTabId(null)
clearPendingLiveInputCommit()
liveInputTerminalHandlesRef.current = new Set()
defaultedLiveInputTerminalHandlesRef.current = new Set()
setLiveInputTerminalHandles(new Set())
setMarkdownDocs(new Map())
setFileDocs(new Map())
clearDelayedActionTimers()
@@ -3362,17 +3314,7 @@ export default function SessionScreen() {
if (!activeHandle) {
return
}
const nextEnabled = !liveInputTerminalHandles.has(activeHandle)
setLiveInputTerminalHandles((prev) => {
const next = new Set(prev)
if (nextEnabled) {
next.add(activeHandle)
} else {
next.delete(activeHandle)
}
liveInputTerminalHandlesRef.current = next
return next
})
const nextEnabled = toggleTerminalLiveInput(activeHandle)
clearPendingLiveInputCommit()
if (nextEnabled) {
scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus())
@@ -3380,7 +3322,7 @@ export default function SessionScreen() {
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
liveInputRef.current?.blur()
}
}, [activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles])
}, [activeHandle, clearPendingLiveInputCommit, toggleTerminalLiveInput])
const allowTerminalGestureInput = useCallback(
(handle: string, sequenceCount: number): boolean => {
@@ -0,0 +1,138 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
readDisabledTerminalLiveInputHandlesPreference,
saveDisabledTerminalLiveInputHandles,
type DisabledTerminalLiveInputHandlesPreference
} from '../storage/preferences'
import { useTerminalLiveInputModePreference } from './use-terminal-live-input-mode-preference'
vi.mock('../storage/preferences', () => ({
readDisabledTerminalLiveInputHandlesPreference: vi.fn(),
saveDisabledTerminalLiveInputHandles: vi.fn()
}))
type TerminalLiveInputModePreferenceHarness = {
readonly current: ReturnType<typeof useTerminalLiveInputModePreference>
readonly unmount: () => void
}
type Deferred<T> = {
readonly promise: Promise<T>
readonly resolve: (value: T) => void
}
function createDeferred<T>(): Deferred<T> {
let resolve: ((value: T) => void) | null = null
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve
})
if (!resolve) {
throw new Error('deferred resolver was not initialized')
}
return { promise, resolve }
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness {
let current: ReturnType<typeof useTerminalLiveInputModePreference> | null = null
let renderer: ReactTestRenderer | null = null
function Harness(): null {
current = useTerminalLiveInputModePreference({
hostId: 'host-1',
worktreeId: 'worktree-1'
})
return null
}
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
act(() => {
renderer = create(createElement(Harness))
})
} finally {
restoreConsoleError()
}
if (!current || !renderer) {
throw new Error('terminal live input mode preference hook did not render')
}
return {
get current() {
if (!current) {
throw new Error('terminal live input mode preference hook is not mounted')
}
return current
},
unmount: () => {
act(() => renderer?.unmount())
}
}
}
describe('terminal live input mode preference hook', () => {
beforeEach(() => {
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReset()
vi.mocked(saveDisabledTerminalLiveInputHandles).mockReset()
vi.mocked(saveDisabledTerminalLiveInputHandles).mockResolvedValue()
})
it('merges pre-hydration edits with loaded disabled handles', async () => {
const load = createDeferred<DisabledTerminalLiveInputHandlesPreference>()
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise)
const harness = createTerminalLiveInputModePreferenceHarness()
act(() => {
harness.current.defaultTerminalHandlesToLiveInput(['pty-1', 'pty-2'])
})
act(() => {
expect(harness.current.toggleTerminalLiveInput('pty-1')).toBe(true)
})
await act(async () => {
load.resolve({ handles: new Set(['pty-2']), loaded: true })
await load.promise
})
expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1'])
expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledTimes(1)
expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledWith(
'host-1',
'worktree-1',
new Set(['pty-2'])
)
harness.unmount()
})
it('does not persist fallback-empty storage reads during clean hydration', async () => {
const load = createDeferred<DisabledTerminalLiveInputHandlesPreference>()
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise)
const harness = createTerminalLiveInputModePreferenceHarness()
act(() => {
harness.current.defaultTerminalHandlesToLiveInput(['pty-1'])
})
await act(async () => {
load.resolve({ handles: new Set(), loaded: false })
await load.promise
})
expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1'])
expect(saveDisabledTerminalLiveInputHandles).not.toHaveBeenCalled()
harness.unmount()
})
})
@@ -0,0 +1,212 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
readDisabledTerminalLiveInputHandlesPreference,
saveDisabledTerminalLiveInputHandles
} from '../storage/preferences'
import {
applyDisabledTerminalLiveInputHandles,
defaultTerminalLiveInputHandles,
filterTerminalLiveInputDefaultCandidates,
pruneTerminalLiveInputHandles
} from '../terminal/terminal-live-input'
type UseTerminalLiveInputModePreferenceOptions = {
readonly hostId: string
readonly worktreeId: string
}
export function useTerminalLiveInputModePreference({
hostId,
worktreeId
}: UseTerminalLiveInputModePreferenceOptions) {
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState<Set<string>>(
() => new Set()
)
const liveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
const defaultedLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
const disabledLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
const disabledLiveInputHydratedRef = useRef(false)
const pendingDisabledLiveInputHydrationEditsRef = useRef<Map<string, boolean>>(new Map())
const pendingLiveInputDefaultHandlesRef = useRef<Set<string>>(new Set())
const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => {
// Why: terminal discovery (tab snapshots, list poll, create) can arrive
// before the async persisted-disabled load on worktree re-entry.
if (!disabledLiveInputHydratedRef.current) {
for (const handle of handles) {
pendingLiveInputDefaultHandlesRef.current.add(handle)
}
return
}
const defaultableHandles = filterTerminalLiveInputDefaultCandidates(
handles,
disabledLiveInputTerminalHandlesRef.current
)
const result = defaultTerminalLiveInputHandles(
liveInputTerminalHandlesRef.current,
defaultedLiveInputTerminalHandlesRef.current,
defaultableHandles
)
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 persistDisabledLiveInputHandles = useCallback(() => {
void saveDisabledTerminalLiveInputHandles(
hostId,
worktreeId,
disabledLiveInputTerminalHandlesRef.current
).catch(() => {})
}, [hostId, worktreeId])
const pruneTerminalHandlesFromLiveInput = useCallback(
(liveHandles: ReadonlySet<string>) => {
const result = pruneTerminalLiveInputHandles(
liveInputTerminalHandlesRef.current,
defaultedLiveInputTerminalHandlesRef.current,
liveHandles
)
let prunedDisabledHandles = false
for (const handle of disabledLiveInputTerminalHandlesRef.current) {
if (liveHandles.has(handle)) {
continue
}
disabledLiveInputTerminalHandlesRef.current.delete(handle)
if (!disabledLiveInputHydratedRef.current) {
pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false)
}
prunedDisabledHandles = true
}
if (prunedDisabledHandles && disabledLiveInputHydratedRef.current) {
persistDisabledLiveInputHandles()
}
if (!result.changed) {
return
}
const nextEnabledHandles = new Set(result.enabledHandles)
const nextDefaultedHandles = new Set(result.defaultedHandles)
liveInputTerminalHandlesRef.current = nextEnabledHandles
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
setLiveInputTerminalHandles(nextEnabledHandles)
},
[persistDisabledLiveInputHandles]
)
const clearTerminalLiveInputDefault = useCallback(
(handle: string) => {
const liveHandles = new Set([
...liveInputTerminalHandlesRef.current,
...defaultedLiveInputTerminalHandlesRef.current
])
liveHandles.delete(handle)
if (!disabledLiveInputHydratedRef.current) {
pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false)
}
if (disabledLiveInputTerminalHandlesRef.current.delete(handle)) {
if (disabledLiveInputHydratedRef.current) {
persistDisabledLiveInputHandles()
}
}
pruneTerminalHandlesFromLiveInput(liveHandles)
},
[persistDisabledLiveInputHandles, pruneTerminalHandlesFromLiveInput]
)
const toggleTerminalLiveInput = useCallback(
(handle: string): boolean => {
const nextEnabled = !liveInputTerminalHandlesRef.current.has(handle)
if (nextEnabled) {
disabledLiveInputTerminalHandlesRef.current.delete(handle)
} else {
disabledLiveInputTerminalHandlesRef.current.add(handle)
}
// Why: pre-hydration edits must patch the loaded set per handle; replacing
// the loaded set would erase other persisted opt-outs for this worktree.
if (!disabledLiveInputHydratedRef.current) {
pendingDisabledLiveInputHydrationEditsRef.current.set(handle, !nextEnabled)
}
// Why: only persist after hydration; an earlier write would use the
// reset-empty ref and overwrite other handles' opt-outs for the worktree.
if (disabledLiveInputHydratedRef.current) {
persistDisabledLiveInputHandles()
}
setLiveInputTerminalHandles((prev) => {
const next = new Set(prev)
if (nextEnabled) {
next.add(handle)
} else {
next.delete(handle)
}
liveInputTerminalHandlesRef.current = next
return next
})
return nextEnabled
},
[persistDisabledLiveInputHandles]
)
useEffect(() => {
liveInputTerminalHandlesRef.current = new Set()
defaultedLiveInputTerminalHandlesRef.current = new Set()
disabledLiveInputTerminalHandlesRef.current = new Set()
disabledLiveInputHydratedRef.current = false
pendingDisabledLiveInputHydrationEditsRef.current = new Map()
pendingLiveInputDefaultHandlesRef.current = new Set()
setLiveInputTerminalHandles(new Set())
let disposed = false
// Why: load the persisted opt-outs first so defaulting logic (which can
// fire immediately from subscriptions) respects prior user choices.
void readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId).then((preference) => {
if (disposed) {
return
}
const pendingEdits = pendingDisabledLiveInputHydrationEditsRef.current
const hydratedDisabledHandles = new Set(preference.handles)
for (const [handle, disabled] of pendingEdits) {
if (disabled) {
hydratedDisabledHandles.add(handle)
} else {
hydratedDisabledHandles.delete(handle)
}
}
disabledLiveInputTerminalHandlesRef.current = hydratedDisabledHandles
disabledLiveInputHydratedRef.current = true
pendingDisabledLiveInputHydrationEditsRef.current = new Map()
if (preference.loaded && pendingEdits.size > 0) {
persistDisabledLiveInputHandles()
}
const result = applyDisabledTerminalLiveInputHandles(
liveInputTerminalHandlesRef.current,
defaultedLiveInputTerminalHandlesRef.current,
hydratedDisabledHandles
)
const nextEnabledHandles = new Set(result.enabledHandles)
const nextDefaultedHandles = new Set(result.defaultedHandles)
liveInputTerminalHandlesRef.current = nextEnabledHandles
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
setLiveInputTerminalHandles(nextEnabledHandles)
const pendingDefaultHandles = [...pendingLiveInputDefaultHandlesRef.current]
pendingLiveInputDefaultHandlesRef.current.clear()
defaultTerminalHandlesToLiveInput(pendingDefaultHandles)
})
return () => {
disposed = true
}
}, [defaultTerminalHandlesToLiveInput, hostId, persistDisabledLiveInputHandles, worktreeId])
return {
clearTerminalLiveInputDefault,
defaultTerminalHandlesToLiveInput,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
pruneTerminalHandlesFromLiveInput,
toggleTerminalLiveInput
}
}
+59
View File
@@ -8,9 +8,12 @@ import {
HOST_SIDEBAR_MIN_WIDTH,
clampHostDockWidth,
clampHostSidebarWidth,
loadDisabledTerminalLiveInputHandles,
loadHostSidebarWidth,
loadTerminalAutocompleteEnabled,
loadTerminalLinkOpenMode,
readDisabledTerminalLiveInputHandlesPreference,
saveDisabledTerminalLiveInputHandles,
saveHostSidebarWidth,
saveTerminalAutocompleteEnabled,
saveTerminalLinkOpenMode
@@ -63,6 +66,62 @@ describe('terminal autocomplete preference', () => {
})
})
describe('terminal live input disabled handles preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset()
})
it('defaults to no disabled handles when unset', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
new Set()
)
await expect(
readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1')
).resolves.toEqual({ handles: new Set(), loaded: true })
})
it('loads only string terminal handles from storage', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['pty-1', 42, 'pty-2']))
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
new Set(['pty-1', 'pty-2'])
)
})
it('falls back to no disabled handles for invalid or unreadable storage', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json')
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
new Set()
)
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
new Set()
)
await expect(
readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1')
).resolves.toEqual({ handles: new Set(), loaded: false })
})
it('persists disabled handles per host and worktree', async () => {
await saveDisabledTerminalLiveInputHandles(
'host/one',
'folder:C:\\repo',
new Set(['pty-2', 'pty-1'])
)
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:terminalLiveInputDisabled:host%2Fone:folder%3AC%3A%5Crepo',
JSON.stringify(['pty-2', 'pty-1'])
)
})
})
describe('host sidebar width preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
+47
View File
@@ -73,6 +73,53 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise
await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled))
}
const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:'
export type DisabledTerminalLiveInputHandlesPreference = {
readonly handles: Set<string>
readonly loaded: boolean
}
function terminalLiveInputDisabledKey(hostId: string, worktreeId: string): string {
return `${TERMINAL_LIVE_INPUT_DISABLED_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(
worktreeId
)}`
}
export async function readDisabledTerminalLiveInputHandlesPreference(
hostId: string,
worktreeId: string
): Promise<DisabledTerminalLiveInputHandlesPreference> {
try {
const raw = await AsyncStorage.getItem(terminalLiveInputDisabledKey(hostId, worktreeId))
if (!raw) {
return { handles: new Set(), loaded: true }
}
return { handles: new Set(stringArray(JSON.parse(raw))), loaded: true }
} catch {
return { handles: new Set(), loaded: false }
}
}
export async function loadDisabledTerminalLiveInputHandles(
hostId: string,
worktreeId: string
): Promise<Set<string>> {
const preference = await readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId)
return preference.handles
}
export async function saveDisabledTerminalLiveInputHandles(
hostId: string,
worktreeId: string,
handles: ReadonlySet<string>
): Promise<void> {
await AsyncStorage.setItem(
terminalLiveInputDisabledKey(hostId, worktreeId),
JSON.stringify([...handles])
)
}
const SIDEBAR_WIDTH_KEY = 'orca:hostSidebarWidth'
// Bounds for the draggable host worktree-list sidebar on tablet/foldable
@@ -1,8 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
TERMINAL_LIVE_INPUT_MAX_BYTES,
applyDisabledTerminalLiveInputHandles,
clearTerminalLiveInputFocusTimer,
defaultTerminalLiveInputHandles,
filterTerminalLiveInputDefaultCandidates,
getTerminalLiveSpecialKeyBytes,
isTerminalLiveInputWithinByteLimit,
pruneTerminalLiveInputHandles,
@@ -101,6 +103,35 @@ describe('terminal live input', () => {
expect(result.defaultedHandles).toBe(defaulted)
})
it('does not default persisted buffered-mode handles back to live input on reentry', () => {
const defaultableHandles = filterTerminalLiveInputDefaultCandidates(
['pty-1', 'pty-2'],
new Set(['pty-1'])
)
const result = defaultTerminalLiveInputHandles(
new Set(),
new Set(['pty-1']),
defaultableHandles
)
expect(defaultableHandles).toEqual(['pty-2'])
expect([...result.enabledHandles]).toEqual(['pty-2'])
expect([...result.defaultedHandles]).toEqual(['pty-1', 'pty-2'])
})
it('reconciles persisted buffered-mode handles with currently enabled live input', () => {
const result = applyDisabledTerminalLiveInputHandles(
new Set(['pty-1', 'pty-2']),
new Set(['pty-2']),
new Set(['pty-1'])
)
expect(result.changed).toBe(true)
expect([...result.enabledHandles]).toEqual(['pty-2'])
expect([...result.defaultedHandles]).toEqual(['pty-2', 'pty-1'])
})
it('prunes terminal handles that disappear from session snapshots', () => {
const result = pruneTerminalLiveInputHandles(
new Set(['pty-1', 'pty-stale']),
@@ -118,6 +118,48 @@ export function defaultTerminalLiveInputHandles(
}
}
export function filterTerminalLiveInputDefaultCandidates(
terminalHandles: readonly string[],
disabledHandles: ReadonlySet<string>
): string[] {
return terminalHandles.filter((handle) => !disabledHandles.has(handle))
}
export function applyDisabledTerminalLiveInputHandles(
enabledHandles: ReadonlySet<string>,
defaultedHandles: ReadonlySet<string>,
disabledHandles: ReadonlySet<string>
): TerminalLiveInputDefaultResult {
let nextEnabledHandles: Set<string> | null = null
let nextDefaultedHandles: Set<string> | null = null
for (const handle of enabledHandles) {
if (!disabledHandles.has(handle)) {
continue
}
nextEnabledHandles ??= new Set(enabledHandles)
nextEnabledHandles.delete(handle)
}
for (const handle of disabledHandles) {
if (defaultedHandles.has(handle)) {
continue
}
nextDefaultedHandles ??= new Set(defaultedHandles)
nextDefaultedHandles.add(handle)
}
if (!nextEnabledHandles && !nextDefaultedHandles) {
return { enabledHandles, defaultedHandles, changed: false }
}
return {
enabledHandles: nextEnabledHandles ?? enabledHandles,
defaultedHandles: nextDefaultedHandles ?? defaultedHandles,
changed: true
}
}
export function pruneTerminalLiveInputHandles(
enabledHandles: ReadonlySet<string>,
defaultedHandles: ReadonlySet<string>,