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 <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-09 03:35:15 -07:00
co-authored by Orca
parent ee823b766c
commit 44028d2cb7
4 changed files with 60 additions and 6 deletions
@@ -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"
@@ -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)')
})
})
@@ -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()
@@ -109,9 +109,11 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
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,