mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
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:
co-authored by
Orca
realitsyourman
Neil
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 { 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user