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 1ca5216c7a8..3ae65ca12ec 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 @@ -38,6 +38,7 @@ function suppressReactTestRendererDeprecationWarning(): () => void { } type AccessoryInputCommitHarnessOptions = { + readonly compositionHandle?: string | null readonly heldText?: string readonly sentText?: string readonly pendingHandle?: string | null @@ -58,6 +59,7 @@ type AccessoryInputCommitHarness = { } function createAccessoryInputCommitHarness({ + compositionHandle = null, heldText = '', sentText = '', pendingHandle = null, @@ -70,6 +72,9 @@ function createAccessoryInputCommitHarness({ const sentLiveInputTextRef: RefObject = { current: sentText } const pendingLiveInputHandleRef: RefObject = { current: pendingHandle } const liveInputRef: RefObject = { current: null } + const liveInputCompositionHandleRef: RefObject = { + current: compositionHandle + } const liveInputTerminalHandles = new Set([activeHandle]) const sent: string[] = [] const sendLiveTerminalInputRef: RefObject = { @@ -95,6 +100,7 @@ function createAccessoryInputCommitHarness({ flushPendingLiveInputText, heldLiveInputTextRef, liveInputRef, + liveInputCompositionHandleRef, liveInputTerminalHandles, pendingLiveInputHandleRef, sentLiveInputTextRef, @@ -165,6 +171,16 @@ describe('terminal live accessory inactive input commit result', () => { }) describe('terminal live accessory input commit hook', () => { + it('Given native marked text When an accessory key is pressed Then suppresses raw input', async () => { + const harness = createAccessoryInputCommitHarness({ compositionHandle: 'terminal-a' }) + + const result = await harness.commit({ bytes: '\x1b' }) + + expect(result).toEqual({ kind: 'suppress-raw' }) + expect(harness.waitForPendingLiveInputFlush).not.toHaveBeenCalled() + expect(harness.sent).toEqual([]) + }) + it('Given raw accessory bytes with a held syllable When committed Then flushes held text before sending bytes', async () => { // Given const harness = createAccessoryInputCommitHarness({ 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 0893ae16098..bfba227ab17 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts @@ -26,6 +26,7 @@ type TerminalLiveAccessoryInputCommitOptions = { readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise readonly heldLiveInputTextRef: RefObject readonly liveInputRef: RefObject + readonly liveInputCompositionHandleRef: RefObject readonly liveInputTerminalHandles: ReadonlySet readonly pendingLiveInputHandleRef: RefObject readonly sentLiveInputTextRef: RefObject @@ -41,6 +42,7 @@ export function useTerminalLiveAccessoryInputCommit({ flushPendingLiveInputText, heldLiveInputTextRef, liveInputRef, + liveInputCompositionHandleRef, liveInputTerminalHandles, pendingLiveInputHandleRef, sentLiveInputTextRef, @@ -58,6 +60,9 @@ export function useTerminalLiveAccessoryInputCommit({ if (!liveInputTerminalHandles.has(activeHandle)) { return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush) } + if (liveInputCompositionHandleRef.current === activeHandle) { + return { kind: 'suppress-raw' } + } const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle if (pendingLiveInputHandleRef.current && !ownsPendingState) { clearPendingLiveInputCommit() @@ -102,6 +107,7 @@ export function useTerminalLiveAccessoryInputCommit({ flushPendingLiveInputText, heldLiveInputTextRef, liveInputRef, + liveInputCompositionHandleRef, liveInputTerminalHandles, pendingLiveInputHandleRef, sentLiveInputTextRef, 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 bd82851bfd6..f41d30f41f4 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -10,6 +10,8 @@ type TerminalLiveInputCommitHarness = { readonly captures: readonly string[] readonly handlers: ReturnType> readonly sent: readonly string[] + readonly sentByHandle: readonly { readonly bytes: string; readonly handle: string }[] + readonly setActiveHandle: (next: string) => void readonly setActiveSessionTabType: (next: string | undefined) => void readonly setConnected: (next: boolean) => void readonly setSendResult: (next: boolean) => void @@ -35,23 +37,25 @@ function suppressReactTestRendererDeprecationWarning(): () => void { function createTerminalLiveInputCommitHarness({ sendResult = true }: TerminalLiveInputCommitHarnessOptions = {}): TerminalLiveInputCommitHarness { - const activeHandle = 'terminal-a' - const activeHandleRef: RefObject = { current: activeHandle } + let currentActiveHandle = 'terminal-a' + const activeHandleRef: RefObject = { current: currentActiveHandle } 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 liveInputTerminalHandles = new Set(['terminal-a', 'terminal-b']) const liveInputTerminalHandlesRef: RefObject> = { - current: new Set([activeHandle]) + current: new Set(liveInputTerminalHandles) } const sent: string[] = [] + const sentByHandle: { bytes: string; handle: string }[] = [] let currentSendResult = sendResult const sendLiveTerminalInputRef: RefObject = { - current: async (_handle, bytes) => { + current: async (handle, bytes) => { sent.push(bytes) + sentByHandle.push({ bytes, handle }) return currentSendResult } } @@ -63,7 +67,7 @@ function createTerminalLiveInputCommitHarness({ function Harness(): null { handlers = useTerminalLiveInputCommit({ - activeHandle, + activeHandle: currentActiveHandle, activeHandleRef, activeSessionTabType: currentActiveSessionTabType, activeSessionTabTypeRef, @@ -91,8 +95,21 @@ function createTerminalLiveInputCommitHarness({ return { captures, - handlers, + get handlers() { + if (!handlers) { + throw new Error('terminal live input hook is not mounted') + } + return handlers + }, sent, + sentByHandle, + setActiveHandle: (next: string): void => { + currentActiveHandle = next + activeHandleRef.current = next + act(() => { + renderer?.update(createElement(Harness)) + }) + }, setActiveSessionTabType: (next: string | undefined): void => { currentActiveSessionTabType = next // Ref and prop derive from the same activeSessionTab in the real route, so @@ -241,6 +258,72 @@ describe('terminal live input commit hook', () => { await vi.waitFor(() => expect(sent).toEqual(['かな'])) }) + it('Given marked text When the active terminal switches Then clears it without sending', () => { + const harness = createTerminalLiveInputCommitHarness() + changeLiveInput(harness.handlers, 'かな', true) + + harness.setActiveHandle('terminal-b') + + expect(harness.captures).toEqual(['かな', '']) + expect(harness.sent).toEqual([]) + }) + + it('Given a switch during marked text When its final event arrives Then rejects it for the new terminal', async () => { + const harness = createTerminalLiveInputCommitHarness() + changeLiveInput(harness.handlers, 'かな', true) + harness.setActiveHandle('terminal-b') + + changeLiveInput(harness.handlers, '仮名', false) + + await Promise.resolve() + expect(harness.captures.at(-1)).toBe('') + expect(harness.sentByHandle).toEqual([]) + }) + + it('Given active marked text When an external send requests a flush Then suppresses it until commit', async () => { + const harness = createTerminalLiveInputCommitHarness() + changeLiveInput(harness.handlers, 'かな', true) + + await expect( + harness.handlers.flushPendingLiveInputBeforeExternalSend('terminal-a') + ).resolves.toBe(false) + expect(harness.sent).toEqual([]) + + changeLiveInput(harness.handlers, '仮名', false) + await vi.waitFor(() => expect(harness.sent).toEqual(['仮名'])) + await expect( + harness.handlers.flushPendingLiveInputBeforeExternalSend('terminal-a') + ).resolves.toBe(true) + }) + + it('Given active marked text When an accessory key is pressed Then suppresses it until commit', async () => { + const harness = createTerminalLiveInputCommitHarness() + changeLiveInput(harness.handlers, 'かな', true) + + await expect( + harness.handlers.handleLiveInputAccessoryBytes({ bytes: '\x1b' }) + ).resolves.toEqual({ kind: 'suppress-raw' }) + + changeLiveInput(harness.handlers, '仮名', false) + await vi.waitFor(() => expect(harness.sent).toEqual(['仮名'])) + await expect( + harness.handlers.handleLiveInputAccessoryBytes({ bytes: '\x1b' }) + ).resolves.toEqual({ kind: 'allow-raw' }) + }) + + it('Given a switch clears marked text When controls target the new terminal Then allows them', async () => { + const harness = createTerminalLiveInputCommitHarness() + changeLiveInput(harness.handlers, 'かな', true) + harness.setActiveHandle('terminal-b') + + await expect( + harness.handlers.flushPendingLiveInputBeforeExternalSend('terminal-b') + ).resolves.toBe(true) + await expect( + harness.handlers.handleLiveInputAccessoryBytes({ bytes: '\x1b' }) + ).resolves.toEqual({ kind: 'allow-raw' }) + }) + it('Given active composition When native control events arrive Then keeps them inside the IME', () => { const { handlers, sent } = createTerminalLiveInputCommitHarness() changeLiveInput(handlers, 'かな', true) diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index 4f19e5dd5bf..31fb43072e2 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -60,7 +60,8 @@ export function useTerminalLiveInputCommit({ sendLiveTerminalInputRef, setLiveInputCapture }: TerminalLiveInputCommitOptions): TerminalLiveInputCommitHandlers { - const liveInputComposingRef = useRef(false) + const liveInputCompositionHandleRef = useRef(null) + const staleLiveInputCompositionHandleRef = useRef(null) const { applyLiveInputMirror, clearPendingLiveInputCommit: clearPendingLiveInputMirror, @@ -79,7 +80,10 @@ export function useTerminalLiveInputCommit({ }) const clearPendingLiveInputCommit = useCallback(() => { - liveInputComposingRef.current = false + if (liveInputCompositionHandleRef.current) { + staleLiveInputCompositionHandleRef.current = liveInputCompositionHandleRef.current + liveInputCompositionHandleRef.current = null + } clearPendingLiveInputMirror() }, [clearPendingLiveInputMirror]) @@ -92,7 +96,9 @@ export function useTerminalLiveInputCommit({ useEffect(() => { const pendingHandle = pendingLiveInputHandleRef.current - if (!pendingHandle) { + const compositionHandle = liveInputCompositionHandleRef.current + const inputOwnerHandle = compositionHandle ?? pendingHandle + if (!inputOwnerHandle) { return } // Why: a lagging mobile tab list briefly yields no active tab object; a @@ -100,7 +106,7 @@ export function useTerminalLiveInputCommit({ // still block sends if the tab truly changed. if ( !activeHandle || - pendingHandle !== activeHandle || + inputOwnerHandle !== activeHandle || (activeSessionTabType != null && activeSessionTabType !== 'terminal') || !liveInputTerminalHandles.has(activeHandle) ) { @@ -110,6 +116,10 @@ export function useTerminalLiveInputCommit({ const flushPendingLiveInputBeforeExternalSend = useCallback( async (handle: string): Promise => { + // Marked text belongs to the native IME until its final onChange event. + if (liveInputCompositionHandleRef.current === handle) { + return false + } const pendingHandle = pendingLiveInputHandleRef.current if (pendingHandle && pendingHandle !== handle) { clearPendingLiveInputCommit() @@ -131,14 +141,29 @@ export function useTerminalLiveInputCommit({ clearPendingLiveInputCommit() return } + if (isComposing === true) { + staleLiveInputCompositionHandleRef.current = null + liveInputCompositionHandleRef.current = activeHandle + setLiveInputCapture(text) + return + } + + const compositionHandle = liveInputCompositionHandleRef.current + if ( + (compositionHandle && compositionHandle !== activeHandle) || + (!compositionHandle && staleLiveInputCompositionHandleRef.current) + ) { + liveInputCompositionHandleRef.current = null + staleLiveInputCompositionHandleRef.current = null + clearPendingLiveInputMirror() + return + } // Why: iOS kills an active dictation/IME session when JS writes a value // that differs from the native field text, so the controlled capture must // echo the field verbatim; only the PTY mirror sees normalized text. + liveInputCompositionHandleRef.current = null + staleLiveInputCompositionHandleRef.current = null setLiveInputCapture(text) - liveInputComposingRef.current = isComposing === true - if (liveInputComposingRef.current) { - return - } applyLiveInputMirror(activeHandle, normalizeTerminalTextInput(text)) }, [ @@ -155,7 +180,7 @@ export function useTerminalLiveInputCommit({ if ( !activeHandle || !liveInputTerminalHandles.has(activeHandle) || - liveInputComposingRef.current + liveInputCompositionHandleRef.current === activeHandle ) { return } @@ -201,6 +226,7 @@ export function useTerminalLiveInputCommit({ activeHandle, applyLiveInputMirror, clearPendingLiveInputCommit, + liveInputCompositionHandleRef, flushPendingLiveInputText, heldLiveInputTextRef, liveInputRef, @@ -216,7 +242,7 @@ export function useTerminalLiveInputCommit({ if ( !activeHandle || !liveInputTerminalHandles.has(activeHandle) || - liveInputComposingRef.current + liveInputCompositionHandleRef.current === activeHandle ) { return }