Fix mobile live input keyboard and Korean IME (#7273)

* feat: 모바일 터미널 한글 미러 스텝 순수 모델 추가

* feat: 미러 델타 순서 보장용 send 체인 추가

* fix: 모바일 터미널 한글 입력을 미러 모델로 전환

* fix: 탭 상태 지연 중 한글 조합 상태 소실 방지

* fix: 미러 가드와 send 체인 리뷰 지적사항 반영

탭 상태 지연으로 활성 탭 타입이 일시적으로 null이 될 때 runMirrorStep의 stale-handle 가드가 조합 중 음절을 버리지 않도록 pending-clear 효과와 동일한 null 허용 패턴 적용. 테스트 하네스가 ref와 prop을 동일 소스에서 파생하도록 결합해 실제 경로의 lag 프레임을 검증. queueTerminalLiveMirrorSend의 previousSend await를 catch로 보호.

* refactor(mobile): drop dead queueTerminalLivePendingFlush orphaned by the mirror model

The mirror model migrated all live-input sends to queueTerminalLiveMirrorSend,
leaving queueTerminalLivePendingFlush referenced only by its own tests. Remove
the dead function and its three tests.

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): expose live terminal keyboard target

Co-authored-by: Orca <help@stably.ai>

* fix(mobile): refocus live keyboard after dismissal

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: realitsyourman <wongil@demodev.io>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-07-03 16:47:59 -07:00 committed by GitHub
parent 2c03133975
commit e8c2b79a93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1067 additions and 576 deletions

View File

@ -96,6 +96,7 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi
import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target'
import {
clearTerminalLiveInputFocusTimer,
focusTerminalLiveInputTarget,
isTerminalLiveInputWithinByteLimit,
scheduleTerminalLiveInputFocus
} from '../../../../src/terminal/terminal-live-input'
@ -3134,8 +3135,12 @@ export default function SessionScreen() {
if (!canSend || !liveInputEnabled) {
return
}
liveInputRef.current?.focus()
}, [canSend, liveInputEnabled])
focusTerminalLiveInputTarget(liveInputRef.current, {
keyboardHeight,
refocus: () =>
scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus())
})
}, [canSend, keyboardHeight, liveInputEnabled])
const clearSessionTabActionSheetKeyboardListener = useCallback(() => {
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
@ -4947,10 +4952,16 @@ export default function SessionScreen() {
{liveInputEnabled ? (
<View style={[styles.inputBar, styles.liveInputBar]}>
<Pressable
style={styles.liveInputFocusTarget}
style={({ pressed }) => [
styles.liveInputFocusTarget,
pressed && styles.liveInputFocusTargetPressed,
!canSend && styles.liveInputFocusTargetDisabled
]}
disabled={!canSend}
onPress={focusLiveInput}
accessibilityLabel="Focus live terminal input"
accessibilityRole="button"
accessibilityLabel="Show keyboard for live terminal input"
accessibilityHint="Typed text is sent directly to the active terminal"
>
<KeyboardIcon size={16} color={colors.textSecondary} strokeWidth={2} />
<MobileTerminalLiveInputStatus
@ -4981,6 +4992,7 @@ export default function SessionScreen() {
onKeyPress={handleLiveInputKeyPress}
onSubmitEditing={handleLiveInputSubmit}
placeholder=""
showSoftInputOnFocus
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}

View File

@ -141,7 +141,20 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
minHeight: 34,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
paddingHorizontal: spacing.sm + 2
},
liveInputFocusTargetPressed: {
backgroundColor: colors.borderSubtle
},
liveInputFocusTargetDisabled: {
opacity: 0.45
},
liveInputCapture: {

View File

@ -27,9 +27,11 @@ export function MobileTerminalLiveInputStatus({
? 'Tap mic to stop'
: dictation.isProcessing
? 'Transcribing on desktop'
: isAttaching
? 'Uploading image to host'
: 'Keyboard goes directly to terminal'
: dictation.isStarting
? 'Preparing microphone'
: isAttaching
? 'Uploading image to host'
: 'Tap to show keyboard'
return (
<View style={styles.status}>

View File

@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest'
import {
buildTerminalLiveMirrorPayload,
computeTerminalLiveMirrorStep,
isTerminalLiveHangulCodePoint,
type TerminalLiveMirrorStep
} from './terminal-live-hangul-mirror'
type MirrorRun = {
readonly payloads: readonly string[]
readonly sentText: string
readonly heldText: string
}
function runMirrorSequence(
fieldStates: readonly string[],
options: { readonly commitAtEnd: boolean } = { commitAtEnd: false }
): MirrorRun {
const payloads: string[] = []
let sentText = ''
let heldText = ''
for (const fieldText of fieldStates) {
const step = computeTerminalLiveMirrorStep(sentText, fieldText, { commitHeld: false })
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length > 0) {
payloads.push(payload)
}
sentText = step.nextSentText
heldText = step.heldText
}
if (options.commitAtEnd) {
const lastField = sentText + heldText
const step = computeTerminalLiveMirrorStep(sentText, lastField, { commitHeld: true })
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length > 0) {
payloads.push(payload)
}
sentText = step.nextSentText
heldText = step.heldText
}
return { payloads, sentText, heldText }
}
describe('terminal live hangul mirror', () => {
it('Given single-syllable composition When steps run Then leaks no jamo and commits only the final syllable', () => {
// Given / When
const run = runMirrorSequence(['ㅎ', '하', '한'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['한'])
expect(run.sentText).toBe('한')
expect(run.heldText).toBe('')
})
it('Given multi-syllable composition When a new syllable starts Then streams the stable prefix without erases', () => {
// Given / When
const run = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['한', '글'])
expect(run.sentText).toBe('한글')
})
it('Given dubeolsik resplit 간→가나 When steps run Then never sends the intermediate syllable', () => {
// Given / When
const run = runMirrorSequence(['ㄱ', '가', '간', '가나'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['가', '나'])
expect(run.sentText).toBe('가나')
})
it('Given a timer-committed syllable When composition continues Then erases and recommits via DEL correction', () => {
// Given: '하' was committed by the settle timer
const commit = computeTerminalLiveMirrorStep('', '하', { commitHeld: true })
expect(buildTerminalLiveMirrorPayload(commit)).toBe('하')
expect(commit.nextSentText).toBe('하')
// When: user keeps composing '하' → '한'
const correction = computeTerminalLiveMirrorStep(commit.nextSentText, '한', {
commitHeld: false
})
// Then: one DEL erases the stale syllable; the new one is held again
expect(buildTerminalLiveMirrorPayload(correction)).toBe('\x7f')
expect(correction.nextSentText).toBe('')
expect(correction.heldText).toBe('한')
const recommit = computeTerminalLiveMirrorStep('', '한', { commitHeld: true })
expect(buildTerminalLiveMirrorPayload(recommit)).toBe('한')
})
it('Given pure ASCII typing When steps run Then mirrors immediately with no held text', () => {
// Given / When
const run = runMirrorSequence(['a', 'ab', 'abc'])
// Then
expect(run.payloads).toEqual(['a', 'b', 'c'])
expect(run.heldText).toBe('')
})
it('Given a trailing space after Hangul When the step runs Then the space commits the held syllable', () => {
// Given: '한글' typed, '한' streamed, '글' held
const beforeSpace = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'])
expect(beforeSpace.sentText).toBe('한')
expect(beforeSpace.heldText).toBe('글')
// When
const step = computeTerminalLiveMirrorStep(beforeSpace.sentText, '한글 ', {
commitHeld: false
})
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('글 ')
expect(step.heldText).toBe('')
expect(step.nextSentText).toBe('한글 ')
})
it('Given a trailing ASCII letter after Hangul When the step runs Then Hangul is committed with the letter', () => {
// Given
const held = computeTerminalLiveMirrorStep('', '한', { commitHeld: false })
expect(held.heldText).toBe('한')
// When
const step = computeTerminalLiveMirrorStep(held.nextSentText, '한a', { commitHeld: false })
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('한a')
expect(step.heldText).toBe('')
})
it('Given sent text When the user deletes everything Then erases with one DEL per code point', () => {
// Given / When
const step = computeTerminalLiveMirrorStep('한글a', '', { commitHeld: false })
// Then
expect(step).toEqual<TerminalLiveMirrorStep>({
eraseCount: 3,
appendText: '',
nextSentText: '',
heldText: ''
})
expect(buildTerminalLiveMirrorPayload(step)).toBe('\x7f\x7f\x7f')
})
it('Given empty field and empty sent text When committing Then produces a zero step', () => {
// Given / When
const step = computeTerminalLiveMirrorStep('', '', { commitHeld: true })
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('')
expect(step).toEqual<TerminalLiveMirrorStep>({
eraseCount: 0,
appendText: '',
nextSentText: '',
heldText: ''
})
})
it('Given non-Hangul IME text When the step runs Then it mirrors immediately without holding', () => {
// Given / When
const chinese = computeTerminalLiveMirrorStep('', '你好', { commitHeld: false })
const vietnamese = computeTerminalLiveMirrorStep('', 'tiếng', { commitHeld: false })
// Then
expect(buildTerminalLiveMirrorPayload(chinese)).toBe('你好')
expect(chinese.heldText).toBe('')
expect(buildTerminalLiveMirrorPayload(vietnamese)).toBe('tiếng')
expect(vietnamese.heldText).toBe('')
})
it('Given Hangul code point ranges When checked Then jamo and syllables match and ASCII does not', () => {
expect(isTerminalLiveHangulCodePoint('ㅎ'.codePointAt(0) ?? 0)).toBe(true)
expect(isTerminalLiveHangulCodePoint('한'.codePointAt(0) ?? 0)).toBe(true)
expect(isTerminalLiveHangulCodePoint('a'.codePointAt(0) ?? 0)).toBe(false)
expect(isTerminalLiveHangulCodePoint('あ'.codePointAt(0) ?? 0)).toBe(false)
})
})

View File

@ -0,0 +1,61 @@
// Why: paused composition should still reach the PTY quickly; corrections make
// a premature commit safe, so this can be short without leaking jamo forever.
export const TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS = 300
const TERMINAL_DEL_BYTE = '\x7f'
export function isTerminalLiveHangulCodePoint(codePoint: number): boolean {
return (
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
(codePoint >= 0xac00 && codePoint <= 0xd7af)
)
}
export type TerminalLiveMirrorStep = {
readonly eraseCount: number
readonly appendText: string
readonly nextSentText: string
readonly heldText: string
}
// Why: React Native exposes no composition events, but Hangul composition only
// mutates the trailing syllable. Holding just that code point keeps the PTY
// echo live while preedit jamo never leak; DEL corrections repair any commit
// that later turns out to be premature.
export function computeTerminalLiveMirrorStep(
sentText: string,
fieldText: string,
options: { readonly commitHeld: boolean }
): TerminalLiveMirrorStep {
const fieldCodePoints = Array.from(fieldText)
const lastCodePoint = fieldCodePoints.at(-1)
const holdLast =
!options.commitHeld &&
lastCodePoint !== undefined &&
isTerminalLiveHangulCodePoint(lastCodePoint.codePointAt(0) ?? 0)
const heldText = holdLast && lastCodePoint !== undefined ? lastCodePoint : ''
const targetCodePoints = holdLast ? fieldCodePoints.slice(0, -1) : fieldCodePoints
const sentCodePoints = Array.from(sentText)
let commonPrefixLength = 0
while (
commonPrefixLength < sentCodePoints.length &&
commonPrefixLength < targetCodePoints.length &&
sentCodePoints[commonPrefixLength] === targetCodePoints[commonPrefixLength]
) {
commonPrefixLength += 1
}
return {
eraseCount: sentCodePoints.length - commonPrefixLength,
appendText: targetCodePoints.slice(commonPrefixLength).join(''),
nextSentText: targetCodePoints.join(''),
heldText
}
}
export function buildTerminalLiveMirrorPayload(step: TerminalLiveMirrorStep): string {
return TERMINAL_DEL_BYTE.repeat(step.eraseCount) + step.appendText
}

View File

@ -0,0 +1,49 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const sessionRouteSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
const liveInputStatusSource = readFileSync(
new URL('../session/MobileTerminalLiveInputStatus.tsx', import.meta.url),
'utf8'
)
const commandInputStylesSource = readFileSync(
new URL('../../app/h/[hostId]/session/mobile-session-command-input-styles.ts', import.meta.url),
'utf8'
)
function liveInputBarBlock(): string {
const start = sessionRouteSource.indexOf('{liveInputEnabled ? (')
expect(start).toBeGreaterThanOrEqual(0)
const end = sessionRouteSource.indexOf(') : (', start)
expect(end).toBeGreaterThan(start)
return sessionRouteSource.slice(start, end)
}
describe('terminal live input affordance', () => {
it('keeps the live status row wired as the keyboard focus control', () => {
const block = liveInputBarBlock()
expect(block).toContain('onPress={focusLiveInput}')
expect(block).toContain('accessibilityRole="button"')
expect(block).toContain('accessibilityLabel="Show keyboard for live terminal input"')
expect(block).toContain(
'accessibilityHint="Typed text is sent directly to the active terminal"'
)
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')
})
it('makes the live keyboard target visible instead of status-only chrome', () => {
expect(liveInputStatusSource).toContain("'Tap to show keyboard'")
expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised')
expect(commandInputStylesSource).toContain('borderWidth: 1')
expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed')
})
})

View File

@ -5,10 +5,12 @@ import {
clearTerminalLiveInputFocusTimer,
defaultTerminalLiveInputHandles,
filterTerminalLiveInputDefaultCandidates,
focusTerminalLiveInputTarget,
getTerminalLiveSpecialKeyBytes,
isTerminalLiveInputWithinByteLimit,
pruneTerminalLiveInputHandles,
scheduleTerminalLiveInputFocus,
type TerminalLiveInputFocusTarget,
type TerminalLiveInputFocusTimerRef
} from './terminal-live-input'
@ -16,6 +18,14 @@ function createTimerRef(): TerminalLiveInputFocusTimerRef {
return { current: null }
}
function createFocusTarget(isFocused: () => boolean): TerminalLiveInputFocusTarget {
return {
blur: vi.fn(),
focus: vi.fn(),
isFocused
}
}
describe('terminal live input', () => {
afterEach(() => {
vi.useRealTimers()
@ -181,4 +191,37 @@ describe('terminal live input', () => {
expect(focus).not.toHaveBeenCalled()
expect(timerRef.current).toBeNull()
})
it('refocuses an already-focused capture input when the keyboard is closed', () => {
const input = createFocusTarget(() => true)
const refocus = vi.fn()
focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus })
expect(input.blur).toHaveBeenCalledTimes(1)
expect(input.focus).not.toHaveBeenCalled()
expect(refocus).toHaveBeenCalledTimes(1)
})
it('focuses the capture input directly when the keyboard is open', () => {
const input = createFocusTarget(() => true)
const refocus = vi.fn()
focusTerminalLiveInputTarget(input, { keyboardHeight: 240, refocus })
expect(input.blur).not.toHaveBeenCalled()
expect(input.focus).toHaveBeenCalledTimes(1)
expect(refocus).not.toHaveBeenCalled()
})
it('focuses the capture input directly when it is not already focused', () => {
const input = createFocusTarget(() => false)
const refocus = vi.fn()
focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus })
expect(input.blur).not.toHaveBeenCalled()
expect(input.focus).toHaveBeenCalledTimes(1)
expect(refocus).not.toHaveBeenCalled()
})
})

View File

@ -66,6 +66,17 @@ export type TerminalLiveInputFocusTimerRef = {
current: ReturnType<typeof setTimeout> | null
}
export type TerminalLiveInputFocusTarget = {
readonly focus: () => void
readonly blur: () => void
readonly isFocused?: () => boolean
}
type FocusTerminalLiveInputTargetOptions = {
readonly keyboardHeight: number
readonly refocus: () => void
}
export type TerminalLiveInputDefaultResult = {
enabledHandles: ReadonlySet<string>
defaultedHandles: ReadonlySet<string>
@ -217,4 +228,23 @@ export function scheduleTerminalLiveInputFocus(
}, delayMs)
}
export function focusTerminalLiveInputTarget(
input: TerminalLiveInputFocusTarget | null,
{ keyboardHeight, refocus }: FocusTerminalLiveInputTargetOptions
): void {
if (!input) {
return
}
if (keyboardHeight <= 0 && input.isFocused?.()) {
// Why: Android can keep a hidden TextInput focused after the IME is dismissed;
// focus() is then a no-op, so force a new focus session to reopen the keyboard.
input.blur()
refocus()
return
}
input.focus()
}
export { TERMINAL_LIVE_INPUT_MAX_BYTES }

View File

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import {
queueTerminalLivePendingFlush,
queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush,
type TerminalLivePendingFlushState
} from './terminal-live-pending-flush-state'
@ -64,83 +64,54 @@ describe('terminal live pending flush state', () => {
await expect(controlSend).resolves.toBe(false)
expect(events).toEqual([])
})
})
it('Given a queued pending flush When it resolves or rejects Then clears the barrier', async () => {
describe('terminal live mirror send queue', () => {
it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => {
// Given
const resolvedState: TerminalLivePendingFlushState = { current: null }
const rejectedState: TerminalLivePendingFlushState = { current: null }
const state: TerminalLivePendingFlushState = { current: null }
const order: string[] = []
const first = queueTerminalLiveMirrorSend(state, async () => {
order.push('first')
return false
})
// When
await expect(queueTerminalLivePendingFlush(resolvedState, async () => true)).resolves.toBe(true)
await expect(
queueTerminalLivePendingFlush(rejectedState, async () => {
throw new Error('send failed')
})
).resolves.toBe(false)
const second = queueTerminalLiveMirrorSend(state, async () => {
order.push('second')
return true
})
// Then
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(true)
expect(order).toEqual(['first', 'second'])
})
it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => {
// Given
const state: TerminalLivePendingFlushState = { current: null }
const first = queueTerminalLiveMirrorSend(state, async () => {
throw new Error('boom')
})
// When
const second = queueTerminalLiveMirrorSend(state, async () => true)
// Then
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(true)
})
it('Given a settled mirror send When it was the newest Then the state resets to null', async () => {
// Given
const state: TerminalLivePendingFlushState = { current: null }
// When
await queueTerminalLiveMirrorSend(state, async () => true)
await Promise.resolve()
// Then
expect(resolvedState.current).toBeNull()
expect(rejectedState.current).toBeNull()
})
it('Given a current pending snapshot while another flush is in flight When queued Then sends current text after prior success', async () => {
// Given
const events: string[] = []
let resolveFirstFlush: (value: boolean) => void = () => {}
const firstFlush = new Promise<boolean>((resolve) => {
resolveFirstFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: firstFlush }
// When
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
events.push('second-flush')
return true
})
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
await Promise.resolve()
// Then
expect(events).toEqual([])
resolveFirstFlush(true)
await expect(secondFlush).resolves.toBe(true)
await expect(controlSend).resolves.toBe(true)
expect(events).toEqual(['second-flush', 'control'])
})
it('Given a prior pending flush fails When another snapshot is queued Then skips the current text and control', async () => {
// Given
const events: string[] = []
let resolveFirstFlush: (value: boolean) => void = () => {}
const firstFlush = new Promise<boolean>((resolve) => {
resolveFirstFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: firstFlush }
// When
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
events.push('second-flush')
return true
})
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
resolveFirstFlush(false)
// Then
await expect(secondFlush).resolves.toBe(false)
await expect(controlSend).resolves.toBe(false)
expect(events).toEqual([])
expect(state.current).toBeNull()
})
})

View File

@ -8,22 +8,26 @@ export function waitForTerminalLivePendingFlush(
return state.current ?? Promise.resolve(true)
}
export function queueTerminalLivePendingFlush(
// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped
// delta desyncs every later diff, so this chain runs each send even when the
// previous one failed. state.current should never reject; the catch keeps a
// future raw assignment from skipping a delta.
export function queueTerminalLiveMirrorSend(
state: TerminalLivePendingFlushState,
sendPendingText: () => Promise<boolean>
sendMirrorPayload: () => Promise<boolean>
): Promise<boolean> {
const previousFlush = state.current
const flushPromise = (async () => {
if (previousFlush && !(await previousFlush)) {
return false
const previousSend = state.current
const sendPromise = (async () => {
if (previousSend) {
await previousSend.catch(() => false)
}
return sendPendingText()
return sendMirrorPayload()
})().catch(() => false)
state.current = flushPromise
void flushPromise.then(() => {
if (state.current === flushPromise) {
state.current = sendPromise
void sendPromise.then(() => {
if (state.current === sendPromise) {
state.current = null
}
})
return flushPromise
return sendPromise
}

View File

@ -1,234 +1,118 @@
import { describe, expect, it } from 'vitest'
import {
TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS,
getTerminalLiveAccessoryBytesDecision,
getTerminalLiveAccessoryLocalEditText,
getTerminalLiveDeferredTextDelayMs,
getTerminalLiveSpecialKeyDecision,
getTerminalLiveSubmitSequence,
getTerminalLiveTextChangeDecision,
isTerminalLiveTextHangulCandidate,
isTerminalLiveTextImeCandidate
getTerminalLiveSpecialKeyDecision
} from './terminal-live-text-commit'
describe('terminal live text commit', () => {
it('Given Korean IME changes When live text changes Then defers candidates and submits only final text before carriage return', () => {
// Given
const koreanCompositionSteps = ['ㅎ', '하', '한'] as const
// When
const decisions = koreanCompositionSteps.map(getTerminalLiveTextChangeDecision)
const sentImmediately = decisions.filter((decision) => decision.kind === 'send-now')
const submitSequence = getTerminalLiveSubmitSequence('한')
const composedWordDecision = getTerminalLiveTextChangeDecision('한글')
const composedWordSubmitSequence = getTerminalLiveSubmitSequence('한글')
// Then
expect(decisions).toEqual([
{ kind: 'defer', text: 'ㅎ', delayMs: null },
{ kind: 'defer', text: '하', delayMs: null },
{ kind: 'defer', text: '한', delayMs: null }
])
expect(sentImmediately).toEqual([])
expect(isTerminalLiveTextHangulCandidate('ㅎ')).toBe(true)
expect(getTerminalLiveDeferredTextDelayMs('ㅎ')).toBeNull()
expect(submitSequence).toEqual(['한', '\r'])
expect(composedWordDecision).toEqual({
kind: 'defer',
text: '한글',
delayMs: null
describe('terminal live special key decision', () => {
it('Given an unmapped key Then ignores it', () => {
expect(getTerminalLiveSpecialKeyDecision({ key: 'ㅎ', heldText: '', sentText: '' })).toEqual({
kind: 'ignore'
})
expect(composedWordSubmitSequence).toEqual(['한글', '\r'])
})
it('Given non-Hangul IME text When live text changes Then keeps the bounded settle timer', () => {
// Given
const text = 'あ'
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
expect(decision).toEqual({ kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS })
it('Given Backspace with any field text Then edits locally so the mirror diff handles the PTY erase', () => {
// Held syllable present
expect(
getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '한', sentText: '' })
).toEqual({ kind: 'local-edit' })
// Only mirrored text present — native edit fires onChangeText and the diff erases
expect(
getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '', sentText: 'abc' })
).toEqual({ kind: 'local-edit' })
})
it('Given Chinese and Vietnamese IME text When live text changes Then does not use Hangul-only indefinite deferral', () => {
// Given
const nonHangulImeTexts = ['你好', 'tiếng Việt'] as const
for (const text of nonHangulImeTexts) {
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
expect(decision).toEqual({
kind: 'defer',
text,
delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
})
}
})
it('Given ASCII text When live text changes Then sends immediately', () => {
// Given
const text = 'abc123'
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
expect(decision).toEqual({ kind: 'send-now', text })
})
it('Given empty text When live text changes Then ignores the change', () => {
// Given
const text = ''
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
expect(decision).toEqual({ kind: 'ignore' })
})
it('Given pending text When Backspace or Delete is pressed Then keeps edits local', () => {
// Given
const pendingText = '한'
// When
const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
// Then
expect(backspaceDecision).toEqual({ kind: 'local-edit' })
expect(deleteDecision).toEqual({ kind: 'local-edit' })
})
it('Given no pending text When Backspace or Delete is pressed Then sends terminal bytes', () => {
// Given
const pendingText = ''
// When
const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
// Then
expect(backspaceDecision).toEqual({ kind: 'send-now', bytes: '\x7f' })
expect(deleteDecision).toEqual({ kind: 'send-now', bytes: '\x1b[3~' })
})
it('Given pending text When a terminal special key is pressed Then flushes pending text before bytes', () => {
// Given
const pendingText = '한'
// When
const decision = getTerminalLiveSpecialKeyDecision({ key: 'Tab', pendingText })
// Then
expect(decision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
})
it('Given pending text When accessory control bytes are requested Then flushes pending text before bytes', () => {
// Given
const pendingText = '한글'
// When
const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
const escapeDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\x1b', pendingText })
const enterDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\r', pendingText })
// Then
expect(tabDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
expect(escapeDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\x1b' })
expect(enterDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\r' })
})
it('Given pending text When accessory Backspace or Delete bytes are requested Then keeps edits local', () => {
// Given
const pendingText = '한글'
// When
const backspaceDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
pendingText
it('Given Backspace with an empty field Then sends terminal backspace bytes', () => {
const decision = getTerminalLiveSpecialKeyDecision({
key: 'Backspace',
heldText: '',
sentText: ''
})
const ctrlBackspaceDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\b',
localEdit: 'backspace',
pendingText
})
const deleteDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x1b[3~',
localEdit: 'delete',
pendingText
})
const customDeleteByteDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
pendingText
})
const backspaceText = getTerminalLiveAccessoryLocalEditText({
localEdit: 'backspace',
pendingText
})
const deleteText = getTerminalLiveAccessoryLocalEditText({
localEdit: 'delete',
pendingText
})
// Then
expect(backspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
expect(ctrlBackspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
expect(deleteDecision).toEqual({ kind: 'local-edit', localEdit: 'delete' })
expect(customDeleteByteDecision).toEqual({
kind: 'flush-then-send',
pendingText,
bytes: '\x7f'
})
expect(backspaceText).toBe('한')
expect(deleteText).toBe('한글')
expect(getTerminalLiveDeferredTextDelayMs(backspaceText)).toBeNull()
expect(getTerminalLiveDeferredTextDelayMs(deleteText)).toBeNull()
expect(decision.kind).toBe('send-now')
})
it('Given no pending text When accessory bytes are requested Then sends terminal bytes', () => {
// Given
const pendingText = ''
// When
const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
// Then
expect(tabDecision).toEqual({ kind: 'send-now', bytes: '\t' })
it('Given a control key with a held syllable Then commits the held text before the bytes', () => {
const decision = getTerminalLiveSpecialKeyDecision({
key: 'Tab',
heldText: '글',
sentText: '한'
})
expect(decision.kind).toBe('commit-held-then-send')
})
it('Given a non-special key When key decision is requested Then ignores it', () => {
// Given
const key = 'a'
// When
const decision = getTerminalLiveSpecialKeyDecision({ key, pendingText: '한' })
// Then
expect(decision).toEqual({ kind: 'ignore' })
})
it('Given no pending text When submit is requested Then sends only carriage return', () => {
// Given
const pendingText = ''
// When
const sequence = getTerminalLiveSubmitSequence(pendingText)
// Then
expect(sequence).toEqual(['\r'])
it('Given a control key with no held syllable Then sends immediately', () => {
const decision = getTerminalLiveSpecialKeyDecision({
key: 'ArrowUp',
heldText: '',
sentText: 'ls'
})
expect(decision.kind).toBe('send-now')
})
})
describe('terminal live accessory bytes decision', () => {
it('Given a local-edit accessory key with field text Then edits locally', () => {
expect(
getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
heldText: '한',
sentText: ''
})
).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
expect(
getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
heldText: '',
sentText: 'abc'
})
).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
})
it('Given raw accessory bytes with a held syllable Then commits held text first', () => {
const decision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x1b',
heldText: '한',
sentText: ''
})
expect(decision).toEqual({ kind: 'commit-held-then-send', bytes: '\x1b' })
})
it('Given raw accessory bytes with nothing held Then sends immediately', () => {
const decision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x1b',
heldText: '',
sentText: 'abc'
})
expect(decision).toEqual({ kind: 'send-now', bytes: '\x1b' })
})
it('Given a local-edit accessory key with an empty field Then sends the raw bytes', () => {
const decision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
heldText: '',
sentText: ''
})
expect(decision).toEqual({ kind: 'send-now', bytes: '\x7f' })
})
})
describe('terminal live accessory local edit text', () => {
it('Given backspace Then drops the last code point of the field text', () => {
expect(
getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한글' })
).toBe('한')
expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한' })).toBe(
''
)
})
it('Given forward delete Then keeps the field text unchanged', () => {
expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'delete', fieldText: '한글' })).toBe(
'한글'
)
})
})

View File

@ -1,23 +1,15 @@
import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input'
// Why: React Native does not expose portable composition events here, so
// non-Hangul IME text gets a short settle window before being sent to the PTY.
export const TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS = 150
export type TerminalLiveTextChangeDecision =
| { readonly kind: 'ignore' }
| { readonly kind: 'send-now'; readonly text: string }
| { readonly kind: 'defer'; readonly text: string; readonly delayMs: number | null }
export type TerminalLiveSpecialKeyDecision =
| { readonly kind: 'ignore' }
| { readonly kind: 'local-edit' }
| { readonly kind: 'send-now'; readonly bytes: string }
| { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
| { readonly kind: 'commit-held-then-send'; readonly bytes: string }
export type TerminalLiveSpecialKeyDecisionInput = {
readonly key: string
readonly pendingText: string
readonly heldText: string
readonly sentText: string
}
export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
@ -25,78 +17,33 @@ export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
export type TerminalLiveAccessoryBytesDecision =
| { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit }
| { readonly kind: 'send-now'; readonly bytes: string }
| { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
| { readonly kind: 'commit-held-then-send'; readonly bytes: string }
export type TerminalLiveAccessoryBytesDecisionInput = {
readonly bytes: string
readonly localEdit?: TerminalLiveAccessoryLocalEdit
readonly pendingText: string
}
export function isTerminalLiveTextImeCandidate(text: string): boolean {
for (const character of text) {
const codePoint = character.codePointAt(0)
if (codePoint !== undefined && codePoint > 0x7f) {
return true
}
}
return false
}
function isHangulCodePoint(codePoint: number): boolean {
return (
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
(codePoint >= 0xac00 && codePoint <= 0xd7af)
)
}
export function isTerminalLiveTextHangulCandidate(text: string): boolean {
for (const character of text) {
const codePoint = character.codePointAt(0)
if (codePoint !== undefined && isHangulCodePoint(codePoint)) {
return true
}
}
return false
}
export function getTerminalLiveTextChangeDecision(text: string): TerminalLiveTextChangeDecision {
if (text.length === 0) {
return { kind: 'ignore' }
}
if (isTerminalLiveTextHangulCandidate(text)) {
return { kind: 'defer', text, delayMs: null }
}
if (isTerminalLiveTextImeCandidate(text)) {
return { kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS }
}
return { kind: 'send-now', text }
}
export function getTerminalLiveDeferredTextDelayMs(text: string): number | null {
return isTerminalLiveTextHangulCandidate(text) ? null : TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
readonly heldText: string
readonly sentText: string
}
export function getTerminalLiveSpecialKeyDecision({
key,
pendingText
heldText,
sentText
}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision {
const bytes = getTerminalLiveSpecialKeyBytes(key)
if (bytes === null) {
return { kind: 'ignore' }
}
if (pendingText.length > 0 && (key === 'Backspace' || key === 'Delete')) {
// Why: native field edits fire onChangeText and the mirror diff emits the
// matching PTY erase; sending raw DEL here as well would double-erase.
if ((key === 'Backspace' || key === 'Delete') && (heldText.length > 0 || sentText.length > 0)) {
return { kind: 'local-edit' }
}
if (pendingText.length > 0) {
return { kind: 'flush-then-send', pendingText, bytes }
if (heldText.length > 0) {
return { kind: 'commit-held-then-send', bytes }
}
return { kind: 'send-now', bytes }
@ -105,14 +52,15 @@ export function getTerminalLiveSpecialKeyDecision({
export function getTerminalLiveAccessoryBytesDecision({
bytes,
localEdit,
pendingText
heldText,
sentText
}: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision {
if (pendingText.length > 0 && localEdit) {
if (localEdit && (heldText.length > 0 || sentText.length > 0)) {
return { kind: 'local-edit', localEdit }
}
if (pendingText.length > 0) {
return { kind: 'flush-then-send', pendingText, bytes }
if (heldText.length > 0) {
return { kind: 'commit-held-then-send', bytes }
}
return { kind: 'send-now', bytes }
@ -120,26 +68,16 @@ export function getTerminalLiveAccessoryBytesDecision({
export function getTerminalLiveAccessoryLocalEditText({
localEdit,
pendingText
fieldText
}: {
readonly localEdit: TerminalLiveAccessoryLocalEdit
readonly pendingText: string
readonly fieldText: string
}): string {
if (localEdit === 'delete') {
// Why: accessory Delete mirrors forward-delete at the hidden input's end;
// it stays local but does not remove the pending IME text.
return pendingText
// it stays local but does not remove the field text.
return fieldText
}
return Array.from(pendingText).slice(0, -1).join('')
}
export type TerminalLiveSubmitSequence = readonly ['\r'] | readonly [string, '\r']
export function getTerminalLiveSubmitSequence(pendingText: string): TerminalLiveSubmitSequence {
if (pendingText.length === 0) {
return ['\r']
}
return [pendingText, '\r']
return Array.from(fieldText).slice(0, -1).join('')
}

View File

@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest'
import { getTerminalLiveAccessoryInactiveInputCommitResult } from './use-terminal-live-accessory-input-commit'
import { createElement, type RefObject } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { TextInput } from 'react-native'
import { describe, expect, it, vi } from 'vitest'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import {
getTerminalLiveAccessoryInactiveInputCommitResult,
useTerminalLiveAccessoryInputCommit,
type TerminalLiveAccessoryInputCommitResult
} from './use-terminal-live-accessory-input-commit'
type DeferredBoolean = {
readonly promise: Promise<boolean>
@ -16,7 +25,111 @@ function createDeferredBoolean(): DeferredBoolean {
return { promise, resolve: resolvePromise }
}
describe('terminal live accessory input commit', () => {
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()
}
type AccessoryInputCommitHarnessOptions = {
readonly heldText?: string
readonly sentText?: string
readonly pendingHandle?: string | null
readonly sendResult?: boolean
readonly flushResult?: boolean
readonly waitResult?: boolean
}
type AccessoryInputCommitHarness = {
readonly commit: (
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult>
readonly sent: readonly string[]
readonly applyLiveInputMirror: ReturnType<typeof vi.fn>
readonly flushPendingLiveInputText: ReturnType<typeof vi.fn>
readonly waitForPendingLiveInputFlush: ReturnType<typeof vi.fn>
readonly unmount: () => void
}
function createAccessoryInputCommitHarness({
heldText = '',
sentText = '',
pendingHandle = null,
sendResult = true,
flushResult = true,
waitResult = true
}: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness {
const activeHandle = 'terminal-a'
const heldLiveInputTextRef: RefObject<string> = { current: heldText }
const sentLiveInputTextRef: RefObject<string> = { current: sentText }
const pendingLiveInputHandleRef: RefObject<string | null> = { current: pendingHandle }
const liveInputRef: RefObject<TextInput | null> = { current: null }
const liveInputTerminalHandles = new Set([activeHandle])
const sent: string[] = []
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
current: async (_handle, bytes) => {
sent.push(bytes)
return sendResult
}
}
const applyLiveInputMirror = vi.fn((_handle: string, _fieldText: string) => {})
const clearPendingLiveInputCommit = vi.fn(() => {})
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
const waitForPendingLiveInputFlush = vi.fn(async () => waitResult)
const setLiveInputCapture = vi.fn((_text: string) => {})
let commit: AccessoryInputCommitHarness['commit'] | null = null
let renderer: ReactTestRenderer | null = null
function Harness(): null {
commit = useTerminalLiveAccessoryInputCommit({
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
})
return null
}
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
act(() => {
renderer = create(createElement(Harness))
})
} finally {
restoreConsoleError()
}
if (!commit || !renderer) {
throw new Error('terminal live accessory input hook did not render')
}
return {
commit,
sent,
applyLiveInputMirror,
flushPendingLiveInputText,
waitForPendingLiveInputFlush,
unmount: () => {
act(() => renderer?.unmount())
}
}
}
describe('terminal live accessory inactive input commit result', () => {
it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => {
// Given
const deferredFlush = createDeferredBoolean()
@ -50,3 +163,68 @@ describe('terminal live accessory input commit', () => {
expect(result).toEqual({ kind: 'suppress-raw' })
})
})
describe('terminal live accessory input commit hook', () => {
it('Given raw accessory bytes with a held syllable When committed Then flushes held text before sending bytes', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '한',
sentText: '',
pendingHandle: 'terminal-a'
})
// When
const result = await harness.commit({ bytes: '\x1b' })
// Then
expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a')
expect(harness.sent).toEqual(['\x1b'])
expect(result).toEqual({ kind: 'handled' })
})
it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => {
// Given
const harness = createAccessoryInputCommitHarness({ pendingHandle: null })
// When
const result = await harness.commit({ bytes: '\x1b' })
// Then
expect(result).toEqual({ kind: 'allow-raw' })
expect(harness.flushPendingLiveInputText).not.toHaveBeenCalled()
expect(harness.sent).toEqual([])
})
it('Given accessory backspace with a held syllable When committed Then mirrors the emptied field without terminal bytes', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '한',
sentText: '',
pendingHandle: 'terminal-a'
})
// When
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '')
expect(result).toEqual({ kind: 'handled' })
expect(harness.sent).toEqual([])
})
it('Given accessory backspace with mirrored sent text When committed Then mirrors the shortened field so the diff emits DEL', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '',
sentText: 'ab',
pendingHandle: 'terminal-a'
})
// When
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a')
expect(result).toEqual({ kind: 'handled' })
})
})

View File

@ -2,8 +2,7 @@ import { useCallback, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import {
getTerminalLiveAccessoryBytesDecision,
getTerminalLiveAccessoryLocalEditText,
getTerminalLiveDeferredTextDelayMs
getTerminalLiveAccessoryLocalEditText
} from './terminal-live-text-commit'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
@ -14,12 +13,6 @@ export type TerminalLiveAccessoryInputCommitResult =
| { readonly kind: 'handled' }
| { readonly kind: 'suppress-raw' }
type TerminalLiveInputCommitScheduler = (
handle: string,
text: string,
delayMs: number | null
) => void
export async function getTerminalLiveAccessoryInactiveInputCommitResult(
waitForPendingLiveInputFlush: () => Promise<boolean>
): Promise<TerminalLiveAccessoryInputCommitResult> {
@ -28,13 +21,14 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult(
type TerminalLiveAccessoryInputCommitOptions = {
readonly activeHandle: string | null
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly pendingLiveInputTextRef: RefObject<string>
readonly schedulePendingLiveInputCommit: TerminalLiveInputCommitScheduler
readonly sentLiveInputTextRef: RefObject<string>
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
@ -42,13 +36,14 @@ type TerminalLiveAccessoryInputCommitOptions = {
export function useTerminalLiveAccessoryInputCommit({
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
@ -63,40 +58,33 @@ export function useTerminalLiveAccessoryInputCommit({
if (!liveInputTerminalHandles.has(activeHandle)) {
return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
}
const pendingText =
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
clearPendingLiveInputCommit()
}
const decision = getTerminalLiveAccessoryBytesDecision({ ...input, pendingText })
const heldText = ownsPendingState ? heldLiveInputTextRef.current : ''
const sentText = ownsPendingState ? sentLiveInputTextRef.current : ''
const decision = getTerminalLiveAccessoryBytesDecision({ ...input, heldText, sentText })
switch (decision.kind) {
case 'send-now':
// Why: raw accessory bytes must wait behind any in-flight IME text
// flush so composed Hangul reaches the PTY before follow-up controls.
// Why: raw accessory bytes must wait behind any in-flight mirror send
// so composed Hangul reaches the PTY before follow-up controls.
return (await waitForPendingLiveInputFlush())
? { kind: 'allow-raw' }
: { kind: 'suppress-raw' }
case 'local-edit': {
const editedText = getTerminalLiveAccessoryLocalEditText({
localEdit: decision.localEdit,
pendingText
fieldText: sentText + heldText
})
if (editedText.length === 0) {
clearPendingLiveInputCommit()
return { kind: 'handled' }
}
// Why: accessory buttons do not emit native TextInput edits, so the
// pending IME buffer must be edited and rescheduled here.
// field is edited here and the mirror diff syncs the PTY echo.
setLiveInputCapture(editedText)
liveInputRef.current?.setNativeProps({ text: editedText })
schedulePendingLiveInputCommit(
activeHandle,
editedText,
getTerminalLiveDeferredTextDelayMs(editedText)
)
applyLiveInputMirror(activeHandle, editedText)
return { kind: 'handled' }
}
case 'flush-then-send':
case 'commit-held-then-send':
await sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
@ -109,13 +97,14 @@ export function useTerminalLiveAccessoryInputCommit({
},
[
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush

View File

@ -3,13 +3,14 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { TextInput } from 'react-native'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } from './terminal-live-text-commit'
import { TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS } from './terminal-live-hangul-mirror'
import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
type TerminalLiveInputCommitHarness = {
readonly captures: readonly string[]
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
readonly sent: readonly string[]
readonly setActiveSessionTabType: (next: string | undefined) => void
readonly unmount: () => void
}
@ -36,6 +37,9 @@ function createTerminalLiveInputCommitHarness({
const activeHandleRef: RefObject<string | null> = { current: activeHandle }
const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' }
const captures: string[] = []
const setLiveInputCapture = (text: string): void => {
captures.push(text)
}
const liveInputRef: RefObject<TextInput | null> = { current: null }
const liveInputTerminalHandles = new Set([activeHandle])
const liveInputTerminalHandlesRef: RefObject<Set<string>> = {
@ -48,6 +52,9 @@ function createTerminalLiveInputCommitHarness({
return sendResult
}
}
// The hook keeps live-input state in refs, so a change handler alone never
// re-renders; only a prop change (this variable) re-runs the pending-clear effect.
let currentActiveSessionTabType: string | undefined = 'terminal'
let handlers: ReturnType<typeof useTerminalLiveInputCommit<string>> | null = null
let renderer: ReactTestRenderer | null = null
@ -55,13 +62,13 @@ function createTerminalLiveInputCommitHarness({
handlers = useTerminalLiveInputCommit({
activeHandle,
activeHandleRef,
activeSessionTabType: 'terminal',
activeSessionTabType: currentActiveSessionTabType,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture: (text) => captures.push(text)
setLiveInputCapture
})
return null
}
@ -82,6 +89,15 @@ function createTerminalLiveInputCommitHarness({
captures,
handlers,
sent,
setActiveSessionTabType: (next: string | undefined): void => {
currentActiveSessionTabType = next
// Ref and prop derive from the same activeSessionTab in the real route, so
// they go null together during tab-list lag — keep the harness coupled.
activeSessionTabTypeRef.current = next ?? null
act(() => {
renderer?.update(createElement(Harness))
})
},
unmount: () => {
act(() => renderer?.unmount())
}
@ -93,18 +109,48 @@ describe('terminal live input commit hook', () => {
vi.useRealTimers()
})
it('Given Hangul pending text When the old idle window elapses Then does not send jamo to the terminal', async () => {
it('Given Hangul composition When steps arrive Then streams the stable prefix and never leaks jamo', async () => {
// Given
vi.useFakeTimers()
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When: ㅎ→하→한→한ㄱ→한그→한글 (no settle pause between steps)
for (const fieldText of ['ㅎ', '하', '한', '한ㄱ', '한그', '한글']) {
handlers.handleLiveInputChange(fieldText)
await vi.advanceTimersByTimeAsync(50)
}
// Then: only the stable prefix went out; the trailing syllable is held
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputChange('ㅎ')
await vi.advanceTimersByTimeAsync(1_000)
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
// Then
expect(captures).toEqual(['ㅎ'])
expect(sent).toEqual([])
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given a timer-committed syllable When composition continues Then corrects with DEL and recommits', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('하')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
await vi.waitFor(() => expect(sent).toEqual(['하']))
// When
handlers.handleLiveInputChange('한')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
// Then
await vi.waitFor(() => expect(sent).toEqual(['하', '\x7f', '한']))
})
it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => {
@ -119,6 +165,55 @@ describe('terminal live input commit hook', () => {
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
})
it('Given no pending text When submit is requested Then sends only carriage return', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputSubmit()
// Then
await vi.waitFor(() => expect(sent).toEqual(['\r']))
})
it('Given a rejected held-text send When submit is requested Then suppresses the carriage return', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputSubmit()
await Promise.resolve()
await Promise.resolve()
// Then: the held commit went out but was not accepted, so no \r follows
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given ASCII typing When changes arrive Then mirrors immediately', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputChange('a')
handlers.handleLiveInputChange('ab')
// Then
await vi.waitFor(() => expect(sent).toEqual(['a', 'b']))
})
it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputChange('한 ')
// Then
await vi.waitFor(() => expect(sent).toEqual(['한 ']))
})
it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
@ -145,34 +240,22 @@ describe('terminal live input commit hook', () => {
expect(sent).toEqual(['한'])
})
it('Given Chinese and Vietnamese IME text When the settle window elapses Then sends the committed text', async () => {
it('Given non-Hangul IME text When changes arrive Then mirrors immediately without a settle window', async () => {
// Given
vi.useFakeTimers()
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputChange('你好')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS - 1)
// Then
expect(captures).toEqual(['你好'])
expect(sent).toEqual([])
// When
await vi.advanceTimersByTimeAsync(1)
await vi.waitFor(() => expect(sent).toEqual(['你好']))
handlers.handleLiveInputChange('tiếng Việt')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
// Then
await vi.waitFor(() => expect(sent).toEqual(['你好', 'tiếng Việt']))
})
it('Given deferred IME text When the hook unmounts Then cancels the pending commit timer', async () => {
it('Given a held syllable When the hook unmounts Then cancels the settle timer', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('é')
handlers.handleLiveInputChange('')
// When
unmount()
@ -181,4 +264,54 @@ describe('terminal live input commit hook', () => {
// Then
expect(sent).toEqual([])
})
it('Given Backspace with field text When the key arrives Then edits locally without terminal bytes', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Backspace' } })
// Then
await vi.waitFor(() => expect(sent).toEqual([]))
})
it('Given Tab with a held syllable When the key arrives Then commits the syllable before the tab bytes', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Tab' } })
// Then
await vi.waitFor(() => expect(sent).toEqual(['한', '\t']))
})
it('Given Hangul pending When the tab type lags to undefined Then keeps the composition state', async () => {
// Given: '한' held while the active tab is still a terminal
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When: the mobile tab list momentarily yields no active tab object
setActiveSessionTabType(undefined)
handlers.handleLiveInputSubmit()
// Then: an unknown tab type is not "left the terminal", so pending still flushes
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
})
it('Given Hangul pending When the tab genuinely changes to non-terminal Then clears the composition state', async () => {
// Given: '한' held while the active tab is still a terminal
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When: the active tab actually becomes a non-terminal (chat) tab
setActiveSessionTabType('chat')
handlers.handleLiveInputSubmit()
// Then: pending was dropped, so submit sends only the carriage return
await vi.waitFor(() => expect(sent).toEqual(['\r']))
})
})

View File

@ -1,10 +1,6 @@
import { useCallback, useEffect, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import {
getTerminalLiveSpecialKeyDecision,
getTerminalLiveSubmitSequence,
getTerminalLiveTextChangeDecision
} from './terminal-live-text-commit'
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'
@ -56,11 +52,12 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
setLiveInputCapture
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
const {
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
} = useTerminalLivePendingInputFlush({
activeHandleRef,
@ -76,10 +73,13 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
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 !== 'terminal' ||
(activeSessionTabType != null && activeSessionTabType !== 'terminal') ||
!liveInputTerminalHandles.has(activeHandle)
) {
clearPendingLiveInputCommit()
@ -93,7 +93,9 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
clearPendingLiveInputCommit()
return waitForPendingLiveInputFlush()
}
if (pendingHandle === handle && pendingLiveInputTextRef.current.length > 0) {
// 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()
@ -108,35 +110,15 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
return
}
const normalizedText = normalizeTerminalTextInput(text)
const decision = getTerminalLiveTextChangeDecision(normalizedText)
switch (decision.kind) {
case 'ignore':
clearPendingLiveInputCommit()
return
case 'send-now':
clearPendingLiveInputCommit()
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, decision.text)
)
return
case 'defer':
// Why: React Native does not expose composition events here, so keep
// probable IME text in the native field until the commit timer settles.
setLiveInputCapture(decision.text)
schedulePendingLiveInputCommit(activeHandle, decision.text, decision.delayMs)
return
default:
decision satisfies never
}
setLiveInputCapture(normalizedText)
applyLiveInputMirror(activeHandle, normalizedText)
},
[
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
liveInputTerminalHandles,
schedulePendingLiveInputCommit,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
setLiveInputCapture
]
)
@ -145,26 +127,25 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return
}
const pendingText =
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
clearPendingLiveInputCommit()
}
const decision = getTerminalLiveSpecialKeyDecision({
key: event.nativeEvent.key,
pendingText
heldText: ownsPendingState ? heldLiveInputTextRef.current : '',
sentText: ownsPendingState ? sentLiveInputTextRef.current : ''
})
switch (decision.kind) {
case 'ignore':
case 'local-edit':
return
case 'send-now':
clearPendingLiveInputCommit()
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return
case 'flush-then-send':
case 'commit-held-then-send':
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
@ -186,13 +167,14 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
@ -202,28 +184,11 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return
}
const pendingText =
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
const sequence = getTerminalLiveSubmitSequence(pendingText)
if (sequence.length === 2) {
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, sequence[1])
)
return
}
clearPendingLiveInputCommit()
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, sequence[0])
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, '\r')
)
}, [
activeHandle,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputTerminalHandles,
sendLiveTerminalInputRef,
waitForPendingLiveInputFlush
])
}, [activeHandle, flushPendingLiveInputText, liveInputTerminalHandles, sendLiveTerminalInputRef])
return {
clearPendingLiveInputCommit,

View File

@ -2,7 +2,12 @@ import { useCallback, useEffect, useRef, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import {
queueTerminalLivePendingFlush,
buildTerminalLiveMirrorPayload,
computeTerminalLiveMirrorStep,
TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS
} from './terminal-live-hangul-mirror'
import {
queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state'
@ -16,15 +21,12 @@ type TerminalLivePendingInputFlushOptions<TTabType extends string> = {
}
type TerminalLivePendingInputFlush = {
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly pendingLiveInputTextRef: RefObject<string>
readonly schedulePendingLiveInputCommit: (
handle: string,
text: string,
delayMs: number | null
) => void
readonly sentLiveInputTextRef: RefObject<string>
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
}
@ -36,104 +38,143 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
sendLiveTerminalInputRef,
setLiveInputCapture
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush {
const liveInputCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingLiveInputFlushRef = useRef<Promise<boolean> | null>(null)
const pendingLiveInputTextRef = useRef('')
const heldLiveInputTextRef = useRef('')
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null)
const runMirrorStepRef = useRef<
(handle: string, fieldText: string, commitHeld: boolean) => Promise<boolean>
>(async () => false)
const clearHeldCommitTimer = useCallback(() => {
if (heldCommitTimerRef.current) {
clearTimeout(heldCommitTimerRef.current)
heldCommitTimerRef.current = null
}
}, [])
const resetMirrorState = useCallback(() => {
clearHeldCommitTimer()
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
}, [clearHeldCommitTimer])
const clearPendingLiveInputCommit = useCallback(() => {
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
liveInputCommitTimerRef.current = null
}
pendingLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
resetMirrorState()
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
}, [liveInputRef, setLiveInputCapture])
}, [liveInputRef, resetMirrorState, setLiveInputCapture])
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
}, [])
const flushPendingLiveInputText = useCallback(
async (expectedHandle: string | null): Promise<boolean> => {
const existingFlush = pendingLiveInputFlushRef.current
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
liveInputCommitTimerRef.current = null
}
const handle = pendingLiveInputHandleRef.current
const text = pendingLiveInputTextRef.current
pendingLiveInputHandleRef.current = null
pendingLiveInputTextRef.current = ''
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
if (!handle || text.length === 0) {
return existingFlush ?? false
}
const runMirrorStep = useCallback(
async (handle: string, fieldText: string, commitHeld: boolean): Promise<boolean> => {
if (
(expectedHandle !== null && handle !== expectedHandle) ||
handle !== activeHandleRef.current ||
activeSessionTabTypeRef.current !== 'terminal' ||
(activeSessionTabTypeRef.current != null &&
activeSessionTabTypeRef.current !== 'terminal') ||
!liveInputTerminalHandlesRef.current.has(handle)
) {
// Why: a stale handle must not keep local mirror state alive — the next
// active terminal would inherit wrong erase counts. A null tab type is
// "unknown" during tab-list lag, not "left the terminal", so it must not trip.
resetMirrorState()
return false
}
return queueTerminalLivePendingFlush(pendingLiveInputFlushRef, () =>
sendLiveTerminalInputRef.current(handle, text)
const step = computeTerminalLiveMirrorStep(sentLiveInputTextRef.current, fieldText, {
commitHeld
})
sentLiveInputTextRef.current = step.nextSentText
heldLiveInputTextRef.current = step.heldText
pendingLiveInputHandleRef.current =
step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null
clearHeldCommitTimer()
if (step.heldText.length > 0) {
heldCommitTimerRef.current = setTimeout(() => {
heldCommitTimerRef.current = null
const heldField = sentLiveInputTextRef.current + heldLiveInputTextRef.current
void runMirrorStepRef.current(handle, heldField, true)
}, TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
}
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length === 0) {
return waitForPendingLiveInputFlush()
}
return queueTerminalLiveMirrorSend(pendingLiveInputFlushRef, () =>
sendLiveTerminalInputRef.current(handle, payload)
)
},
[
activeHandleRef,
activeSessionTabTypeRef,
liveInputRef,
clearHeldCommitTimer,
liveInputTerminalHandlesRef,
resetMirrorState,
sendLiveTerminalInputRef,
setLiveInputCapture
waitForPendingLiveInputFlush
]
)
runMirrorStepRef.current = runMirrorStep
const schedulePendingLiveInputCommit = useCallback(
(handle: string, text: string, delayMs: number | null) => {
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
}
pendingLiveInputHandleRef.current = handle
pendingLiveInputTextRef.current = text
if (delayMs === null) {
liveInputCommitTimerRef.current = null
return
}
liveInputCommitTimerRef.current = setTimeout(() => {
liveInputCommitTimerRef.current = null
void flushPendingLiveInputText(handle)
}, delayMs)
const applyLiveInputMirror = useCallback(
(handle: string, fieldText: string): void => {
void runMirrorStep(handle, fieldText, false)
},
[flushPendingLiveInputText]
[runMirrorStep]
)
const flushPendingLiveInputText = useCallback(
async (expectedHandle: string | null): Promise<boolean> => {
const handle = pendingLiveInputHandleRef.current
if (!handle) {
return waitForPendingLiveInputFlush()
}
if (expectedHandle !== null && handle !== expectedHandle) {
clearPendingLiveInputCommit()
return waitForPendingLiveInputFlush()
}
const heldText = heldLiveInputTextRef.current
const result =
heldText.length > 0
? await runMirrorStep(handle, sentLiveInputTextRef.current + heldText, true)
: await waitForPendingLiveInputFlush()
// Why: an explicit flush ends the field's editing session; the echoed PTY
// text stays, so local mirror state must restart from empty.
clearPendingLiveInputCommit()
return result
},
[clearPendingLiveInputCommit, runMirrorStep, waitForPendingLiveInputFlush]
)
useEffect(() => {
return () => {
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
liveInputCommitTimerRef.current = null
if (heldCommitTimerRef.current) {
clearTimeout(heldCommitTimerRef.current)
heldCommitTimerRef.current = null
}
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
pendingLiveInputTextRef.current = ''
pendingLiveInputFlushRef.current = null
}
}, [])
return {
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
}
}