diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 4ec0e564a..5d9ba3900 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -117,10 +117,10 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send' import { clearTerminalLiveInputFocusTimer, - focusTerminalLiveInputTarget, isTerminalLiveInputWithinByteLimit, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' +import { useTerminalLiveInputFocus } from '../../../../src/terminal/use-terminal-live-input-focus' import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss' import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response' @@ -1082,6 +1082,22 @@ export default function SessionScreen() { activeSessionTabType: activeSessionTab?.type }) const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false + const { focusLiveInput, handleTerminalTap, resetLiveInputFocus } = useTerminalLiveInputFocus({ + activeHandleRef, + canSend, + inputRef: liveInputRef, + keyboardHeight, + lifecycleIdentity: client, + lifecycleKey: JSON.stringify([hostId, worktreeId, connState]), + liveInputEnabled, + timerRef: liveInputFocusTimerRef + }) + useFocusEffect( + useCallback(() => { + // Expo retains this route while pushed screens are visible. + return resetLiveInputFocus + }, [resetLiveInputFocus]) + ) const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null) // Why: hosts without aiVault.v1 reject listSessions, so hide the header entry instead of a dead-end "update this host" panel. const [agentSessionHistorySupported, setAgentSessionHistorySupported] = useState( @@ -3092,17 +3108,6 @@ export default function SessionScreen() { ) sendLiveTerminalInputRef.current = sendLiveTerminalInput - const focusLiveInput = useCallback(() => { - if (!canSend || !liveInputEnabled) { - return - } - focusTerminalLiveInputTarget(liveInputRef.current, { - keyboardHeight, - refocus: () => - scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus()) - }) - }, [canSend, keyboardHeight, liveInputEnabled]) - const clearSessionTabActionSheetKeyboardListener = useCallback(() => { sessionTabActionSheetKeyboardHideSubRef.current?.remove() sessionTabActionSheetKeyboardHideSubRef.current = null @@ -3177,16 +3182,6 @@ export default function SessionScreen() { }) }, []) - const handleTerminalTap = useCallback( - (handle: string) => { - if (handle !== activeHandleRef.current) { - return - } - focusLiveInput() - }, - [focusLiveInput] - ) - // Tap a terminal file path → resolve on host, open as file tab (mirrors desktop Cmd/Ctrl-click); silent on a miss. const handleFileTapActivationSeqRef = useRef(0) const handleFileTap = useCallback( diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts index 1ceab18ab..cc681e205 100644 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -13,6 +13,10 @@ const commandInputStylesSource = readFileSync( new URL('../../app/h/[hostId]/session/mobile-session-command-input-styles.ts', import.meta.url), 'utf8' ) +const liveInputFocusSource = readFileSync( + new URL('./use-terminal-live-input-focus.ts', import.meta.url), + 'utf8' +) function liveInputBarBlock(): string { const start = sessionRouteSource.indexOf('{liveInputEnabled ? (') @@ -35,9 +39,15 @@ describe('terminal live input affordance', () => { expect(block).toContain('pressed && styles.liveInputFocusTargetPressed') expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled') expect(block).toContain('showSoftInputOnFocus') - expect(sessionRouteSource).toContain('focusTerminalLiveInputTarget(liveInputRef.current') - expect(sessionRouteSource).toContain('keyboardHeight') - expect(sessionRouteSource).toContain('scheduleTerminalLiveInputFocus(liveInputFocusTimerRef') + expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({') + expect(sessionRouteSource).toContain('return resetLiveInputFocus') + expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current') + expect(liveInputFocusSource).toContain('lifecycleIdentity,') + expect(liveInputFocusSource).toContain('resetLiveInputFocus') + expect(liveInputFocusSource).toContain('keyboardHeight: context.keyboardHeight') + expect(liveInputFocusSource).toContain( + 'scheduleTerminalLiveInputFocus(timerRef, focusLiveInput)' + ) }) it('makes the live keyboard target visible instead of status-only chrome', () => { diff --git a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts index bc9c12aa0..54ee8d430 100644 --- a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts +++ b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts @@ -179,9 +179,8 @@ export const TERMINAL_MOUSE_CLICK_DRAG_JS = ` // Why: a dismissing tap only clears the selection (touch parity); it must // not also open a link or focus the keyboard underneath. if (gesture.dismissedSelection) return; - // Plain click == touch tap: links and file paths win, then tracking-mode - // click reports, then keyboard focus. - notifyTerminalSurfaceTap(e.clientX, e.clientY); + // Pointer clicks keep their current link, file, TUI mouse, and focus priority. + notifyTerminalSurfaceTap(e.clientX, e.clientY, false); }, true); targetSurface.addEventListener('pointercancel', function(e) { diff --git a/mobile/src/terminal/terminal-webview-mouse-click.test.ts b/mobile/src/terminal/terminal-webview-mouse-click.test.ts index bb2494b62..4c519deee 100644 --- a/mobile/src/terminal/terminal-webview-mouse-click.test.ts +++ b/mobile/src/terminal/terminal-webview-mouse-click.test.ts @@ -18,6 +18,7 @@ describe('terminal WebView external mouse click', () => { expect(bytes.slice(0, 4)).toBe(`${ESC}[M `) expect(bytes.slice(6, 10)).toBe(`${ESC}[M#`) expect(bytes.slice(4, 6)).toBe(bytes.slice(10, 12)) + expect(mouse.postedMessages().filter((message) => message.type === 'terminal-tap')).toEqual([]) }) it('dismisses an existing selection with a click without focusing the keyboard', () => { diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index 391ccd3a8..32bc6dbd5 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -212,10 +212,12 @@ describe('TerminalWebView scroll routing', () => { "document.addEventListener('touchend'", '}, { capture: true, passive: true });' ) - expect(touchEndBlock).toContain('notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y)') + expect(touchEndBlock).toContain( + 'notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true)' + ) const tapHandlerBlock = sliceBetween( - 'function notifyTerminalSurfaceTap(originX, originY)', + 'function notifyTerminalSurfaceTap(originX, originY, focusKeyboard)', "document.addEventListener('touchstart'" ) expect(tapHandlerBlock.indexOf('oscLinkAtViewportPoint')).toBeLessThan( @@ -229,6 +231,9 @@ describe('TerminalWebView scroll routing', () => { ) expect(tapHandlerBlock).toContain("notify({ type: 'open-url', url: tappedUrl });") expect(tapHandlerBlock).toContain("notify({ type: 'terminal-input', bytes: clickInput });") + expect(tapHandlerBlock).toContain( + 'if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode()))' + ) expect(tapHandlerBlock).toContain("notify({ type: 'terminal-tap' });") }) diff --git a/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts b/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts index ebf36ee1b..23036f84d 100644 --- a/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts +++ b/mobile/src/terminal/terminal-webview-tap-dispatch-injected.ts @@ -163,7 +163,7 @@ export const TERMINAL_TAP_DISPATCH_JS = ` selMode !== 'select' && Date.now() - tapCandidate.t <= TAP_MAX_MS ) { - notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y); + notifyTerminalSurfaceTap(tapCandidate.x, tapCandidate.y, true); } clearLongPress(); tapCandidate = null; diff --git a/mobile/src/terminal/terminal-webview-tap-routing.test.ts b/mobile/src/terminal/terminal-webview-tap-routing.test.ts index 2ab038806..13a81ef61 100644 --- a/mobile/src/terminal/terminal-webview-tap-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-tap-routing.test.ts @@ -19,12 +19,12 @@ function bodyMarkup(): string { } // Minimal xterm stub: one scrollback line containing a URL, fixed 8x15 cells. -function makeTerminal(lineRef: { current: string }) { +function makeTerminal(lineRef: { current: string }, mouseTrackingMode = 'none') { return { cols: 80, rows: 24, options: { fontSize: 13 }, - modes: {}, + modes: { mouseTrackingMode }, element: { scrollWidth: 800, scrollHeight: 360 }, _core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 15 } } } } }, buffer: { @@ -76,13 +76,14 @@ type OscLinkRange = { row: number; startCol: number; endCol: number; uri: string function boot( line: string, - oscLinks?: OscLinkRange[] + oscLinks?: OscLinkRange[], + mouseTrackingMode = 'none' ): { posted: Posted; setLine: (line: string) => void } { const posted: Posted = [] const lineRef = { current: line } const w = window as unknown as { Terminal: unknown; ReactNativeWebView: unknown } w.Terminal = function () { - return makeTerminal(lineRef) + return makeTerminal(lineRef, mouseTrackingMode) } w.ReactNativeWebView = { postMessage(s: string) { @@ -150,6 +151,31 @@ describe('terminal WebView tap routing', () => { expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/foo') }) + it('focuses native input after reporting a touch tap to a mouse-tracking TUI', async () => { + const { posted } = boot('interactive prompt', undefined, 'drag') + await settle() + + fireTouch('touchstart', [{ x: 20, y: tapY }]) + fireTouch('touchend', []) + + expect( + posted + .filter((message) => message.type === 'terminal-input' || message.type === 'terminal-tap') + .map((message) => message.type) + ).toEqual(['terminal-input', 'terminal-tap']) + }) + + it('reports a non-mouse touch tap without terminal mouse bytes', async () => { + const { posted } = boot('plain prompt') + await settle() + + fireTouch('touchstart', [{ x: 20, y: tapY }]) + fireTouch('touchend', []) + + expect(posted.find((message) => message.type === 'terminal-input')).toBeUndefined() + expect(posted.filter((message) => message.type === 'terminal-tap')).toHaveLength(1) + }) + it('opens the URL even right after a width-change reflow', async () => { // Why: scrollback reflow rewraps the local buffer; the tap path must keep // working afterward (the reflow message must not disturb tap routing). diff --git a/mobile/src/terminal/terminal-webview-url-tap.ts b/mobile/src/terminal/terminal-webview-url-tap.ts index 86f87a441..f5d416d57 100644 --- a/mobile/src/terminal/terminal-webview-url-tap.ts +++ b/mobile/src/terminal/terminal-webview-url-tap.ts @@ -205,7 +205,7 @@ export const URL_TAP_WEBVIEW_JS = ` } catch (e) { return 0; } } - function notifyTerminalSurfaceTap(originX, originY) { + function notifyTerminalSurfaceTap(originX, originY, focusKeyboard) { var tappedOscLink = oscLinkAtViewportPoint(originX, originY); if (tappedOscLink && tappedOscLink.kind === 'file') { notify({ @@ -245,7 +245,9 @@ export const URL_TAP_WEBVIEW_JS = ` var clickInput = buildMouseClickInput(originX, originY); if (clickInput) { notify({ type: 'terminal-input', bytes: clickInput }); - } else if (!isClickMouseTrackingMode(getMouseTrackingMode())) { + } + // Touch still needs native input focus after the TUI consumes its mouse click. + if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) { notify({ type: 'terminal-tap' }); } } diff --git a/mobile/src/terminal/use-terminal-live-input-focus.test.ts b/mobile/src/terminal/use-terminal-live-input-focus.test.ts new file mode 100644 index 000000000..f1b6c5ead --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-input-focus.test.ts @@ -0,0 +1,252 @@ +import { createElement, type RefObject } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + TerminalLiveInputFocusTarget, + TerminalLiveInputFocusTimerRef +} from './terminal-live-input' +import { useTerminalLiveInputFocus } from './use-terminal-live-input-focus' + +type HarnessProps = { + readonly activeHandleRef: RefObject + readonly canSend: boolean + readonly inputRef: RefObject + readonly keyboardHeight?: number + readonly lifecycleIdentity: object | null + readonly lifecycleKey: string + readonly liveInputEnabled: boolean + readonly timerRef: TerminalLiveInputFocusTimerRef +} + +type FocusHandlers = ReturnType + +function suppressReactTestRendererWarning(): () => void { + const originalConsoleError = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => spy.mockRestore() +} + +function createFocusTarget(initiallyFocused = false): TerminalLiveInputFocusTarget & { + readonly blur: ReturnType + readonly focus: ReturnType +} { + let focused = initiallyFocused + return { + blur: vi.fn(() => { + focused = false + }), + focus: vi.fn(() => { + focused = true + }), + isFocused: () => focused + } +} + +function createTimerRef(): TerminalLiveInputFocusTimerRef { + return { current: null } +} + +function createHarness(initialProps: HarnessProps): { + readonly handlers: () => FocusHandlers + readonly render: (props: HarnessProps) => void + readonly unmount: () => void +} { + let handlers: FocusHandlers | null = null + let renderer: ReactTestRenderer | null = null + + function Harness(props: HarnessProps): null { + handlers = useTerminalLiveInputFocus({ + ...props, + keyboardHeight: props.keyboardHeight ?? 0 + }) + return null + } + + const restoreWarning = suppressReactTestRendererWarning() + try { + act(() => { + renderer = create(createElement(Harness, initialProps)) + }) + } finally { + restoreWarning() + } + if (!handlers || !renderer) { + throw new Error('terminal live input focus harness did not render') + } + + return { + handlers: () => { + if (!handlers) { + throw new Error('terminal live input focus harness is not mounted') + } + return handlers + }, + render: (props) => { + act(() => renderer?.update(createElement(Harness, props))) + }, + unmount: () => { + act(() => renderer?.unmount()) + handlers = null + } + } +} + +function connectedProps( + inputRef: RefObject, + timerRef = createTimerRef(), + activeHandleRef: RefObject = { current: 'terminal-a' } +): HarnessProps { + return { + activeHandleRef, + canSend: true, + inputRef, + lifecycleIdentity: null, + lifecycleKey: 'host-a:worktree-a:connected', + liveInputEnabled: true, + timerRef + } +} + +describe('terminal live input focus hook', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('defers initial terminal surface focus until the WebView touch has completed', () => { + vi.useFakeTimers() + const input = createFocusTarget() + const harness = createHarness(connectedProps({ current: input })) + + harness.handlers().handleTerminalTap('terminal-a') + expect(input.focus).not.toHaveBeenCalled() + + vi.runOnlyPendingTimers() + expect(input.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) + + it('reacquires focus for mouse-aware and non-mouse terminal tap notifications', () => { + vi.useFakeTimers() + const input = createFocusTarget() + const harness = createHarness(connectedProps({ current: input })) + + harness.handlers().handleTerminalTap('terminal-a') + vi.runOnlyPendingTimers() + harness.handlers().handleTerminalTap('terminal-a') + vi.runAllTimers() + + expect(input.blur).toHaveBeenCalledTimes(1) + expect(input.focus).toHaveBeenCalledTimes(2) + harness.unmount() + }) + + it('cancels focus when navigation reuses the mounted route', () => { + vi.useFakeTimers() + const oldInput = createFocusTarget() + const inputRef: RefObject = { current: oldInput } + const timerRef = createTimerRef() + const harness = createHarness(connectedProps(inputRef, timerRef)) + harness.handlers().handleTerminalTap('terminal-a') + const newInput = createFocusTarget() + inputRef.current = newInput + harness.render({ + ...connectedProps(inputRef, timerRef), + lifecycleKey: 'host-a:worktree-b:connected' + }) + vi.runOnlyPendingTimers() + expect(oldInput.focus).not.toHaveBeenCalled() + expect(newInput.focus).not.toHaveBeenCalled() + + harness.handlers().handleTerminalTap('terminal-a') + vi.runOnlyPendingTimers() + expect(newInput.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) + + it('drops pending focus when reconnect reuses the mounted route', () => { + vi.useFakeTimers() + const oldInput = createFocusTarget() + const oldInputRef = { current: oldInput } + const timerRef = createTimerRef() + const harness = createHarness(connectedProps(oldInputRef, timerRef)) + harness.handlers().handleTerminalTap('terminal-a') + harness.render({ + ...connectedProps(oldInputRef, timerRef), + canSend: false, + lifecycleIdentity: {}, + lifecycleKey: 'host-a:worktree-a:disconnected' + }) + vi.runOnlyPendingTimers() + expect(oldInput.focus).not.toHaveBeenCalled() + + const replacementInput = createFocusTarget() + oldInputRef.current = replacementInput + harness.render({ + ...connectedProps(oldInputRef, timerRef), + lifecycleIdentity: {}, + lifecycleKey: 'host-a:worktree-a:connected' + }) + harness.handlers().handleTerminalTap('terminal-a') + vi.runOnlyPendingTimers() + expect(replacementInput.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) + + it('cancels focus when a retained screen blurs', () => { + vi.useFakeTimers() + const input = createFocusTarget() + const timerRef = createTimerRef() + const harness = createHarness(connectedProps({ current: input }, timerRef)) + + harness.handlers().handleTerminalTap('terminal-a') + harness.handlers().resetLiveInputFocus() + vi.runOnlyPendingTimers() + + expect(timerRef.current).toBeNull() + expect(input.blur).toHaveBeenCalledTimes(1) + expect(input.focus).not.toHaveBeenCalled() + harness.unmount() + }) + + it('does not let stale handle state focus a replacement terminal', () => { + vi.useFakeTimers() + const firstInput = createFocusTarget() + const inputRef: RefObject = { current: firstInput } + const timerRef = createTimerRef() + const activeHandleRef = { current: 'terminal-a' as string | null } + const harness = createHarness(connectedProps(inputRef, timerRef, activeHandleRef)) + harness.handlers().handleTerminalTap('terminal-a') + + const replacementInput = createFocusTarget() + inputRef.current = replacementInput + activeHandleRef.current = 'terminal-b' + harness.render(connectedProps(inputRef, timerRef, activeHandleRef)) + vi.runOnlyPendingTimers() + + expect(firstInput.focus).not.toHaveBeenCalled() + expect(replacementInput.focus).not.toHaveBeenCalled() + harness.handlers().handleTerminalTap('terminal-b') + vi.runOnlyPendingTimers() + expect(replacementInput.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) + + it('keeps the native focus target immediate outside the WebView tap path', () => { + const input = createFocusTarget() + const harness = createHarness(connectedProps({ current: input })) + + harness.handlers().focusLiveInput() + + expect(input.focus).toHaveBeenCalledTimes(1) + harness.unmount() + }) +}) diff --git a/mobile/src/terminal/use-terminal-live-input-focus.ts b/mobile/src/terminal/use-terminal-live-input-focus.ts new file mode 100644 index 000000000..b0ab456b7 --- /dev/null +++ b/mobile/src/terminal/use-terminal-live-input-focus.ts @@ -0,0 +1,86 @@ +import { useCallback, useLayoutEffect, useRef, type RefObject } from 'react' +import { + clearTerminalLiveInputFocusTimer, + focusTerminalLiveInputTarget, + scheduleTerminalLiveInputFocus, + type TerminalLiveInputFocusTarget, + type TerminalLiveInputFocusTimerRef +} from './terminal-live-input' + +type TerminalLiveInputFocusContext = { + readonly canSend: boolean + readonly keyboardHeight: number + readonly liveInputEnabled: boolean +} + +type UseTerminalLiveInputFocusOptions = + TerminalLiveInputFocusContext & { + readonly activeHandleRef: RefObject + readonly inputRef: RefObject + readonly lifecycleIdentity: object | null + readonly lifecycleKey: string + readonly timerRef: TerminalLiveInputFocusTimerRef + } + +type TerminalLiveInputFocusHandlers = { + readonly focusLiveInput: () => void + readonly handleTerminalTap: (handle: string) => void + readonly resetLiveInputFocus: () => void +} + +export function useTerminalLiveInputFocus({ + activeHandleRef, + canSend, + inputRef, + keyboardHeight, + lifecycleIdentity, + lifecycleKey, + liveInputEnabled, + timerRef +}: UseTerminalLiveInputFocusOptions): TerminalLiveInputFocusHandlers { + const contextRef = useRef({ + canSend, + keyboardHeight, + liveInputEnabled + }) + useLayoutEffect(() => { + contextRef.current = { canSend, keyboardHeight, liveInputEnabled } + }, [canSend, keyboardHeight, liveInputEnabled]) + + const resetLiveInputFocus = useCallback(() => { + clearTerminalLiveInputFocusTimer(timerRef) + inputRef.current?.blur() + }, [inputRef, timerRef]) + + // Retained Expo routes must not carry focus work across navigation or reconnect scopes. + useLayoutEffect(() => resetLiveInputFocus, [lifecycleIdentity, lifecycleKey, resetLiveInputFocus]) + + const focusLiveInput = useCallback(() => { + const context = contextRef.current + if (!context.canSend || !context.liveInputEnabled) { + return + } + focusTerminalLiveInputTarget(inputRef.current, { + keyboardHeight: context.keyboardHeight, + refocus: () => scheduleTerminalLiveInputFocus(timerRef, focusLiveInput) + }) + }, [inputRef, timerRef]) + + const handleTerminalTap = useCallback( + (handle: string) => { + const context = contextRef.current + if (handle !== activeHandleRef.current || !context.canSend || !context.liveInputEnabled) { + return + } + // WKWebView still owns first responder during its touchend notification. + scheduleTerminalLiveInputFocus(timerRef, () => { + if (activeHandleRef.current === handle) { + focusLiveInput() + } + }) + }, + [activeHandleRef, focusLiveInput, timerRef] + ) + + return { focusLiveInput, handleTerminalTap, resetLiveInputFocus } +}