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,