From 44028d2cb773c6a2d2f8962c806c6a9dca807d6e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:35:15 -0700 Subject: [PATCH] Fix iOS native keyboard dictation in mobile terminal inputs iOS terminates an active keyboard-dictation (and IME) session whenever JS writes text into the focused field that differs from the native text. Both terminal inputs echoed normalized text back into their controlled value, so smart-dash rewrites and dictation hypothesis revisions triggered native setTextAndSelection writes that killed dictation. Store the raw field text in the controlled value and apply dash normalization only on the PTY mirror / send path, which already normalizes. Refs stablyai/orca#7925. Co-authored-by: Orca --- .../app/h/[hostId]/session/[worktreeId].tsx | 7 ++--- .../terminal-ios-dictation-write-back.test.ts | 25 ++++++++++++++++++ .../use-terminal-live-input-commit.test.ts | 26 +++++++++++++++++++ .../use-terminal-live-input-commit.ts | 8 +++--- 4 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 mobile/src/terminal/terminal-ios-dictation-write-back.test.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 2119c2da29b..c4701a302db 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -5061,9 +5061,10 @@ export default function SessionScreen() { } style={styles.textInput} value={input} - onChangeText={(text) => - setInput((previousText) => normalizeTerminalTextInput(text, previousText)) - } + // Why: iOS kills an active dictation/IME session when JS + // writes a value that differs from the native field text; + // store the raw field text and normalize at send time. + onChangeText={setInput} placeholder="Type a command…" placeholderTextColor={colors.textMuted} autoCapitalize="none" diff --git a/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts b/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts new file mode 100644 index 00000000000..8dff075d85e --- /dev/null +++ b/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts @@ -0,0 +1,25 @@ +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' +) + +// Why: iOS terminates an active keyboard-dictation (and IME) session whenever +// JS writes a value into the focused field that differs from the native text +// (RN applies it via setTextAndSelection / _setAttributedString). Terminal +// inputs therefore must echo the raw field text in their controlled value and +// apply dash normalization only on the send/mirror path. See stablyai/orca#7925. +describe('terminal iOS dictation write-back', () => { + it('does not write normalized text back into the buffered command input value', () => { + expect(sessionRouteSource).toContain('onChangeText={setInput}') + expect(sessionRouteSource).not.toContain( + 'setInput((previousText) => normalizeTerminalTextInput' + ) + }) + + it('still normalizes the buffered command text at send time', () => { + expect(sessionRouteSource).toContain('normalizeTerminalTextInput(input)') + }) +}) 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 3ddb8868270..b759e30d615 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -202,6 +202,32 @@ describe('terminal live input commit hook', () => { await vi.waitFor(() => expect(sent).toEqual(['a', 'b'])) }) + it('Given iOS smart-dash text When the change arrives Then the capture echoes the raw field text and the PTY gets normalized bytes', async () => { + // Given + const { captures, handlers, sent } = createTerminalLiveInputCommitHarness() + + // When: iOS smart punctuation rewrote "--" into an en dash inside the field + handlers.handleLiveInputChange('a–') + + // Then: writing "a--" back into the controlled value would kill an active + // iOS dictation/IME session, so the capture must keep what iOS produced + expect(captures).toEqual(['a–']) + await vi.waitFor(() => expect(sent).toEqual(['a--'])) + }) + + it('Given dictation-style hypothesis revisions When changes arrive Then the field is never rewritten and the PTY converges', async () => { + // Given + const { captures, handlers, sent } = createTerminalLiveInputCommitHarness() + + // When: iOS dictation replaces its hypothesis as recognition refines + handlers.handleLiveInputChange('high') + handlers.handleLiveInputChange('hi there') + + // Then: captures only echo the field; the mirror repairs the PTY with DELs + expect(captures).toEqual(['high', 'hi there']) + await vi.waitFor(() => expect(sent).toEqual(['high', '\x7f\x7f there'])) + }) + it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => { // Given const { handlers, sent } = createTerminalLiveInputCommitHarness() diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index cdabe1bed49..9c707dfbb31 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -109,9 +109,11 @@ export function useTerminalLiveInputCommit({ clearPendingLiveInputCommit() return } - const normalizedText = normalizeTerminalTextInput(text) - setLiveInputCapture(normalizedText) - applyLiveInputMirror(activeHandle, normalizedText) + // 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. + setLiveInputCapture(text) + applyLiveInputMirror(activeHandle, normalizeTerminalTextInput(text)) }, [ activeHandle,