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
co-authored by Orca realitsyourman Neil
parent 2c03133975
commit e8c2b79a93
17 changed files with 1061 additions and 570 deletions
+16 -4
View File
@@ -96,6 +96,7 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi
import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target' import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target'
import { import {
clearTerminalLiveInputFocusTimer, clearTerminalLiveInputFocusTimer,
focusTerminalLiveInputTarget,
isTerminalLiveInputWithinByteLimit, isTerminalLiveInputWithinByteLimit,
scheduleTerminalLiveInputFocus scheduleTerminalLiveInputFocus
} from '../../../../src/terminal/terminal-live-input' } from '../../../../src/terminal/terminal-live-input'
@@ -3134,8 +3135,12 @@ export default function SessionScreen() {
if (!canSend || !liveInputEnabled) { if (!canSend || !liveInputEnabled) {
return return
} }
liveInputRef.current?.focus() focusTerminalLiveInputTarget(liveInputRef.current, {
}, [canSend, liveInputEnabled]) keyboardHeight,
refocus: () =>
scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus())
})
}, [canSend, keyboardHeight, liveInputEnabled])
const clearSessionTabActionSheetKeyboardListener = useCallback(() => { const clearSessionTabActionSheetKeyboardListener = useCallback(() => {
sessionTabActionSheetKeyboardHideSubRef.current?.remove() sessionTabActionSheetKeyboardHideSubRef.current?.remove()
@@ -4947,10 +4952,16 @@ export default function SessionScreen() {
{liveInputEnabled ? ( {liveInputEnabled ? (
<View style={[styles.inputBar, styles.liveInputBar]}> <View style={[styles.inputBar, styles.liveInputBar]}>
<Pressable <Pressable
style={styles.liveInputFocusTarget} style={({ pressed }) => [
styles.liveInputFocusTarget,
pressed && styles.liveInputFocusTargetPressed,
!canSend && styles.liveInputFocusTargetDisabled
]}
disabled={!canSend} disabled={!canSend}
onPress={focusLiveInput} 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} /> <KeyboardIcon size={16} color={colors.textSecondary} strokeWidth={2} />
<MobileTerminalLiveInputStatus <MobileTerminalLiveInputStatus
@@ -4981,6 +4992,7 @@ export default function SessionScreen() {
onKeyPress={handleLiveInputKeyPress} onKeyPress={handleLiveInputKeyPress}
onSubmitEditing={handleLiveInputSubmit} onSubmitEditing={handleLiveInputSubmit}
placeholder="" placeholder=""
showSoftInputOnFocus
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
spellCheck={false} spellCheck={false}
@@ -141,7 +141,20 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
minHeight: 34, minHeight: 34,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', 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: { liveInputCapture: {
@@ -27,9 +27,11 @@ export function MobileTerminalLiveInputStatus({
? 'Tap mic to stop' ? 'Tap mic to stop'
: dictation.isProcessing : dictation.isProcessing
? 'Transcribing on desktop' ? 'Transcribing on desktop'
: isAttaching : dictation.isStarting
? 'Uploading image to host' ? 'Preparing microphone'
: 'Keyboard goes directly to terminal' : isAttaching
? 'Uploading image to host'
: 'Tap to show keyboard'
return ( return (
<View style={styles.status}> <View style={styles.status}>
@@ -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)
})
})
@@ -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
}
@@ -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')
})
})
@@ -5,10 +5,12 @@ import {
clearTerminalLiveInputFocusTimer, clearTerminalLiveInputFocusTimer,
defaultTerminalLiveInputHandles, defaultTerminalLiveInputHandles,
filterTerminalLiveInputDefaultCandidates, filterTerminalLiveInputDefaultCandidates,
focusTerminalLiveInputTarget,
getTerminalLiveSpecialKeyBytes, getTerminalLiveSpecialKeyBytes,
isTerminalLiveInputWithinByteLimit, isTerminalLiveInputWithinByteLimit,
pruneTerminalLiveInputHandles, pruneTerminalLiveInputHandles,
scheduleTerminalLiveInputFocus, scheduleTerminalLiveInputFocus,
type TerminalLiveInputFocusTarget,
type TerminalLiveInputFocusTimerRef type TerminalLiveInputFocusTimerRef
} from './terminal-live-input' } from './terminal-live-input'
@@ -16,6 +18,14 @@ function createTimerRef(): TerminalLiveInputFocusTimerRef {
return { current: null } return { current: null }
} }
function createFocusTarget(isFocused: () => boolean): TerminalLiveInputFocusTarget {
return {
blur: vi.fn(),
focus: vi.fn(),
isFocused
}
}
describe('terminal live input', () => { describe('terminal live input', () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers() vi.useRealTimers()
@@ -181,4 +191,37 @@ describe('terminal live input', () => {
expect(focus).not.toHaveBeenCalled() expect(focus).not.toHaveBeenCalled()
expect(timerRef.current).toBeNull() 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()
})
}) })
@@ -66,6 +66,17 @@ export type TerminalLiveInputFocusTimerRef = {
current: ReturnType<typeof setTimeout> | null 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 = { export type TerminalLiveInputDefaultResult = {
enabledHandles: ReadonlySet<string> enabledHandles: ReadonlySet<string>
defaultedHandles: ReadonlySet<string> defaultedHandles: ReadonlySet<string>
@@ -217,4 +228,23 @@ export function scheduleTerminalLiveInputFocus(
}, delayMs) }, 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 } export { TERMINAL_LIVE_INPUT_MAX_BYTES }
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import { import {
queueTerminalLivePendingFlush, queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush, waitForTerminalLivePendingFlush,
type TerminalLivePendingFlushState type TerminalLivePendingFlushState
} from './terminal-live-pending-flush-state' } from './terminal-live-pending-flush-state'
@@ -64,83 +64,54 @@ describe('terminal live pending flush state', () => {
await expect(controlSend).resolves.toBe(false) await expect(controlSend).resolves.toBe(false)
expect(events).toEqual([]) 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 // Given
const resolvedState: TerminalLivePendingFlushState = { current: null } const state: TerminalLivePendingFlushState = { current: null }
const rejectedState: TerminalLivePendingFlushState = { current: null } const order: string[] = []
const first = queueTerminalLiveMirrorSend(state, async () => {
order.push('first')
return false
})
// When // When
await expect(queueTerminalLivePendingFlush(resolvedState, async () => true)).resolves.toBe(true) const second = queueTerminalLiveMirrorSend(state, async () => {
await expect( order.push('second')
queueTerminalLivePendingFlush(rejectedState, async () => { return true
throw new Error('send failed') })
})
).resolves.toBe(false) // 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() await Promise.resolve()
// Then // Then
expect(resolvedState.current).toBeNull() expect(state.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([])
}) })
}) })
@@ -8,22 +8,26 @@ export function waitForTerminalLivePendingFlush(
return state.current ?? Promise.resolve(true) 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, state: TerminalLivePendingFlushState,
sendPendingText: () => Promise<boolean> sendMirrorPayload: () => Promise<boolean>
): Promise<boolean> { ): Promise<boolean> {
const previousFlush = state.current const previousSend = state.current
const flushPromise = (async () => { const sendPromise = (async () => {
if (previousFlush && !(await previousFlush)) { if (previousSend) {
return false await previousSend.catch(() => false)
} }
return sendPendingText() return sendMirrorPayload()
})().catch(() => false) })().catch(() => false)
state.current = flushPromise state.current = sendPromise
void flushPromise.then(() => { void sendPromise.then(() => {
if (state.current === flushPromise) { if (state.current === sendPromise) {
state.current = null state.current = null
} }
}) })
return flushPromise return sendPromise
} }
@@ -1,234 +1,118 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS,
getTerminalLiveAccessoryBytesDecision, getTerminalLiveAccessoryBytesDecision,
getTerminalLiveAccessoryLocalEditText, getTerminalLiveAccessoryLocalEditText,
getTerminalLiveDeferredTextDelayMs, getTerminalLiveSpecialKeyDecision
getTerminalLiveSpecialKeyDecision,
getTerminalLiveSubmitSequence,
getTerminalLiveTextChangeDecision,
isTerminalLiveTextHangulCandidate,
isTerminalLiveTextImeCandidate
} from './terminal-live-text-commit' } from './terminal-live-text-commit'
describe('terminal live text commit', () => { describe('terminal live special key decision', () => {
it('Given Korean IME changes When live text changes Then defers candidates and submits only final text before carriage return', () => { it('Given an unmapped key Then ignores it', () => {
// Given expect(getTerminalLiveSpecialKeyDecision({ key: 'ㅎ', heldText: '', sentText: '' })).toEqual({
const koreanCompositionSteps = ['ㅎ', '하', '한'] as const kind: 'ignore'
// 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
}) })
expect(composedWordSubmitSequence).toEqual(['한글', '\r'])
}) })
it('Given non-Hangul IME text When live text changes Then keeps the bounded settle timer', () => { it('Given Backspace with any field text Then edits locally so the mirror diff handles the PTY erase', () => {
// Given // Held syllable present
const text = 'あ' expect(
getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '한', sentText: '' })
// When ).toEqual({ kind: 'local-edit' })
const decision = getTerminalLiveTextChangeDecision(text) // Only mirrored text present — native edit fires onChangeText and the diff erases
expect(
// Then getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '', sentText: 'abc' })
expect(isTerminalLiveTextImeCandidate(text)).toBe(true) ).toEqual({ kind: 'local-edit' })
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 Chinese and Vietnamese IME text When live text changes Then does not use Hangul-only indefinite deferral', () => { it('Given Backspace with an empty field Then sends terminal backspace bytes', () => {
// Given const decision = getTerminalLiveSpecialKeyDecision({
const nonHangulImeTexts = ['你好', 'tiếng Việt'] as const key: 'Backspace',
heldText: '',
for (const text of nonHangulImeTexts) { sentText: ''
// 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
}) })
const ctrlBackspaceDecision = getTerminalLiveAccessoryBytesDecision({ expect(decision.kind).toBe('send-now')
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()
}) })
it('Given no pending text When accessory bytes are requested Then sends terminal bytes', () => { it('Given a control key with a held syllable Then commits the held text before the bytes', () => {
// Given const decision = getTerminalLiveSpecialKeyDecision({
const pendingText = '' key: 'Tab',
heldText: '글',
// When sentText: '한'
const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText }) })
expect(decision.kind).toBe('commit-held-then-send')
// Then
expect(tabDecision).toEqual({ kind: 'send-now', bytes: '\t' })
}) })
it('Given a non-special key When key decision is requested Then ignores it', () => { it('Given a control key with no held syllable Then sends immediately', () => {
// Given const decision = getTerminalLiveSpecialKeyDecision({
const key = 'a' 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' })
})
// When it('Given raw accessory bytes with a held syllable Then commits held text first', () => {
const decision = getTerminalLiveSpecialKeyDecision({ key, pendingText: '한' }) const decision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x1b',
heldText: '한',
sentText: ''
})
expect(decision).toEqual({ kind: 'commit-held-then-send', bytes: '\x1b' })
})
// Then it('Given raw accessory bytes with nothing held Then sends immediately', () => {
expect(decision).toEqual({ kind: 'ignore' }) const decision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x1b',
heldText: '',
sentText: 'abc'
})
expect(decision).toEqual({ kind: 'send-now', bytes: '\x1b' })
}) })
it('Given no pending text When submit is requested Then sends only carriage return', () => { it('Given a local-edit accessory key with an empty field Then sends the raw bytes', () => {
// Given const decision = getTerminalLiveAccessoryBytesDecision({
const pendingText = '' bytes: '\x7f',
localEdit: 'backspace',
heldText: '',
sentText: ''
})
expect(decision).toEqual({ kind: 'send-now', bytes: '\x7f' })
})
})
// When describe('terminal live accessory local edit text', () => {
const sequence = getTerminalLiveSubmitSequence(pendingText) 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(
''
)
})
// Then it('Given forward delete Then keeps the field text unchanged', () => {
expect(sequence).toEqual(['\r']) expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'delete', fieldText: '한글' })).toBe(
'한글'
)
}) })
}) })
@@ -1,23 +1,15 @@
import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input' 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 = export type TerminalLiveSpecialKeyDecision =
| { readonly kind: 'ignore' } | { readonly kind: 'ignore' }
| { readonly kind: 'local-edit' } | { readonly kind: 'local-edit' }
| { readonly kind: 'send-now'; readonly bytes: string } | { 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 = { export type TerminalLiveSpecialKeyDecisionInput = {
readonly key: string readonly key: string
readonly pendingText: string readonly heldText: string
readonly sentText: string
} }
export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete' export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
@@ -25,78 +17,33 @@ export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
export type TerminalLiveAccessoryBytesDecision = export type TerminalLiveAccessoryBytesDecision =
| { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit } | { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit }
| { readonly kind: 'send-now'; readonly bytes: string } | { 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 = { export type TerminalLiveAccessoryBytesDecisionInput = {
readonly bytes: string readonly bytes: string
readonly localEdit?: TerminalLiveAccessoryLocalEdit readonly localEdit?: TerminalLiveAccessoryLocalEdit
readonly pendingText: string readonly heldText: string
} readonly sentText: 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
} }
export function getTerminalLiveSpecialKeyDecision({ export function getTerminalLiveSpecialKeyDecision({
key, key,
pendingText heldText,
sentText
}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision { }: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision {
const bytes = getTerminalLiveSpecialKeyBytes(key) const bytes = getTerminalLiveSpecialKeyBytes(key)
if (bytes === null) { if (bytes === null) {
return { kind: 'ignore' } 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' } return { kind: 'local-edit' }
} }
if (pendingText.length > 0) { if (heldText.length > 0) {
return { kind: 'flush-then-send', pendingText, bytes } return { kind: 'commit-held-then-send', bytes }
} }
return { kind: 'send-now', bytes } return { kind: 'send-now', bytes }
@@ -105,14 +52,15 @@ export function getTerminalLiveSpecialKeyDecision({
export function getTerminalLiveAccessoryBytesDecision({ export function getTerminalLiveAccessoryBytesDecision({
bytes, bytes,
localEdit, localEdit,
pendingText heldText,
sentText
}: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision { }: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision {
if (pendingText.length > 0 && localEdit) { if (localEdit && (heldText.length > 0 || sentText.length > 0)) {
return { kind: 'local-edit', localEdit } return { kind: 'local-edit', localEdit }
} }
if (pendingText.length > 0) { if (heldText.length > 0) {
return { kind: 'flush-then-send', pendingText, bytes } return { kind: 'commit-held-then-send', bytes }
} }
return { kind: 'send-now', bytes } return { kind: 'send-now', bytes }
@@ -120,26 +68,16 @@ export function getTerminalLiveAccessoryBytesDecision({
export function getTerminalLiveAccessoryLocalEditText({ export function getTerminalLiveAccessoryLocalEditText({
localEdit, localEdit,
pendingText fieldText
}: { }: {
readonly localEdit: TerminalLiveAccessoryLocalEdit readonly localEdit: TerminalLiveAccessoryLocalEdit
readonly pendingText: string readonly fieldText: string
}): string { }): string {
if (localEdit === 'delete') { if (localEdit === 'delete') {
// Why: accessory Delete mirrors forward-delete at the hidden input's end; // Why: accessory Delete mirrors forward-delete at the hidden input's end;
// it stays local but does not remove the pending IME text. // it stays local but does not remove the field text.
return pendingText return fieldText
} }
return Array.from(pendingText).slice(0, -1).join('') return Array.from(fieldText).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']
} }
@@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest' import { createElement, type RefObject } from 'react'
import { getTerminalLiveAccessoryInactiveInputCommitResult } from './use-terminal-live-accessory-input-commit' 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 = { type DeferredBoolean = {
readonly promise: Promise<boolean> readonly promise: Promise<boolean>
@@ -16,7 +25,111 @@ function createDeferredBoolean(): DeferredBoolean {
return { promise, resolve: resolvePromise } 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 () => { it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => {
// Given // Given
const deferredFlush = createDeferredBoolean() const deferredFlush = createDeferredBoolean()
@@ -50,3 +163,68 @@ describe('terminal live accessory input commit', () => {
expect(result).toEqual({ kind: 'suppress-raw' }) 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' })
})
})
@@ -2,8 +2,7 @@ import { useCallback, type RefObject } from 'react'
import type { TextInput } from 'react-native' import type { TextInput } from 'react-native'
import { import {
getTerminalLiveAccessoryBytesDecision, getTerminalLiveAccessoryBytesDecision,
getTerminalLiveAccessoryLocalEditText, getTerminalLiveAccessoryLocalEditText
getTerminalLiveDeferredTextDelayMs
} from './terminal-live-text-commit' } from './terminal-live-text-commit'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
@@ -14,12 +13,6 @@ export type TerminalLiveAccessoryInputCommitResult =
| { readonly kind: 'handled' } | { readonly kind: 'handled' }
| { readonly kind: 'suppress-raw' } | { readonly kind: 'suppress-raw' }
type TerminalLiveInputCommitScheduler = (
handle: string,
text: string,
delayMs: number | null
) => void
export async function getTerminalLiveAccessoryInactiveInputCommitResult( export async function getTerminalLiveAccessoryInactiveInputCommitResult(
waitForPendingLiveInputFlush: () => Promise<boolean> waitForPendingLiveInputFlush: () => Promise<boolean>
): Promise<TerminalLiveAccessoryInputCommitResult> { ): Promise<TerminalLiveAccessoryInputCommitResult> {
@@ -28,13 +21,14 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult(
type TerminalLiveAccessoryInputCommitOptions = { type TerminalLiveAccessoryInputCommitOptions = {
readonly activeHandle: string | null readonly activeHandle: string | null
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
readonly clearPendingLiveInputCommit: () => void readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean> readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputRef: RefObject<TextInput | null> readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string> readonly liveInputTerminalHandles: ReadonlySet<string>
readonly pendingLiveInputHandleRef: RefObject<string | null> readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly pendingLiveInputTextRef: RefObject<string> readonly sentLiveInputTextRef: RefObject<string>
readonly schedulePendingLiveInputCommit: TerminalLiveInputCommitScheduler
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void readonly setLiveInputCapture: (text: string) => void
readonly waitForPendingLiveInputFlush: () => Promise<boolean> readonly waitForPendingLiveInputFlush: () => Promise<boolean>
@@ -42,13 +36,14 @@ type TerminalLiveAccessoryInputCommitOptions = {
export function useTerminalLiveAccessoryInputCommit({ export function useTerminalLiveAccessoryInputCommit({
activeHandle, activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit, clearPendingLiveInputCommit,
flushPendingLiveInputText, flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef, liveInputRef,
liveInputTerminalHandles, liveInputTerminalHandles,
pendingLiveInputHandleRef, pendingLiveInputHandleRef,
pendingLiveInputTextRef, sentLiveInputTextRef,
schedulePendingLiveInputCommit,
sendLiveTerminalInputRef, sendLiveTerminalInputRef,
setLiveInputCapture, setLiveInputCapture,
waitForPendingLiveInputFlush waitForPendingLiveInputFlush
@@ -63,40 +58,33 @@ export function useTerminalLiveAccessoryInputCommit({
if (!liveInputTerminalHandles.has(activeHandle)) { if (!liveInputTerminalHandles.has(activeHandle)) {
return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush) return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
} }
const pendingText = const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : '' if (pendingLiveInputHandleRef.current && !ownsPendingState) {
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
clearPendingLiveInputCommit() 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) { switch (decision.kind) {
case 'send-now': case 'send-now':
// Why: raw accessory bytes must wait behind any in-flight IME text // Why: raw accessory bytes must wait behind any in-flight mirror send
// flush so composed Hangul reaches the PTY before follow-up controls. // so composed Hangul reaches the PTY before follow-up controls.
return (await waitForPendingLiveInputFlush()) return (await waitForPendingLiveInputFlush())
? { kind: 'allow-raw' } ? { kind: 'allow-raw' }
: { kind: 'suppress-raw' } : { kind: 'suppress-raw' }
case 'local-edit': { case 'local-edit': {
const editedText = getTerminalLiveAccessoryLocalEditText({ const editedText = getTerminalLiveAccessoryLocalEditText({
localEdit: decision.localEdit, 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 // 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) setLiveInputCapture(editedText)
liveInputRef.current?.setNativeProps({ text: editedText }) liveInputRef.current?.setNativeProps({ text: editedText })
schedulePendingLiveInputCommit( applyLiveInputMirror(activeHandle, editedText)
activeHandle,
editedText,
getTerminalLiveDeferredTextDelayMs(editedText)
)
return { kind: 'handled' } return { kind: 'handled' }
} }
case 'flush-then-send': case 'commit-held-then-send':
await sendTerminalLiveControlAfterPendingFlush( await sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle), () => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes) () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
@@ -109,13 +97,14 @@ export function useTerminalLiveAccessoryInputCommit({
}, },
[ [
activeHandle, activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit, clearPendingLiveInputCommit,
flushPendingLiveInputText, flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef, liveInputRef,
liveInputTerminalHandles, liveInputTerminalHandles,
pendingLiveInputHandleRef, pendingLiveInputHandleRef,
pendingLiveInputTextRef, sentLiveInputTextRef,
schedulePendingLiveInputCommit,
sendLiveTerminalInputRef, sendLiveTerminalInputRef,
setLiveInputCapture, setLiveInputCapture,
waitForPendingLiveInputFlush waitForPendingLiveInputFlush
@@ -3,13 +3,14 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { TextInput } from 'react-native' import type { TextInput } from 'react-native'
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TerminalLiveInputSender } from './terminal-live-input-sender' 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' import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
type TerminalLiveInputCommitHarness = { type TerminalLiveInputCommitHarness = {
readonly captures: readonly string[] readonly captures: readonly string[]
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>> readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
readonly sent: readonly string[] readonly sent: readonly string[]
readonly setActiveSessionTabType: (next: string | undefined) => void
readonly unmount: () => void readonly unmount: () => void
} }
@@ -36,6 +37,9 @@ function createTerminalLiveInputCommitHarness({
const activeHandleRef: RefObject<string | null> = { current: activeHandle } const activeHandleRef: RefObject<string | null> = { current: activeHandle }
const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' } const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' }
const captures: string[] = [] const captures: string[] = []
const setLiveInputCapture = (text: string): void => {
captures.push(text)
}
const liveInputRef: RefObject<TextInput | null> = { current: null } const liveInputRef: RefObject<TextInput | null> = { current: null }
const liveInputTerminalHandles = new Set([activeHandle]) const liveInputTerminalHandles = new Set([activeHandle])
const liveInputTerminalHandlesRef: RefObject<Set<string>> = { const liveInputTerminalHandlesRef: RefObject<Set<string>> = {
@@ -48,6 +52,9 @@ function createTerminalLiveInputCommitHarness({
return sendResult 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 handlers: ReturnType<typeof useTerminalLiveInputCommit<string>> | null = null
let renderer: ReactTestRenderer | null = null let renderer: ReactTestRenderer | null = null
@@ -55,13 +62,13 @@ function createTerminalLiveInputCommitHarness({
handlers = useTerminalLiveInputCommit({ handlers = useTerminalLiveInputCommit({
activeHandle, activeHandle,
activeHandleRef, activeHandleRef,
activeSessionTabType: 'terminal', activeSessionTabType: currentActiveSessionTabType,
activeSessionTabTypeRef, activeSessionTabTypeRef,
liveInputRef, liveInputRef,
liveInputTerminalHandles, liveInputTerminalHandles,
liveInputTerminalHandlesRef, liveInputTerminalHandlesRef,
sendLiveTerminalInputRef, sendLiveTerminalInputRef,
setLiveInputCapture: (text) => captures.push(text) setLiveInputCapture
}) })
return null return null
} }
@@ -82,6 +89,15 @@ function createTerminalLiveInputCommitHarness({
captures, captures,
handlers, handlers,
sent, 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: () => { unmount: () => {
act(() => renderer?.unmount()) act(() => renderer?.unmount())
} }
@@ -93,18 +109,48 @@ describe('terminal live input commit hook', () => {
vi.useRealTimers() 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 // Given
vi.useFakeTimers() 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 // When
handlers.handleLiveInputChange('ㅎ') await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
await vi.advanceTimersByTimeAsync(1_000)
// Then // Then
expect(captures).toEqual(['ㅎ']) await vi.waitFor(() => expect(sent).toEqual(['한']))
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 () => { 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'])) 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 () => { it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => {
// Given // Given
const { handlers, sent } = createTerminalLiveInputCommitHarness() const { handlers, sent } = createTerminalLiveInputCommitHarness()
@@ -145,34 +240,22 @@ describe('terminal live input commit hook', () => {
expect(sent).toEqual(['한']) 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 // Given
vi.useFakeTimers() const { handlers, sent } = createTerminalLiveInputCommitHarness()
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
// When // When
handlers.handleLiveInputChange('你好') handlers.handleLiveInputChange('你好')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS - 1)
// Then // Then
expect(captures).toEqual(['你好'])
expect(sent).toEqual([])
// When
await vi.advanceTimersByTimeAsync(1)
await vi.waitFor(() => expect(sent).toEqual(['你好'])) 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 // Given
vi.useFakeTimers() vi.useFakeTimers()
const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness() const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('é') handlers.handleLiveInputChange('한')
// When // When
unmount() unmount()
@@ -181,4 +264,54 @@ describe('terminal live input commit hook', () => {
// Then // Then
expect(sent).toEqual([]) 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']))
})
}) })
@@ -1,10 +1,6 @@
import { useCallback, useEffect, type RefObject } from 'react' import { useCallback, useEffect, type RefObject } from 'react'
import type { TextInput } from 'react-native' import type { TextInput } from 'react-native'
import { import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit'
getTerminalLiveSpecialKeyDecision,
getTerminalLiveSubmitSequence,
getTerminalLiveTextChangeDecision
} from './terminal-live-text-commit'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import type { TerminalLiveInputSender } from './terminal-live-input-sender' import type { TerminalLiveInputSender } from './terminal-live-input-sender'
@@ -56,11 +52,12 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
setLiveInputCapture setLiveInputCapture
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers { }: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
const { const {
applyLiveInputMirror,
clearPendingLiveInputCommit, clearPendingLiveInputCommit,
flushPendingLiveInputText, flushPendingLiveInputText,
heldLiveInputTextRef,
pendingLiveInputHandleRef, pendingLiveInputHandleRef,
pendingLiveInputTextRef, sentLiveInputTextRef,
schedulePendingLiveInputCommit,
waitForPendingLiveInputFlush waitForPendingLiveInputFlush
} = useTerminalLivePendingInputFlush({ } = useTerminalLivePendingInputFlush({
activeHandleRef, activeHandleRef,
@@ -76,10 +73,13 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
if (!pendingHandle) { if (!pendingHandle) {
return 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 ( if (
!activeHandle || !activeHandle ||
pendingHandle !== activeHandle || pendingHandle !== activeHandle ||
activeSessionTabType !== 'terminal' || (activeSessionTabType != null && activeSessionTabType !== 'terminal') ||
!liveInputTerminalHandles.has(activeHandle) !liveInputTerminalHandles.has(activeHandle)
) { ) {
clearPendingLiveInputCommit() clearPendingLiveInputCommit()
@@ -93,7 +93,9 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
clearPendingLiveInputCommit() clearPendingLiveInputCommit()
return waitForPendingLiveInputFlush() 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 flushPendingLiveInputText(handle)
} }
return waitForPendingLiveInputFlush() return waitForPendingLiveInputFlush()
@@ -108,35 +110,15 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
return return
} }
const normalizedText = normalizeTerminalTextInput(text) const normalizedText = normalizeTerminalTextInput(text)
const decision = getTerminalLiveTextChangeDecision(normalizedText) setLiveInputCapture(normalizedText)
switch (decision.kind) { applyLiveInputMirror(activeHandle, normalizedText)
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
}
}, },
[ [
activeHandle, activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit, clearPendingLiveInputCommit,
liveInputTerminalHandles, liveInputTerminalHandles,
schedulePendingLiveInputCommit, setLiveInputCapture
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
] ]
) )
@@ -145,26 +127,25 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return return
} }
const pendingText = const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : '' if (pendingLiveInputHandleRef.current && !ownsPendingState) {
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
clearPendingLiveInputCommit() clearPendingLiveInputCommit()
} }
const decision = getTerminalLiveSpecialKeyDecision({ const decision = getTerminalLiveSpecialKeyDecision({
key: event.nativeEvent.key, key: event.nativeEvent.key,
pendingText heldText: ownsPendingState ? heldLiveInputTextRef.current : '',
sentText: ownsPendingState ? sentLiveInputTextRef.current : ''
}) })
switch (decision.kind) { switch (decision.kind) {
case 'ignore': case 'ignore':
case 'local-edit': case 'local-edit':
return return
case 'send-now': case 'send-now':
clearPendingLiveInputCommit()
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () => void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, decision.bytes) sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
) )
return return
case 'flush-then-send': case 'commit-held-then-send':
void sendTerminalLiveControlAfterPendingFlush( void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle), () => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes) () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
@@ -186,13 +167,14 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({ const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({
activeHandle, activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit, clearPendingLiveInputCommit,
flushPendingLiveInputText, flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef, liveInputRef,
liveInputTerminalHandles, liveInputTerminalHandles,
pendingLiveInputHandleRef, pendingLiveInputHandleRef,
pendingLiveInputTextRef, sentLiveInputTextRef,
schedulePendingLiveInputCommit,
sendLiveTerminalInputRef, sendLiveTerminalInputRef,
setLiveInputCapture, setLiveInputCapture,
waitForPendingLiveInputFlush waitForPendingLiveInputFlush
@@ -202,28 +184,11 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return return
} }
const pendingText = void sendTerminalLiveControlAfterPendingFlush(
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : '' () => flushPendingLiveInputText(activeHandle),
const sequence = getTerminalLiveSubmitSequence(pendingText) () => sendLiveTerminalInputRef.current(activeHandle, '\r')
if (sequence.length === 2) {
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, sequence[1])
)
return
}
clearPendingLiveInputCommit()
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, sequence[0])
) )
}, [ }, [activeHandle, flushPendingLiveInputText, liveInputTerminalHandles, sendLiveTerminalInputRef])
activeHandle,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputTerminalHandles,
sendLiveTerminalInputRef,
waitForPendingLiveInputFlush
])
return { return {
clearPendingLiveInputCommit, clearPendingLiveInputCommit,
@@ -2,7 +2,12 @@ import { useCallback, useEffect, useRef, type RefObject } from 'react'
import type { TextInput } from 'react-native' import type { TextInput } from 'react-native'
import type { TerminalLiveInputSender } from './terminal-live-input-sender' import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { import {
queueTerminalLivePendingFlush, buildTerminalLiveMirrorPayload,
computeTerminalLiveMirrorStep,
TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS
} from './terminal-live-hangul-mirror'
import {
queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state' } from './terminal-live-pending-flush-state'
@@ -16,15 +21,12 @@ type TerminalLivePendingInputFlushOptions<TTabType extends string> = {
} }
type TerminalLivePendingInputFlush = { type TerminalLivePendingInputFlush = {
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
readonly clearPendingLiveInputCommit: () => void readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean> readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly pendingLiveInputHandleRef: RefObject<string | null> readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly pendingLiveInputTextRef: RefObject<string> readonly sentLiveInputTextRef: RefObject<string>
readonly schedulePendingLiveInputCommit: (
handle: string,
text: string,
delayMs: number | null
) => void
readonly waitForPendingLiveInputFlush: () => Promise<boolean> readonly waitForPendingLiveInputFlush: () => Promise<boolean>
} }
@@ -36,104 +38,143 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
sendLiveTerminalInputRef, sendLiveTerminalInputRef,
setLiveInputCapture setLiveInputCapture
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush { }: 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 pendingLiveInputFlushRef = useRef<Promise<boolean> | null>(null)
const pendingLiveInputTextRef = useRef('') const heldLiveInputTextRef = useRef('')
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null) 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(() => { const clearPendingLiveInputCommit = useCallback(() => {
if (liveInputCommitTimerRef.current) { resetMirrorState()
clearTimeout(liveInputCommitTimerRef.current)
liveInputCommitTimerRef.current = null
}
pendingLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
setLiveInputCapture('') setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' }) liveInputRef.current?.setNativeProps({ text: '' })
}, [liveInputRef, setLiveInputCapture]) }, [liveInputRef, resetMirrorState, setLiveInputCapture])
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => { const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef) return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
}, []) }, [])
const flushPendingLiveInputText = useCallback( const runMirrorStep = useCallback(
async (expectedHandle: string | null): Promise<boolean> => { async (handle: string, fieldText: string, commitHeld: boolean): 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
}
if ( if (
(expectedHandle !== null && handle !== expectedHandle) ||
handle !== activeHandleRef.current || handle !== activeHandleRef.current ||
activeSessionTabTypeRef.current !== 'terminal' || (activeSessionTabTypeRef.current != null &&
activeSessionTabTypeRef.current !== 'terminal') ||
!liveInputTerminalHandlesRef.current.has(handle) !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 false
} }
return queueTerminalLivePendingFlush(pendingLiveInputFlushRef, () => const step = computeTerminalLiveMirrorStep(sentLiveInputTextRef.current, fieldText, {
sendLiveTerminalInputRef.current(handle, text) 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, activeHandleRef,
activeSessionTabTypeRef, activeSessionTabTypeRef,
liveInputRef, clearHeldCommitTimer,
liveInputTerminalHandlesRef, liveInputTerminalHandlesRef,
resetMirrorState,
sendLiveTerminalInputRef, sendLiveTerminalInputRef,
setLiveInputCapture waitForPendingLiveInputFlush
] ]
) )
runMirrorStepRef.current = runMirrorStep
const schedulePendingLiveInputCommit = useCallback( const applyLiveInputMirror = useCallback(
(handle: string, text: string, delayMs: number | null) => { (handle: string, fieldText: string): void => {
if (liveInputCommitTimerRef.current) { void runMirrorStep(handle, fieldText, false)
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)
}, },
[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(() => { useEffect(() => {
return () => { return () => {
if (liveInputCommitTimerRef.current) { if (heldCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current) clearTimeout(heldCommitTimerRef.current)
liveInputCommitTimerRef.current = null heldCommitTimerRef.current = null
} }
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null pendingLiveInputHandleRef.current = null
pendingLiveInputTextRef.current = ''
pendingLiveInputFlushRef.current = null pendingLiveInputFlushRef.current = null
} }
}, []) }, [])
return { return {
applyLiveInputMirror,
clearPendingLiveInputCommit, clearPendingLiveInputCommit,
flushPendingLiveInputText, flushPendingLiveInputText,
heldLiveInputTextRef,
pendingLiveInputHandleRef, pendingLiveInputHandleRef,
pendingLiveInputTextRef, sentLiveInputTextRef,
schedulePendingLiveInputCommit,
waitForPendingLiveInputFlush waitForPendingLiveInputFlush
} }
} }