fix(mobile): focus Kimi terminal input after touch (#11865)

* fix(mobile): focus terminal input after TUI touch

* fix(mobile): defer terminal focus after WebView taps

* fix(mobile): reset deferred terminal focus on route blur
This commit is contained in:
Brennan Benson 2026-08-01 01:40:55 -07:00 committed by GitHub
parent 340faaa839
commit c2e3d13efe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 413 additions and 37 deletions

View File

@ -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<boolean | null>(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<boolean | null>(
@ -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(

View File

@ -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', () => {

View File

@ -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) {

View File

@ -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', () => {

View File

@ -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' });")
})

View File

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

View File

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

View File

@ -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' });
}
}

View File

@ -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<string | null>
readonly canSend: boolean
readonly inputRef: RefObject<TerminalLiveInputFocusTarget | null>
readonly keyboardHeight?: number
readonly lifecycleIdentity: object | null
readonly lifecycleKey: string
readonly liveInputEnabled: boolean
readonly timerRef: TerminalLiveInputFocusTimerRef
}
type FocusHandlers = ReturnType<typeof useTerminalLiveInputFocus>
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<typeof vi.fn>
readonly focus: ReturnType<typeof vi.fn>
} {
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<TerminalLiveInputFocusTarget | null>,
timerRef = createTimerRef(),
activeHandleRef: RefObject<string | null> = { 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<TerminalLiveInputFocusTarget | null> = { 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<TerminalLiveInputFocusTarget | null> = { 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()
})
})

View File

@ -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<T extends TerminalLiveInputFocusTarget> =
TerminalLiveInputFocusContext & {
readonly activeHandleRef: RefObject<string | null>
readonly inputRef: RefObject<T | null>
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<T extends TerminalLiveInputFocusTarget>({
activeHandleRef,
canSend,
inputRef,
keyboardHeight,
lifecycleIdentity,
lifecycleKey,
liveInputEnabled,
timerRef
}: UseTerminalLiveInputFocusOptions<T>): TerminalLiveInputFocusHandlers {
const contextRef = useRef<TerminalLiveInputFocusContext>({
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 }
}