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:
parent
2c03133975
commit
e8c2b79a93
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
'한글'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue