Files
orca/mobile/src/terminal/use-terminal-live-input-commit.ts
T
NeilandBrennan Benson 66b599399f fix(mobile): decide terminal preedit from the marked-text range, not a script table (#15007)
* fix(mobile): decide terminal preedit from the marked-text range, not a script table

The live terminal capture field decided what to withhold from the PTY with a
Unicode-block allowlist (Hangul jamo and syllables) and held exactly one trailing
code point. Kana and kanji are not in the table, so a Japanese reading streamed to
the PTY one fragment at a time and was repaired afterwards with DEL bytes (#7427).

A code-point table cannot work, and the counterexample is not exotic: Chinese
pinyin preedit is plain ASCII, and a Japanese romaji reading is one code point on
the first keystroke and three on the fourth. Preedit is a property of the FIELD,
not of the characters in it, so the only signal that identifies it is the text
system's marked-text range. That is what a reference terminal implementation uses
on every platform it supports - `hasMarkedText` there, the input-method context's
composing state elsewhere - and neither one classifies code points anywhere in the
input path.

So the mirror now takes the marked-text report per change and holds the whole
preedit region, whatever its length or script:
- Subscribe the capture field to `onChange`, not `onChangeText`; only the raw
  native event carries the report at all.
- A reported preedit is held entire and is never committed by the settle timer,
  because preedit is not text yet. Explicit boundaries still flush it.
- `isTerminalLiveHangulCodePoint` and its four ranges are deleted.

iOS reports the range but React Native drops it before JS, so the pinned patch
forwards `markedTextRange` into the change payload. It is three hunks and it
compiles because the app already sets `buildReactNativeFromSource` for iOS. The
same idea was proposed in #11450, which is where the patch comes from.

Android has no marked-text report in React Native at all, and a Kotlin patch would
not help: Android consumes the prebuilt react-android artifact, so node_modules
sources are never compiled. Until the report exists there, the fallback holds the
trailing non-ASCII run. It enumerates nothing, it covers kana, kanji and Hangul,
and ASCII keeps its zero-latency echo - but it cannot see an ASCII preedit, so
Chinese pinyin on Android still leaks its reading. Only a report fixes that.

Not-tested: no physical device or emulator was available, so no real IME drove
this path. Japanese, Chinese and Korean composition are covered at the model and
hook level only, and the iOS patch has not been compiled.

Co-authored-by: Brennan Benson <brennanb2025@users.noreply.github.com>

* fix(mobile): bound the fallback hold to text the pty has not received

The no-report branch walked the trailing non-ASCII run over the whole field and
ignored stableLength, unlike the reported branch directly above it. So after a
settle-timer commit the next keystroke re-held everything already delivered and
the caller erased it with DEL and retyped it — a nine-character Cyrillic word
cost a DEL per already-sent character, and for the 300ms before the re-send the
held text was the only copy, so a blur or reconnect destroyed characters the pty
already had.

Bound it the way the reported branch is bounded. Pinned by a test that drives a
settle commit between every keystroke and asserts no DEL reaches the wire.

---------

Co-authored-by: Brennan Benson <brennanb2025@users.noreply.github.com>
2026-08-17 00:01:24 -07:00

227 lines
8.0 KiB
TypeScript

import { useCallback, useEffect, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
import { useTerminalLivePendingInputFlush } from './use-terminal-live-pending-input-flush'
import {
useTerminalLiveAccessoryInputCommit,
type TerminalLiveAccessoryInputCommitResult
} from './use-terminal-live-accessory-input-commit'
type TerminalLiveInputKeyPressEvent = {
readonly nativeEvent: {
readonly key: string
}
}
/** `isComposing` is the text system's marked-text range, forwarded by the pinned
* react-native patch on iOS; `onChangeText` would drop the payload entirely.
* Absent means the platform reports no range — not "not composing". */
type TerminalLiveInputChangeEvent = {
readonly nativeEvent: {
readonly text: string
readonly isComposing?: boolean
}
}
type TerminalLiveInputCommitOptions<TTabType extends string> = {
readonly activeHandle: string | null
readonly activeHandleRef: RefObject<string | null>
readonly activeSessionTabType: TTabType | null | undefined
readonly activeSessionTabTypeRef: RefObject<TTabType | null>
readonly connected: boolean
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly liveInputTerminalHandlesRef: RefObject<Set<string>>
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
}
type TerminalLiveInputCommitHandlers = {
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise<boolean>
readonly handleLiveInputAccessoryBytes: (
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult>
readonly handleLiveInputChange: (event: TerminalLiveInputChangeEvent) => void
readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void
readonly handleLiveInputSubmit: () => void
}
export function useTerminalLiveInputCommit<TTabType extends string>({
activeHandle,
activeHandleRef,
activeSessionTabType,
activeSessionTabTypeRef,
connected,
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
const {
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
} = useTerminalLivePendingInputFlush({
activeHandleRef,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture
})
useEffect(() => {
// Why: what reached the PTY is unknowable across an outage — stale mirror state corrupts the first post-reconnect send.
if (!connected) {
clearPendingLiveInputCommit()
}
}, [connected, clearPendingLiveInputCommit])
useEffect(() => {
const pendingHandle = pendingLiveInputHandleRef.current
if (!pendingHandle) {
return
}
// Why: a lagging mobile tab list briefly yields no active tab object; a
// null/undefined type is "unknown", not "left the terminal" — flush guards
// still block sends if the tab truly changed.
if (
!activeHandle ||
pendingHandle !== activeHandle ||
(activeSessionTabType != null && activeSessionTabType !== 'terminal') ||
!liveInputTerminalHandles.has(activeHandle)
) {
clearPendingLiveInputCommit()
}
}, [activeHandle, activeSessionTabType, clearPendingLiveInputCommit, liveInputTerminalHandles])
const flushPendingLiveInputBeforeExternalSend = useCallback(
async (handle: string): Promise<boolean> => {
const pendingHandle = pendingLiveInputHandleRef.current
if (pendingHandle && pendingHandle !== handle) {
clearPendingLiveInputCommit()
return waitForPendingLiveInputFlush()
}
// Why: external bytes (dictation/paste) land after the field's echo on the
// PTY; the field session must fully end or later diffs would erase them.
if (pendingHandle === handle) {
return flushPendingLiveInputText(handle)
}
return waitForPendingLiveInputFlush()
},
[clearPendingLiveInputCommit, flushPendingLiveInputText, waitForPendingLiveInputFlush]
)
const handleLiveInputChange = useCallback(
({ nativeEvent }: TerminalLiveInputChangeEvent) => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
clearPendingLiveInputCommit()
return
}
// Why: iOS kills an active dictation/IME session when JS writes a value
// that differs from the native field text, so the controlled capture must
// echo the field verbatim; only the PTY mirror sees normalized text.
setLiveInputCapture(nativeEvent.text)
applyLiveInputMirror(
activeHandle,
normalizeTerminalTextInput(nativeEvent.text),
nativeEvent.isComposing
)
},
[
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
liveInputTerminalHandles,
setLiveInputCapture
]
)
const handleLiveInputKeyPress = useCallback(
(event: TerminalLiveInputKeyPressEvent) => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return
}
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
clearPendingLiveInputCommit()
}
const decision = getTerminalLiveSpecialKeyDecision({
key: event.nativeEvent.key,
heldText: ownsPendingState ? heldLiveInputTextRef.current : '',
sentText: ownsPendingState ? sentLiveInputTextRef.current : ''
})
switch (decision.kind) {
case 'ignore':
case 'local-edit':
return
case 'send-now':
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return
case 'commit-held-then-send':
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return
default:
decision satisfies never
}
},
[
activeHandle,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputTerminalHandles,
sendLiveTerminalInputRef,
waitForPendingLiveInputFlush
]
)
const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
})
const handleLiveInputSubmit = useCallback(() => {
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return
}
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, '\r')
)
}, [activeHandle, flushPendingLiveInputText, liveInputTerminalHandles, sendLiveTerminalInputRef])
return {
clearPendingLiveInputCommit,
flushPendingLiveInputBeforeExternalSend,
handleLiveInputAccessoryBytes,
handleLiveInputChange,
handleLiveInputKeyPress,
handleLiveInputSubmit
}
}