diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index a934334b8..b0c766cb2 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -96,6 +96,7 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target' import { clearTerminalLiveInputFocusTimer, + focusTerminalLiveInputTarget, isTerminalLiveInputWithinByteLimit, scheduleTerminalLiveInputFocus } from '../../../../src/terminal/terminal-live-input' @@ -3134,8 +3135,12 @@ export default function SessionScreen() { if (!canSend || !liveInputEnabled) { return } - liveInputRef.current?.focus() - }, [canSend, liveInputEnabled]) + focusTerminalLiveInputTarget(liveInputRef.current, { + keyboardHeight, + refocus: () => + scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus()) + }) + }, [canSend, keyboardHeight, liveInputEnabled]) const clearSessionTabActionSheetKeyboardListener = useCallback(() => { sessionTabActionSheetKeyboardHideSubRef.current?.remove() @@ -4947,10 +4952,16 @@ export default function SessionScreen() { {liveInputEnabled ? ( [ + styles.liveInputFocusTarget, + pressed && styles.liveInputFocusTargetPressed, + !canSend && styles.liveInputFocusTargetDisabled + ]} disabled={!canSend} onPress={focusLiveInput} - accessibilityLabel="Focus live terminal input" + accessibilityRole="button" + accessibilityLabel="Show keyboard for live terminal input" + accessibilityHint="Typed text is sent directly to the active terminal" > diff --git a/mobile/src/terminal/terminal-live-hangul-mirror.test.ts b/mobile/src/terminal/terminal-live-hangul-mirror.test.ts new file mode 100644 index 000000000..b1a666950 --- /dev/null +++ b/mobile/src/terminal/terminal-live-hangul-mirror.test.ts @@ -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({ + 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({ + 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) + }) +}) diff --git a/mobile/src/terminal/terminal-live-hangul-mirror.ts b/mobile/src/terminal/terminal-live-hangul-mirror.ts new file mode 100644 index 000000000..170d1779f --- /dev/null +++ b/mobile/src/terminal/terminal-live-hangul-mirror.ts @@ -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 +} diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts new file mode 100644 index 000000000..1ceab18ab --- /dev/null +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -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') + }) +}) diff --git a/mobile/src/terminal/terminal-live-input.test.ts b/mobile/src/terminal/terminal-live-input.test.ts index 95b4960f9..daca212d4 100644 --- a/mobile/src/terminal/terminal-live-input.test.ts +++ b/mobile/src/terminal/terminal-live-input.test.ts @@ -5,10 +5,12 @@ import { clearTerminalLiveInputFocusTimer, defaultTerminalLiveInputHandles, filterTerminalLiveInputDefaultCandidates, + focusTerminalLiveInputTarget, getTerminalLiveSpecialKeyBytes, isTerminalLiveInputWithinByteLimit, pruneTerminalLiveInputHandles, scheduleTerminalLiveInputFocus, + type TerminalLiveInputFocusTarget, type TerminalLiveInputFocusTimerRef } from './terminal-live-input' @@ -16,6 +18,14 @@ function createTimerRef(): TerminalLiveInputFocusTimerRef { return { current: null } } +function createFocusTarget(isFocused: () => boolean): TerminalLiveInputFocusTarget { + return { + blur: vi.fn(), + focus: vi.fn(), + isFocused + } +} + describe('terminal live input', () => { afterEach(() => { vi.useRealTimers() @@ -181,4 +191,37 @@ describe('terminal live input', () => { expect(focus).not.toHaveBeenCalled() expect(timerRef.current).toBeNull() }) + + it('refocuses an already-focused capture input when the keyboard is closed', () => { + const input = createFocusTarget(() => true) + const refocus = vi.fn() + + focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus }) + + expect(input.blur).toHaveBeenCalledTimes(1) + expect(input.focus).not.toHaveBeenCalled() + expect(refocus).toHaveBeenCalledTimes(1) + }) + + it('focuses the capture input directly when the keyboard is open', () => { + const input = createFocusTarget(() => true) + const refocus = vi.fn() + + focusTerminalLiveInputTarget(input, { keyboardHeight: 240, refocus }) + + expect(input.blur).not.toHaveBeenCalled() + expect(input.focus).toHaveBeenCalledTimes(1) + expect(refocus).not.toHaveBeenCalled() + }) + + it('focuses the capture input directly when it is not already focused', () => { + const input = createFocusTarget(() => false) + const refocus = vi.fn() + + focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus }) + + expect(input.blur).not.toHaveBeenCalled() + expect(input.focus).toHaveBeenCalledTimes(1) + expect(refocus).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/terminal/terminal-live-input.ts b/mobile/src/terminal/terminal-live-input.ts index 96f279a48..321226c0c 100644 --- a/mobile/src/terminal/terminal-live-input.ts +++ b/mobile/src/terminal/terminal-live-input.ts @@ -66,6 +66,17 @@ export type TerminalLiveInputFocusTimerRef = { current: ReturnType | null } +export type TerminalLiveInputFocusTarget = { + readonly focus: () => void + readonly blur: () => void + readonly isFocused?: () => boolean +} + +type FocusTerminalLiveInputTargetOptions = { + readonly keyboardHeight: number + readonly refocus: () => void +} + export type TerminalLiveInputDefaultResult = { enabledHandles: ReadonlySet defaultedHandles: ReadonlySet @@ -217,4 +228,23 @@ export function scheduleTerminalLiveInputFocus( }, delayMs) } +export function focusTerminalLiveInputTarget( + input: TerminalLiveInputFocusTarget | null, + { keyboardHeight, refocus }: FocusTerminalLiveInputTargetOptions +): void { + if (!input) { + return + } + + if (keyboardHeight <= 0 && input.isFocused?.()) { + // Why: Android can keep a hidden TextInput focused after the IME is dismissed; + // focus() is then a no-op, so force a new focus session to reopen the keyboard. + input.blur() + refocus() + return + } + + input.focus() +} + export { TERMINAL_LIVE_INPUT_MAX_BYTES } diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts index 27dd4147d..88918c006 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.test.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import { - queueTerminalLivePendingFlush, + queueTerminalLiveMirrorSend, waitForTerminalLivePendingFlush, type TerminalLivePendingFlushState } from './terminal-live-pending-flush-state' @@ -64,83 +64,54 @@ describe('terminal live pending flush state', () => { await expect(controlSend).resolves.toBe(false) expect(events).toEqual([]) }) +}) - it('Given a queued pending flush When it resolves or rejects Then clears the barrier', async () => { +describe('terminal live mirror send queue', () => { + it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => { // Given - const resolvedState: TerminalLivePendingFlushState = { current: null } - const rejectedState: TerminalLivePendingFlushState = { current: null } + const state: TerminalLivePendingFlushState = { current: null } + const order: string[] = [] + const first = queueTerminalLiveMirrorSend(state, async () => { + order.push('first') + return false + }) // When - await expect(queueTerminalLivePendingFlush(resolvedState, async () => true)).resolves.toBe(true) - await expect( - queueTerminalLivePendingFlush(rejectedState, async () => { - throw new Error('send failed') - }) - ).resolves.toBe(false) + const second = queueTerminalLiveMirrorSend(state, async () => { + order.push('second') + return true + }) + + // Then + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(true) + expect(order).toEqual(['first', 'second']) + }) + + it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => { + // Given + const state: TerminalLivePendingFlushState = { current: null } + const first = queueTerminalLiveMirrorSend(state, async () => { + throw new Error('boom') + }) + + // When + const second = queueTerminalLiveMirrorSend(state, async () => true) + + // Then + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(true) + }) + + it('Given a settled mirror send When it was the newest Then the state resets to null', async () => { + // Given + const state: TerminalLivePendingFlushState = { current: null } + + // When + await queueTerminalLiveMirrorSend(state, async () => true) await Promise.resolve() // Then - expect(resolvedState.current).toBeNull() - expect(rejectedState.current).toBeNull() - }) - - it('Given a current pending snapshot while another flush is in flight When queued Then sends current text after prior success', async () => { - // Given - const events: string[] = [] - let resolveFirstFlush: (value: boolean) => void = () => {} - const firstFlush = new Promise((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((resolve) => { - resolveFirstFlush = resolve - }) - const state: TerminalLivePendingFlushState = { current: firstFlush } - - // When - const secondFlush = queueTerminalLivePendingFlush(state, async () => { - events.push('second-flush') - return true - }) - const controlSend = sendTerminalLiveControlAfterPendingFlush( - () => waitForTerminalLivePendingFlush(state), - async () => { - events.push('control') - return true - } - ) - resolveFirstFlush(false) - - // Then - await expect(secondFlush).resolves.toBe(false) - await expect(controlSend).resolves.toBe(false) - expect(events).toEqual([]) + expect(state.current).toBeNull() }) }) diff --git a/mobile/src/terminal/terminal-live-pending-flush-state.ts b/mobile/src/terminal/terminal-live-pending-flush-state.ts index ea760fcee..405198964 100644 --- a/mobile/src/terminal/terminal-live-pending-flush-state.ts +++ b/mobile/src/terminal/terminal-live-pending-flush-state.ts @@ -8,22 +8,26 @@ export function waitForTerminalLivePendingFlush( return state.current ?? Promise.resolve(true) } -export function queueTerminalLivePendingFlush( +// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped +// delta desyncs every later diff, so this chain runs each send even when the +// previous one failed. state.current should never reject; the catch keeps a +// future raw assignment from skipping a delta. +export function queueTerminalLiveMirrorSend( state: TerminalLivePendingFlushState, - sendPendingText: () => Promise + sendMirrorPayload: () => Promise ): Promise { - const previousFlush = state.current - const flushPromise = (async () => { - if (previousFlush && !(await previousFlush)) { - return false + const previousSend = state.current + const sendPromise = (async () => { + if (previousSend) { + await previousSend.catch(() => false) } - return sendPendingText() + return sendMirrorPayload() })().catch(() => false) - state.current = flushPromise - void flushPromise.then(() => { - if (state.current === flushPromise) { + state.current = sendPromise + void sendPromise.then(() => { + if (state.current === sendPromise) { state.current = null } }) - return flushPromise + return sendPromise } diff --git a/mobile/src/terminal/terminal-live-text-commit.test.ts b/mobile/src/terminal/terminal-live-text-commit.test.ts index 00435fb22..a1206645f 100644 --- a/mobile/src/terminal/terminal-live-text-commit.test.ts +++ b/mobile/src/terminal/terminal-live-text-commit.test.ts @@ -1,234 +1,118 @@ import { describe, expect, it } from 'vitest' import { - TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS, getTerminalLiveAccessoryBytesDecision, getTerminalLiveAccessoryLocalEditText, - getTerminalLiveDeferredTextDelayMs, - getTerminalLiveSpecialKeyDecision, - getTerminalLiveSubmitSequence, - getTerminalLiveTextChangeDecision, - isTerminalLiveTextHangulCandidate, - isTerminalLiveTextImeCandidate + getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit' -describe('terminal live text commit', () => { - it('Given Korean IME changes When live text changes Then defers candidates and submits only final text before carriage return', () => { - // Given - const koreanCompositionSteps = ['ㅎ', '하', '한'] as const - - // When - const decisions = koreanCompositionSteps.map(getTerminalLiveTextChangeDecision) - const sentImmediately = decisions.filter((decision) => decision.kind === 'send-now') - const submitSequence = getTerminalLiveSubmitSequence('한') - const composedWordDecision = getTerminalLiveTextChangeDecision('한글') - const composedWordSubmitSequence = getTerminalLiveSubmitSequence('한글') - - // Then - expect(decisions).toEqual([ - { kind: 'defer', text: 'ㅎ', delayMs: null }, - { kind: 'defer', text: '하', delayMs: null }, - { kind: 'defer', text: '한', delayMs: null } - ]) - expect(sentImmediately).toEqual([]) - expect(isTerminalLiveTextHangulCandidate('ㅎ')).toBe(true) - expect(getTerminalLiveDeferredTextDelayMs('ㅎ')).toBeNull() - expect(submitSequence).toEqual(['한', '\r']) - expect(composedWordDecision).toEqual({ - kind: 'defer', - text: '한글', - delayMs: null +describe('terminal live special key decision', () => { + it('Given an unmapped key Then ignores it', () => { + expect(getTerminalLiveSpecialKeyDecision({ key: 'ㅎ', heldText: '', sentText: '' })).toEqual({ + kind: 'ignore' }) - expect(composedWordSubmitSequence).toEqual(['한글', '\r']) }) - it('Given non-Hangul IME text When live text changes Then keeps the bounded settle timer', () => { - // Given - const text = 'あ' - - // When - const decision = getTerminalLiveTextChangeDecision(text) - - // Then - expect(isTerminalLiveTextImeCandidate(text)).toBe(true) - expect(isTerminalLiveTextHangulCandidate(text)).toBe(false) - expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS) - expect(decision).toEqual({ kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS }) + it('Given Backspace with any field text Then edits locally so the mirror diff handles the PTY erase', () => { + // Held syllable present + expect( + getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '한', sentText: '' }) + ).toEqual({ kind: 'local-edit' }) + // Only mirrored text present — native edit fires onChangeText and the diff erases + expect( + getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '', sentText: 'abc' }) + ).toEqual({ kind: 'local-edit' }) }) - it('Given Chinese and Vietnamese IME text When live text changes Then does not use Hangul-only indefinite deferral', () => { - // Given - const nonHangulImeTexts = ['你好', 'tiếng Việt'] as const - - for (const text of nonHangulImeTexts) { - // When - const decision = getTerminalLiveTextChangeDecision(text) - - // Then - expect(isTerminalLiveTextImeCandidate(text)).toBe(true) - expect(isTerminalLiveTextHangulCandidate(text)).toBe(false) - expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS) - expect(decision).toEqual({ - kind: 'defer', - text, - delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS - }) - } - }) - - it('Given ASCII text When live text changes Then sends immediately', () => { - // Given - const text = 'abc123' - - // When - const decision = getTerminalLiveTextChangeDecision(text) - - // Then - expect(isTerminalLiveTextImeCandidate(text)).toBe(false) - expect(decision).toEqual({ kind: 'send-now', text }) - }) - - it('Given empty text When live text changes Then ignores the change', () => { - // Given - const text = '' - - // When - const decision = getTerminalLiveTextChangeDecision(text) - - // Then - expect(isTerminalLiveTextImeCandidate(text)).toBe(false) - expect(decision).toEqual({ kind: 'ignore' }) - }) - - it('Given pending text When Backspace or Delete is pressed Then keeps edits local', () => { - // Given - const pendingText = '한' - - // When - const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText }) - const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText }) - - // Then - expect(backspaceDecision).toEqual({ kind: 'local-edit' }) - expect(deleteDecision).toEqual({ kind: 'local-edit' }) - }) - - it('Given no pending text When Backspace or Delete is pressed Then sends terminal bytes', () => { - // Given - const pendingText = '' - - // When - const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText }) - const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText }) - - // Then - expect(backspaceDecision).toEqual({ kind: 'send-now', bytes: '\x7f' }) - expect(deleteDecision).toEqual({ kind: 'send-now', bytes: '\x1b[3~' }) - }) - - it('Given pending text When a terminal special key is pressed Then flushes pending text before bytes', () => { - // Given - const pendingText = '한' - - // When - const decision = getTerminalLiveSpecialKeyDecision({ key: 'Tab', pendingText }) - - // Then - expect(decision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' }) - }) - - it('Given pending text When accessory control bytes are requested Then flushes pending text before bytes', () => { - // Given - const pendingText = '한글' - - // When - const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText }) - const escapeDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\x1b', pendingText }) - const enterDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\r', pendingText }) - - // Then - expect(tabDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' }) - expect(escapeDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\x1b' }) - expect(enterDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\r' }) - }) - - it('Given pending text When accessory Backspace or Delete bytes are requested Then keeps edits local', () => { - // Given - const pendingText = '한글' - - // When - const backspaceDecision = getTerminalLiveAccessoryBytesDecision({ - bytes: '\x7f', - localEdit: 'backspace', - pendingText + it('Given Backspace with an empty field Then sends terminal backspace bytes', () => { + const decision = getTerminalLiveSpecialKeyDecision({ + key: 'Backspace', + heldText: '', + sentText: '' }) - const ctrlBackspaceDecision = getTerminalLiveAccessoryBytesDecision({ - bytes: '\b', - localEdit: 'backspace', - pendingText - }) - const deleteDecision = getTerminalLiveAccessoryBytesDecision({ - bytes: '\x1b[3~', - localEdit: 'delete', - pendingText - }) - const customDeleteByteDecision = getTerminalLiveAccessoryBytesDecision({ - bytes: '\x7f', - pendingText - }) - const backspaceText = getTerminalLiveAccessoryLocalEditText({ - localEdit: 'backspace', - pendingText - }) - const deleteText = getTerminalLiveAccessoryLocalEditText({ - localEdit: 'delete', - pendingText - }) - - // Then - expect(backspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' }) - expect(ctrlBackspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' }) - expect(deleteDecision).toEqual({ kind: 'local-edit', localEdit: 'delete' }) - expect(customDeleteByteDecision).toEqual({ - kind: 'flush-then-send', - pendingText, - bytes: '\x7f' - }) - expect(backspaceText).toBe('한') - expect(deleteText).toBe('한글') - expect(getTerminalLiveDeferredTextDelayMs(backspaceText)).toBeNull() - expect(getTerminalLiveDeferredTextDelayMs(deleteText)).toBeNull() + expect(decision.kind).toBe('send-now') }) - it('Given no pending text When accessory bytes are requested Then sends terminal bytes', () => { - // Given - const pendingText = '' - - // When - const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText }) - - // Then - expect(tabDecision).toEqual({ kind: 'send-now', bytes: '\t' }) + it('Given a control key with a held syllable Then commits the held text before the bytes', () => { + const decision = getTerminalLiveSpecialKeyDecision({ + key: 'Tab', + heldText: '글', + sentText: '한' + }) + expect(decision.kind).toBe('commit-held-then-send') }) - it('Given a non-special key When key decision is requested Then ignores it', () => { - // Given - const key = 'a' - - // When - const decision = getTerminalLiveSpecialKeyDecision({ key, pendingText: '한' }) - - // Then - expect(decision).toEqual({ kind: 'ignore' }) - }) - - it('Given no pending text When submit is requested Then sends only carriage return', () => { - // Given - const pendingText = '' - - // When - const sequence = getTerminalLiveSubmitSequence(pendingText) - - // Then - expect(sequence).toEqual(['\r']) + it('Given a control key with no held syllable Then sends immediately', () => { + const decision = getTerminalLiveSpecialKeyDecision({ + key: 'ArrowUp', + heldText: '', + sentText: 'ls' + }) + expect(decision.kind).toBe('send-now') + }) +}) + +describe('terminal live accessory bytes decision', () => { + it('Given a local-edit accessory key with field text Then edits locally', () => { + expect( + getTerminalLiveAccessoryBytesDecision({ + bytes: '\x7f', + localEdit: 'backspace', + heldText: '한', + sentText: '' + }) + ).toEqual({ kind: 'local-edit', localEdit: 'backspace' }) + expect( + getTerminalLiveAccessoryBytesDecision({ + bytes: '\x7f', + localEdit: 'backspace', + heldText: '', + sentText: 'abc' + }) + ).toEqual({ kind: 'local-edit', localEdit: 'backspace' }) + }) + + it('Given raw accessory bytes with a held syllable Then commits held text first', () => { + const decision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x1b', + heldText: '한', + sentText: '' + }) + expect(decision).toEqual({ kind: 'commit-held-then-send', bytes: '\x1b' }) + }) + + it('Given raw accessory bytes with nothing held Then sends immediately', () => { + const decision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x1b', + heldText: '', + sentText: 'abc' + }) + expect(decision).toEqual({ kind: 'send-now', bytes: '\x1b' }) + }) + + it('Given a local-edit accessory key with an empty field Then sends the raw bytes', () => { + const decision = getTerminalLiveAccessoryBytesDecision({ + bytes: '\x7f', + localEdit: 'backspace', + heldText: '', + sentText: '' + }) + expect(decision).toEqual({ kind: 'send-now', bytes: '\x7f' }) + }) +}) + +describe('terminal live accessory local edit text', () => { + it('Given backspace Then drops the last code point of the field text', () => { + expect( + getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한글' }) + ).toBe('한') + expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한' })).toBe( + '' + ) + }) + + it('Given forward delete Then keeps the field text unchanged', () => { + expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'delete', fieldText: '한글' })).toBe( + '한글' + ) }) }) diff --git a/mobile/src/terminal/terminal-live-text-commit.ts b/mobile/src/terminal/terminal-live-text-commit.ts index aac74b893..77752e077 100644 --- a/mobile/src/terminal/terminal-live-text-commit.ts +++ b/mobile/src/terminal/terminal-live-text-commit.ts @@ -1,23 +1,15 @@ import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input' -// Why: React Native does not expose portable composition events here, so -// non-Hangul IME text gets a short settle window before being sent to the PTY. -export const TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS = 150 - -export type TerminalLiveTextChangeDecision = - | { readonly kind: 'ignore' } - | { readonly kind: 'send-now'; readonly text: string } - | { readonly kind: 'defer'; readonly text: string; readonly delayMs: number | null } - export type TerminalLiveSpecialKeyDecision = | { readonly kind: 'ignore' } | { readonly kind: 'local-edit' } | { readonly kind: 'send-now'; readonly bytes: string } - | { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string } + | { readonly kind: 'commit-held-then-send'; readonly bytes: string } export type TerminalLiveSpecialKeyDecisionInput = { readonly key: string - readonly pendingText: string + readonly heldText: string + readonly sentText: string } export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete' @@ -25,78 +17,33 @@ export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete' export type TerminalLiveAccessoryBytesDecision = | { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit } | { readonly kind: 'send-now'; readonly bytes: string } - | { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string } + | { readonly kind: 'commit-held-then-send'; readonly bytes: string } export type TerminalLiveAccessoryBytesDecisionInput = { readonly bytes: string readonly localEdit?: TerminalLiveAccessoryLocalEdit - readonly pendingText: string -} - -export function isTerminalLiveTextImeCandidate(text: string): boolean { - for (const character of text) { - const codePoint = character.codePointAt(0) - if (codePoint !== undefined && codePoint > 0x7f) { - return true - } - } - return false -} - -function isHangulCodePoint(codePoint: number): boolean { - return ( - (codePoint >= 0x1100 && codePoint <= 0x11ff) || - (codePoint >= 0x3130 && codePoint <= 0x318f) || - (codePoint >= 0xa960 && codePoint <= 0xa97f) || - (codePoint >= 0xac00 && codePoint <= 0xd7af) - ) -} - -export function isTerminalLiveTextHangulCandidate(text: string): boolean { - for (const character of text) { - const codePoint = character.codePointAt(0) - if (codePoint !== undefined && isHangulCodePoint(codePoint)) { - return true - } - } - return false -} - -export function getTerminalLiveTextChangeDecision(text: string): TerminalLiveTextChangeDecision { - if (text.length === 0) { - return { kind: 'ignore' } - } - - if (isTerminalLiveTextHangulCandidate(text)) { - return { kind: 'defer', text, delayMs: null } - } - - if (isTerminalLiveTextImeCandidate(text)) { - return { kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } - } - - return { kind: 'send-now', text } -} - -export function getTerminalLiveDeferredTextDelayMs(text: string): number | null { - return isTerminalLiveTextHangulCandidate(text) ? null : TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS + readonly heldText: string + readonly sentText: string } export function getTerminalLiveSpecialKeyDecision({ key, - pendingText + heldText, + sentText }: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision { const bytes = getTerminalLiveSpecialKeyBytes(key) if (bytes === null) { return { kind: 'ignore' } } - if (pendingText.length > 0 && (key === 'Backspace' || key === 'Delete')) { + // Why: native field edits fire onChangeText and the mirror diff emits the + // matching PTY erase; sending raw DEL here as well would double-erase. + if ((key === 'Backspace' || key === 'Delete') && (heldText.length > 0 || sentText.length > 0)) { return { kind: 'local-edit' } } - if (pendingText.length > 0) { - return { kind: 'flush-then-send', pendingText, bytes } + if (heldText.length > 0) { + return { kind: 'commit-held-then-send', bytes } } return { kind: 'send-now', bytes } @@ -105,14 +52,15 @@ export function getTerminalLiveSpecialKeyDecision({ export function getTerminalLiveAccessoryBytesDecision({ bytes, localEdit, - pendingText + heldText, + sentText }: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision { - if (pendingText.length > 0 && localEdit) { + if (localEdit && (heldText.length > 0 || sentText.length > 0)) { return { kind: 'local-edit', localEdit } } - if (pendingText.length > 0) { - return { kind: 'flush-then-send', pendingText, bytes } + if (heldText.length > 0) { + return { kind: 'commit-held-then-send', bytes } } return { kind: 'send-now', bytes } @@ -120,26 +68,16 @@ export function getTerminalLiveAccessoryBytesDecision({ export function getTerminalLiveAccessoryLocalEditText({ localEdit, - pendingText + fieldText }: { readonly localEdit: TerminalLiveAccessoryLocalEdit - readonly pendingText: string + readonly fieldText: string }): string { if (localEdit === 'delete') { // Why: accessory Delete mirrors forward-delete at the hidden input's end; - // it stays local but does not remove the pending IME text. - return pendingText + // it stays local but does not remove the field text. + return fieldText } - return Array.from(pendingText).slice(0, -1).join('') -} - -export type TerminalLiveSubmitSequence = readonly ['\r'] | readonly [string, '\r'] - -export function getTerminalLiveSubmitSequence(pendingText: string): TerminalLiveSubmitSequence { - if (pendingText.length === 0) { - return ['\r'] - } - - return [pendingText, '\r'] + return Array.from(fieldText).slice(0, -1).join('') } diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts index b6bcb3806..1ca5216c7 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts @@ -1,5 +1,14 @@ -import { describe, expect, it } from 'vitest' -import { getTerminalLiveAccessoryInactiveInputCommitResult } from './use-terminal-live-accessory-input-commit' +import { createElement, type RefObject } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import type { TextInput } from 'react-native' +import { describe, expect, it, vi } from 'vitest' +import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' +import type { TerminalLiveInputSender } from './terminal-live-input-sender' +import { + getTerminalLiveAccessoryInactiveInputCommitResult, + useTerminalLiveAccessoryInputCommit, + type TerminalLiveAccessoryInputCommitResult +} from './use-terminal-live-accessory-input-commit' type DeferredBoolean = { readonly promise: Promise @@ -16,7 +25,111 @@ function createDeferredBoolean(): DeferredBoolean { return { promise, resolve: resolvePromise } } -describe('terminal live accessory input commit', () => { +function suppressReactTestRendererDeprecationWarning(): () => void { + const originalConsoleError = console.error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0] + if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) { + return + } + originalConsoleError(...args) + }) + return () => consoleErrorSpy.mockRestore() +} + +type AccessoryInputCommitHarnessOptions = { + readonly heldText?: string + readonly sentText?: string + readonly pendingHandle?: string | null + readonly sendResult?: boolean + readonly flushResult?: boolean + readonly waitResult?: boolean +} + +type AccessoryInputCommitHarness = { + readonly commit: ( + input: TerminalLiveAccessoryInput + ) => Promise + readonly sent: readonly string[] + readonly applyLiveInputMirror: ReturnType + readonly flushPendingLiveInputText: ReturnType + readonly waitForPendingLiveInputFlush: ReturnType + readonly unmount: () => void +} + +function createAccessoryInputCommitHarness({ + heldText = '', + sentText = '', + pendingHandle = null, + sendResult = true, + flushResult = true, + waitResult = true +}: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness { + const activeHandle = 'terminal-a' + const heldLiveInputTextRef: RefObject = { current: heldText } + const sentLiveInputTextRef: RefObject = { current: sentText } + const pendingLiveInputHandleRef: RefObject = { current: pendingHandle } + const liveInputRef: RefObject = { current: null } + const liveInputTerminalHandles = new Set([activeHandle]) + const sent: string[] = [] + const sendLiveTerminalInputRef: RefObject = { + current: async (_handle, bytes) => { + sent.push(bytes) + return sendResult + } + } + const applyLiveInputMirror = vi.fn((_handle: string, _fieldText: string) => {}) + const clearPendingLiveInputCommit = vi.fn(() => {}) + const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult) + const waitForPendingLiveInputFlush = vi.fn(async () => waitResult) + const setLiveInputCapture = vi.fn((_text: string) => {}) + + let commit: AccessoryInputCommitHarness['commit'] | null = null + let renderer: ReactTestRenderer | null = null + + function Harness(): null { + commit = useTerminalLiveAccessoryInputCommit({ + activeHandle, + applyLiveInputMirror, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + heldLiveInputTextRef, + liveInputRef, + liveInputTerminalHandles, + pendingLiveInputHandleRef, + sentLiveInputTextRef, + sendLiveTerminalInputRef, + setLiveInputCapture, + waitForPendingLiveInputFlush + }) + return null + } + + const restoreConsoleError = suppressReactTestRendererDeprecationWarning() + try { + act(() => { + renderer = create(createElement(Harness)) + }) + } finally { + restoreConsoleError() + } + if (!commit || !renderer) { + throw new Error('terminal live accessory input hook did not render') + } + + return { + commit, + sent, + applyLiveInputMirror, + flushPendingLiveInputText, + waitForPendingLiveInputFlush, + unmount: () => { + act(() => renderer?.unmount()) + } + } +} + +describe('terminal live accessory inactive input commit result', () => { it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => { // Given const deferredFlush = createDeferredBoolean() @@ -50,3 +163,68 @@ describe('terminal live accessory input commit', () => { expect(result).toEqual({ kind: 'suppress-raw' }) }) }) + +describe('terminal live accessory input commit hook', () => { + it('Given raw accessory bytes with a held syllable When committed Then flushes held text before sending bytes', async () => { + // Given + const harness = createAccessoryInputCommitHarness({ + heldText: '한', + sentText: '', + pendingHandle: 'terminal-a' + }) + + // When + const result = await harness.commit({ bytes: '\x1b' }) + + // Then + expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a') + expect(harness.sent).toEqual(['\x1b']) + expect(result).toEqual({ kind: 'handled' }) + }) + + it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => { + // Given + const harness = createAccessoryInputCommitHarness({ pendingHandle: null }) + + // When + const result = await harness.commit({ bytes: '\x1b' }) + + // Then + expect(result).toEqual({ kind: 'allow-raw' }) + expect(harness.flushPendingLiveInputText).not.toHaveBeenCalled() + expect(harness.sent).toEqual([]) + }) + + it('Given accessory backspace with a held syllable When committed Then mirrors the emptied field without terminal bytes', async () => { + // Given + const harness = createAccessoryInputCommitHarness({ + heldText: '한', + sentText: '', + pendingHandle: 'terminal-a' + }) + + // When + const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' }) + + // Then + expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '') + expect(result).toEqual({ kind: 'handled' }) + expect(harness.sent).toEqual([]) + }) + + it('Given accessory backspace with mirrored sent text When committed Then mirrors the shortened field so the diff emits DEL', async () => { + // Given + const harness = createAccessoryInputCommitHarness({ + heldText: '', + sentText: 'ab', + pendingHandle: 'terminal-a' + }) + + // When + const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' }) + + // Then + expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a') + expect(result).toEqual({ kind: 'handled' }) + }) +}) diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts index b7e8cc8b2..0893ae160 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts @@ -2,8 +2,7 @@ import { useCallback, type RefObject } from 'react' import type { TextInput } from 'react-native' import { getTerminalLiveAccessoryBytesDecision, - getTerminalLiveAccessoryLocalEditText, - getTerminalLiveDeferredTextDelayMs + getTerminalLiveAccessoryLocalEditText } from './terminal-live-text-commit' import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' @@ -14,12 +13,6 @@ export type TerminalLiveAccessoryInputCommitResult = | { readonly kind: 'handled' } | { readonly kind: 'suppress-raw' } -type TerminalLiveInputCommitScheduler = ( - handle: string, - text: string, - delayMs: number | null -) => void - export async function getTerminalLiveAccessoryInactiveInputCommitResult( waitForPendingLiveInputFlush: () => Promise ): Promise { @@ -28,13 +21,14 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult( type TerminalLiveAccessoryInputCommitOptions = { readonly activeHandle: string | null + readonly applyLiveInputMirror: (handle: string, fieldText: string) => void readonly clearPendingLiveInputCommit: () => void readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise + readonly heldLiveInputTextRef: RefObject readonly liveInputRef: RefObject readonly liveInputTerminalHandles: ReadonlySet readonly pendingLiveInputHandleRef: RefObject - readonly pendingLiveInputTextRef: RefObject - readonly schedulePendingLiveInputCommit: TerminalLiveInputCommitScheduler + readonly sentLiveInputTextRef: RefObject readonly sendLiveTerminalInputRef: RefObject readonly setLiveInputCapture: (text: string) => void readonly waitForPendingLiveInputFlush: () => Promise @@ -42,13 +36,14 @@ type TerminalLiveAccessoryInputCommitOptions = { export function useTerminalLiveAccessoryInputCommit({ activeHandle, + applyLiveInputMirror, clearPendingLiveInputCommit, flushPendingLiveInputText, + heldLiveInputTextRef, liveInputRef, liveInputTerminalHandles, pendingLiveInputHandleRef, - pendingLiveInputTextRef, - schedulePendingLiveInputCommit, + sentLiveInputTextRef, sendLiveTerminalInputRef, setLiveInputCapture, waitForPendingLiveInputFlush @@ -63,40 +58,33 @@ export function useTerminalLiveAccessoryInputCommit({ if (!liveInputTerminalHandles.has(activeHandle)) { return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush) } - const pendingText = - pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : '' - if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) { + const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle + if (pendingLiveInputHandleRef.current && !ownsPendingState) { clearPendingLiveInputCommit() } - const decision = getTerminalLiveAccessoryBytesDecision({ ...input, pendingText }) + const heldText = ownsPendingState ? heldLiveInputTextRef.current : '' + const sentText = ownsPendingState ? sentLiveInputTextRef.current : '' + const decision = getTerminalLiveAccessoryBytesDecision({ ...input, heldText, sentText }) switch (decision.kind) { case 'send-now': - // Why: raw accessory bytes must wait behind any in-flight IME text - // flush so composed Hangul reaches the PTY before follow-up controls. + // Why: raw accessory bytes must wait behind any in-flight mirror send + // so composed Hangul reaches the PTY before follow-up controls. return (await waitForPendingLiveInputFlush()) ? { kind: 'allow-raw' } : { kind: 'suppress-raw' } case 'local-edit': { const editedText = getTerminalLiveAccessoryLocalEditText({ localEdit: decision.localEdit, - pendingText + fieldText: sentText + heldText }) - if (editedText.length === 0) { - clearPendingLiveInputCommit() - return { kind: 'handled' } - } // Why: accessory buttons do not emit native TextInput edits, so the - // pending IME buffer must be edited and rescheduled here. + // field is edited here and the mirror diff syncs the PTY echo. setLiveInputCapture(editedText) liveInputRef.current?.setNativeProps({ text: editedText }) - schedulePendingLiveInputCommit( - activeHandle, - editedText, - getTerminalLiveDeferredTextDelayMs(editedText) - ) + applyLiveInputMirror(activeHandle, editedText) return { kind: 'handled' } } - case 'flush-then-send': + case 'commit-held-then-send': await sendTerminalLiveControlAfterPendingFlush( () => flushPendingLiveInputText(activeHandle), () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes) @@ -109,13 +97,14 @@ export function useTerminalLiveAccessoryInputCommit({ }, [ activeHandle, + applyLiveInputMirror, clearPendingLiveInputCommit, flushPendingLiveInputText, + heldLiveInputTextRef, liveInputRef, liveInputTerminalHandles, pendingLiveInputHandleRef, - pendingLiveInputTextRef, - schedulePendingLiveInputCommit, + sentLiveInputTextRef, sendLiveTerminalInputRef, setLiveInputCapture, waitForPendingLiveInputFlush diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index 005d4b77f..3ddb88682 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -3,13 +3,14 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import type { TextInput } from 'react-native' import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalLiveInputSender } from './terminal-live-input-sender' -import { TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } from './terminal-live-text-commit' +import { TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS } from './terminal-live-hangul-mirror' import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit' type TerminalLiveInputCommitHarness = { readonly captures: readonly string[] readonly handlers: ReturnType> readonly sent: readonly string[] + readonly setActiveSessionTabType: (next: string | undefined) => void readonly unmount: () => void } @@ -36,6 +37,9 @@ function createTerminalLiveInputCommitHarness({ const activeHandleRef: RefObject = { current: activeHandle } const activeSessionTabTypeRef: RefObject = { current: 'terminal' } const captures: string[] = [] + const setLiveInputCapture = (text: string): void => { + captures.push(text) + } const liveInputRef: RefObject = { current: null } const liveInputTerminalHandles = new Set([activeHandle]) const liveInputTerminalHandlesRef: RefObject> = { @@ -48,6 +52,9 @@ function createTerminalLiveInputCommitHarness({ return sendResult } } + // The hook keeps live-input state in refs, so a change handler alone never + // re-renders; only a prop change (this variable) re-runs the pending-clear effect. + let currentActiveSessionTabType: string | undefined = 'terminal' let handlers: ReturnType> | null = null let renderer: ReactTestRenderer | null = null @@ -55,13 +62,13 @@ function createTerminalLiveInputCommitHarness({ handlers = useTerminalLiveInputCommit({ activeHandle, activeHandleRef, - activeSessionTabType: 'terminal', + activeSessionTabType: currentActiveSessionTabType, activeSessionTabTypeRef, liveInputRef, liveInputTerminalHandles, liveInputTerminalHandlesRef, sendLiveTerminalInputRef, - setLiveInputCapture: (text) => captures.push(text) + setLiveInputCapture }) return null } @@ -82,6 +89,15 @@ function createTerminalLiveInputCommitHarness({ captures, handlers, sent, + setActiveSessionTabType: (next: string | undefined): void => { + currentActiveSessionTabType = next + // Ref and prop derive from the same activeSessionTab in the real route, so + // they go null together during tab-list lag — keep the harness coupled. + activeSessionTabTypeRef.current = next ?? null + act(() => { + renderer?.update(createElement(Harness)) + }) + }, unmount: () => { act(() => renderer?.unmount()) } @@ -93,18 +109,48 @@ describe('terminal live input commit hook', () => { vi.useRealTimers() }) - it('Given Hangul pending text When the old idle window elapses Then does not send jamo to the terminal', async () => { + it('Given Hangul composition When steps arrive Then streams the stable prefix and never leaks jamo', async () => { // Given vi.useFakeTimers() - const { captures, handlers, sent } = createTerminalLiveInputCommitHarness() + const { handlers, sent } = createTerminalLiveInputCommitHarness() + + // When: ㅎ→하→한→한ㄱ→한그→한글 (no settle pause between steps) + for (const fieldText of ['ㅎ', '하', '한', '한ㄱ', '한그', '한글']) { + handlers.handleLiveInputChange(fieldText) + await vi.advanceTimersByTimeAsync(50) + } + + // Then: only the stable prefix went out; the trailing syllable is held + await vi.waitFor(() => expect(sent).toEqual(['한'])) + }) + + it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => { + // Given + vi.useFakeTimers() + const { handlers, sent } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('한') // When - handlers.handleLiveInputChange('ㅎ') - await vi.advanceTimersByTimeAsync(1_000) + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS) // Then - expect(captures).toEqual(['ㅎ']) - expect(sent).toEqual([]) + await vi.waitFor(() => expect(sent).toEqual(['한'])) + }) + + it('Given a timer-committed syllable When composition continues Then corrects with DEL and recommits', async () => { + // Given + vi.useFakeTimers() + const { handlers, sent } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('하') + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS) + await vi.waitFor(() => expect(sent).toEqual(['하'])) + + // When + handlers.handleLiveInputChange('한') + await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS) + + // Then + await vi.waitFor(() => expect(sent).toEqual(['하', '\x7f', '한'])) }) it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => { @@ -119,6 +165,55 @@ describe('terminal live input commit hook', () => { await vi.waitFor(() => expect(sent).toEqual(['한', '\r'])) }) + it('Given no pending text When submit is requested Then sends only carriage return', async () => { + // Given + const { handlers, sent } = createTerminalLiveInputCommitHarness() + + // When + handlers.handleLiveInputSubmit() + + // Then + await vi.waitFor(() => expect(sent).toEqual(['\r'])) + }) + + it('Given a rejected held-text send When submit is requested Then suppresses the carriage return', async () => { + // Given + const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false }) + handlers.handleLiveInputChange('한') + + // When + handlers.handleLiveInputSubmit() + await Promise.resolve() + await Promise.resolve() + + // Then: the held commit went out but was not accepted, so no \r follows + await vi.waitFor(() => expect(sent).toEqual(['한'])) + }) + + it('Given ASCII typing When changes arrive Then mirrors immediately', async () => { + // Given + const { handlers, sent } = createTerminalLiveInputCommitHarness() + + // When + handlers.handleLiveInputChange('a') + handlers.handleLiveInputChange('ab') + + // Then + await vi.waitFor(() => expect(sent).toEqual(['a', 'b'])) + }) + + it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => { + // Given + const { handlers, sent } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('한') + + // When + handlers.handleLiveInputChange('한 ') + + // Then + await vi.waitFor(() => expect(sent).toEqual(['한 '])) + }) + it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => { // Given const { handlers, sent } = createTerminalLiveInputCommitHarness() @@ -145,34 +240,22 @@ describe('terminal live input commit hook', () => { expect(sent).toEqual(['한']) }) - it('Given Chinese and Vietnamese IME text When the settle window elapses Then sends the committed text', async () => { + it('Given non-Hangul IME text When changes arrive Then mirrors immediately without a settle window', async () => { // Given - vi.useFakeTimers() - const { captures, handlers, sent } = createTerminalLiveInputCommitHarness() + const { handlers, sent } = createTerminalLiveInputCommitHarness() // When handlers.handleLiveInputChange('你好') - await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS - 1) // Then - expect(captures).toEqual(['你好']) - expect(sent).toEqual([]) - - // When - await vi.advanceTimersByTimeAsync(1) await vi.waitFor(() => expect(sent).toEqual(['你好'])) - handlers.handleLiveInputChange('tiếng Việt') - await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS) - - // Then - await vi.waitFor(() => expect(sent).toEqual(['你好', 'tiếng Việt'])) }) - it('Given deferred IME text When the hook unmounts Then cancels the pending commit timer', async () => { + it('Given a held syllable When the hook unmounts Then cancels the settle timer', async () => { // Given vi.useFakeTimers() const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness() - handlers.handleLiveInputChange('é') + handlers.handleLiveInputChange('한') // When unmount() @@ -181,4 +264,54 @@ describe('terminal live input commit hook', () => { // Then expect(sent).toEqual([]) }) + + it('Given Backspace with field text When the key arrives Then edits locally without terminal bytes', async () => { + // Given + const { handlers, sent } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('한') + + // When + handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Backspace' } }) + + // Then + await vi.waitFor(() => expect(sent).toEqual([])) + }) + + it('Given Tab with a held syllable When the key arrives Then commits the syllable before the tab bytes', async () => { + // Given + const { handlers, sent } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('한') + + // When + handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Tab' } }) + + // Then + await vi.waitFor(() => expect(sent).toEqual(['한', '\t'])) + }) + + it('Given Hangul pending When the tab type lags to undefined Then keeps the composition state', async () => { + // Given: '한' held while the active tab is still a terminal + const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('한') + + // When: the mobile tab list momentarily yields no active tab object + setActiveSessionTabType(undefined) + handlers.handleLiveInputSubmit() + + // Then: an unknown tab type is not "left the terminal", so pending still flushes + await vi.waitFor(() => expect(sent).toEqual(['한', '\r'])) + }) + + it('Given Hangul pending When the tab genuinely changes to non-terminal Then clears the composition state', async () => { + // Given: '한' held while the active tab is still a terminal + const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness() + handlers.handleLiveInputChange('한') + + // When: the active tab actually becomes a non-terminal (chat) tab + setActiveSessionTabType('chat') + handlers.handleLiveInputSubmit() + + // Then: pending was dropped, so submit sends only the carriage return + await vi.waitFor(() => expect(sent).toEqual(['\r'])) + }) }) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index 2e2dd9e15..cdabe1bed 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -1,10 +1,6 @@ import { useCallback, useEffect, type RefObject } from 'react' import type { TextInput } from 'react-native' -import { - getTerminalLiveSpecialKeyDecision, - getTerminalLiveSubmitSequence, - getTerminalLiveTextChangeDecision -} from './terminal-live-text-commit' +import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input' import type { TerminalLiveInputSender } from './terminal-live-input-sender' @@ -56,11 +52,12 @@ export function useTerminalLiveInputCommit({ setLiveInputCapture }: TerminalLiveInputCommitOptions): TerminalLiveInputCommitHandlers { const { + applyLiveInputMirror, clearPendingLiveInputCommit, flushPendingLiveInputText, + heldLiveInputTextRef, pendingLiveInputHandleRef, - pendingLiveInputTextRef, - schedulePendingLiveInputCommit, + sentLiveInputTextRef, waitForPendingLiveInputFlush } = useTerminalLivePendingInputFlush({ activeHandleRef, @@ -76,10 +73,13 @@ export function useTerminalLiveInputCommit({ if (!pendingHandle) { return } + // Why: a lagging mobile tab list briefly yields no active tab object; a + // null/undefined type is "unknown", not "left the terminal" — flush guards + // still block sends if the tab truly changed. if ( !activeHandle || pendingHandle !== activeHandle || - activeSessionTabType !== 'terminal' || + (activeSessionTabType != null && activeSessionTabType !== 'terminal') || !liveInputTerminalHandles.has(activeHandle) ) { clearPendingLiveInputCommit() @@ -93,7 +93,9 @@ export function useTerminalLiveInputCommit({ clearPendingLiveInputCommit() return waitForPendingLiveInputFlush() } - if (pendingHandle === handle && pendingLiveInputTextRef.current.length > 0) { + // Why: external bytes (dictation/paste) land after the field's echo on the + // PTY; the field session must fully end or later diffs would erase them. + if (pendingHandle === handle) { return flushPendingLiveInputText(handle) } return waitForPendingLiveInputFlush() @@ -108,35 +110,15 @@ export function useTerminalLiveInputCommit({ return } const normalizedText = normalizeTerminalTextInput(text) - const decision = getTerminalLiveTextChangeDecision(normalizedText) - switch (decision.kind) { - case 'ignore': - clearPendingLiveInputCommit() - return - case 'send-now': - clearPendingLiveInputCommit() - void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () => - sendLiveTerminalInputRef.current(activeHandle, decision.text) - ) - return - case 'defer': - // Why: React Native does not expose composition events here, so keep - // probable IME text in the native field until the commit timer settles. - setLiveInputCapture(decision.text) - schedulePendingLiveInputCommit(activeHandle, decision.text, decision.delayMs) - return - default: - decision satisfies never - } + setLiveInputCapture(normalizedText) + applyLiveInputMirror(activeHandle, normalizedText) }, [ activeHandle, + applyLiveInputMirror, clearPendingLiveInputCommit, liveInputTerminalHandles, - schedulePendingLiveInputCommit, - sendLiveTerminalInputRef, - setLiveInputCapture, - waitForPendingLiveInputFlush + setLiveInputCapture ] ) @@ -145,26 +127,25 @@ export function useTerminalLiveInputCommit({ if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { return } - const pendingText = - pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : '' - if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) { + const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle + if (pendingLiveInputHandleRef.current && !ownsPendingState) { clearPendingLiveInputCommit() } const decision = getTerminalLiveSpecialKeyDecision({ key: event.nativeEvent.key, - pendingText + heldText: ownsPendingState ? heldLiveInputTextRef.current : '', + sentText: ownsPendingState ? sentLiveInputTextRef.current : '' }) switch (decision.kind) { case 'ignore': case 'local-edit': return case 'send-now': - clearPendingLiveInputCommit() void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes) ) return - case 'flush-then-send': + case 'commit-held-then-send': void sendTerminalLiveControlAfterPendingFlush( () => flushPendingLiveInputText(activeHandle), () => sendLiveTerminalInputRef.current(activeHandle, decision.bytes) @@ -186,13 +167,14 @@ export function useTerminalLiveInputCommit({ const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({ activeHandle, + applyLiveInputMirror, clearPendingLiveInputCommit, flushPendingLiveInputText, + heldLiveInputTextRef, liveInputRef, liveInputTerminalHandles, pendingLiveInputHandleRef, - pendingLiveInputTextRef, - schedulePendingLiveInputCommit, + sentLiveInputTextRef, sendLiveTerminalInputRef, setLiveInputCapture, waitForPendingLiveInputFlush @@ -202,28 +184,11 @@ export function useTerminalLiveInputCommit({ if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { return } - const pendingText = - pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : '' - const sequence = getTerminalLiveSubmitSequence(pendingText) - if (sequence.length === 2) { - void sendTerminalLiveControlAfterPendingFlush( - () => flushPendingLiveInputText(activeHandle), - () => sendLiveTerminalInputRef.current(activeHandle, sequence[1]) - ) - return - } - clearPendingLiveInputCommit() - void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () => - sendLiveTerminalInputRef.current(activeHandle, sequence[0]) + void sendTerminalLiveControlAfterPendingFlush( + () => flushPendingLiveInputText(activeHandle), + () => sendLiveTerminalInputRef.current(activeHandle, '\r') ) - }, [ - activeHandle, - clearPendingLiveInputCommit, - flushPendingLiveInputText, - liveInputTerminalHandles, - sendLiveTerminalInputRef, - waitForPendingLiveInputFlush - ]) + }, [activeHandle, flushPendingLiveInputText, liveInputTerminalHandles, sendLiveTerminalInputRef]) return { clearPendingLiveInputCommit, diff --git a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts index 2c6998c99..f62393129 100644 --- a/mobile/src/terminal/use-terminal-live-pending-input-flush.ts +++ b/mobile/src/terminal/use-terminal-live-pending-input-flush.ts @@ -2,7 +2,12 @@ import { useCallback, useEffect, useRef, type RefObject } from 'react' import type { TextInput } from 'react-native' import type { TerminalLiveInputSender } from './terminal-live-input-sender' import { - queueTerminalLivePendingFlush, + buildTerminalLiveMirrorPayload, + computeTerminalLiveMirrorStep, + TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS +} from './terminal-live-hangul-mirror' +import { + queueTerminalLiveMirrorSend, waitForTerminalLivePendingFlush } from './terminal-live-pending-flush-state' @@ -16,15 +21,12 @@ type TerminalLivePendingInputFlushOptions = { } type TerminalLivePendingInputFlush = { + readonly applyLiveInputMirror: (handle: string, fieldText: string) => void readonly clearPendingLiveInputCommit: () => void readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise + readonly heldLiveInputTextRef: RefObject readonly pendingLiveInputHandleRef: RefObject - readonly pendingLiveInputTextRef: RefObject - readonly schedulePendingLiveInputCommit: ( - handle: string, - text: string, - delayMs: number | null - ) => void + readonly sentLiveInputTextRef: RefObject readonly waitForPendingLiveInputFlush: () => Promise } @@ -36,104 +38,143 @@ export function useTerminalLivePendingInputFlush({ sendLiveTerminalInputRef, setLiveInputCapture }: TerminalLivePendingInputFlushOptions): TerminalLivePendingInputFlush { - const liveInputCommitTimerRef = useRef | null>(null) + const heldCommitTimerRef = useRef | null>(null) const pendingLiveInputFlushRef = useRef | null>(null) - const pendingLiveInputTextRef = useRef('') + const heldLiveInputTextRef = useRef('') + const sentLiveInputTextRef = useRef('') const pendingLiveInputHandleRef = useRef(null) + const runMirrorStepRef = useRef< + (handle: string, fieldText: string, commitHeld: boolean) => Promise + >(async () => false) + + const clearHeldCommitTimer = useCallback(() => { + if (heldCommitTimerRef.current) { + clearTimeout(heldCommitTimerRef.current) + heldCommitTimerRef.current = null + } + }, []) + + const resetMirrorState = useCallback(() => { + clearHeldCommitTimer() + heldLiveInputTextRef.current = '' + sentLiveInputTextRef.current = '' + pendingLiveInputHandleRef.current = null + }, [clearHeldCommitTimer]) const clearPendingLiveInputCommit = useCallback(() => { - if (liveInputCommitTimerRef.current) { - clearTimeout(liveInputCommitTimerRef.current) - liveInputCommitTimerRef.current = null - } - pendingLiveInputTextRef.current = '' - pendingLiveInputHandleRef.current = null + resetMirrorState() setLiveInputCapture('') liveInputRef.current?.setNativeProps({ text: '' }) - }, [liveInputRef, setLiveInputCapture]) + }, [liveInputRef, resetMirrorState, setLiveInputCapture]) const waitForPendingLiveInputFlush = useCallback(async (): Promise => { return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef) }, []) - const flushPendingLiveInputText = useCallback( - async (expectedHandle: string | null): Promise => { - const existingFlush = pendingLiveInputFlushRef.current - if (liveInputCommitTimerRef.current) { - clearTimeout(liveInputCommitTimerRef.current) - liveInputCommitTimerRef.current = null - } - - const handle = pendingLiveInputHandleRef.current - const text = pendingLiveInputTextRef.current - pendingLiveInputHandleRef.current = null - pendingLiveInputTextRef.current = '' - setLiveInputCapture('') - liveInputRef.current?.setNativeProps({ text: '' }) - - if (!handle || text.length === 0) { - return existingFlush ?? false - } + const runMirrorStep = useCallback( + async (handle: string, fieldText: string, commitHeld: boolean): Promise => { if ( - (expectedHandle !== null && handle !== expectedHandle) || handle !== activeHandleRef.current || - activeSessionTabTypeRef.current !== 'terminal' || + (activeSessionTabTypeRef.current != null && + activeSessionTabTypeRef.current !== 'terminal') || !liveInputTerminalHandlesRef.current.has(handle) ) { + // Why: a stale handle must not keep local mirror state alive — the next + // active terminal would inherit wrong erase counts. A null tab type is + // "unknown" during tab-list lag, not "left the terminal", so it must not trip. + resetMirrorState() return false } - return queueTerminalLivePendingFlush(pendingLiveInputFlushRef, () => - sendLiveTerminalInputRef.current(handle, text) + const step = computeTerminalLiveMirrorStep(sentLiveInputTextRef.current, fieldText, { + commitHeld + }) + sentLiveInputTextRef.current = step.nextSentText + heldLiveInputTextRef.current = step.heldText + pendingLiveInputHandleRef.current = + step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null + + clearHeldCommitTimer() + if (step.heldText.length > 0) { + heldCommitTimerRef.current = setTimeout(() => { + heldCommitTimerRef.current = null + const heldField = sentLiveInputTextRef.current + heldLiveInputTextRef.current + void runMirrorStepRef.current(handle, heldField, true) + }, TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS) + } + + const payload = buildTerminalLiveMirrorPayload(step) + if (payload.length === 0) { + return waitForPendingLiveInputFlush() + } + return queueTerminalLiveMirrorSend(pendingLiveInputFlushRef, () => + sendLiveTerminalInputRef.current(handle, payload) ) }, [ activeHandleRef, activeSessionTabTypeRef, - liveInputRef, + clearHeldCommitTimer, liveInputTerminalHandlesRef, + resetMirrorState, sendLiveTerminalInputRef, - setLiveInputCapture + waitForPendingLiveInputFlush ] ) + runMirrorStepRef.current = runMirrorStep - const schedulePendingLiveInputCommit = useCallback( - (handle: string, text: string, delayMs: number | null) => { - if (liveInputCommitTimerRef.current) { - clearTimeout(liveInputCommitTimerRef.current) - } - pendingLiveInputHandleRef.current = handle - pendingLiveInputTextRef.current = text - if (delayMs === null) { - liveInputCommitTimerRef.current = null - return - } - liveInputCommitTimerRef.current = setTimeout(() => { - liveInputCommitTimerRef.current = null - void flushPendingLiveInputText(handle) - }, delayMs) + const applyLiveInputMirror = useCallback( + (handle: string, fieldText: string): void => { + void runMirrorStep(handle, fieldText, false) }, - [flushPendingLiveInputText] + [runMirrorStep] + ) + + const flushPendingLiveInputText = useCallback( + async (expectedHandle: string | null): Promise => { + const handle = pendingLiveInputHandleRef.current + if (!handle) { + return waitForPendingLiveInputFlush() + } + if (expectedHandle !== null && handle !== expectedHandle) { + clearPendingLiveInputCommit() + return waitForPendingLiveInputFlush() + } + + const heldText = heldLiveInputTextRef.current + const result = + heldText.length > 0 + ? await runMirrorStep(handle, sentLiveInputTextRef.current + heldText, true) + : await waitForPendingLiveInputFlush() + + // Why: an explicit flush ends the field's editing session; the echoed PTY + // text stays, so local mirror state must restart from empty. + clearPendingLiveInputCommit() + return result + }, + [clearPendingLiveInputCommit, runMirrorStep, waitForPendingLiveInputFlush] ) useEffect(() => { return () => { - if (liveInputCommitTimerRef.current) { - clearTimeout(liveInputCommitTimerRef.current) - liveInputCommitTimerRef.current = null + if (heldCommitTimerRef.current) { + clearTimeout(heldCommitTimerRef.current) + heldCommitTimerRef.current = null } + heldLiveInputTextRef.current = '' + sentLiveInputTextRef.current = '' pendingLiveInputHandleRef.current = null - pendingLiveInputTextRef.current = '' pendingLiveInputFlushRef.current = null } }, []) return { + applyLiveInputMirror, clearPendingLiveInputCommit, flushPendingLiveInputText, + heldLiveInputTextRef, pendingLiveInputHandleRef, - pendingLiveInputTextRef, - schedulePendingLiveInputCommit, + sentLiveInputTextRef, waitForPendingLiveInputFlush } }