fix(mobile): narrow to #4606 — drop the Android autoCorrect omission

#6995 turned out to be already fixed on main by #7011 (4f0a141951) and
#7273 (e8c2b79a93): a physical Samsung with Samsung Keyboard composes
Hangul correctly on a build that does not contain the omission change.
Shipping it anyway would surface the suggestion strip over the invisible
live-input buffer to fix nothing. What stays is the #4606 half: the
tri-state autocomplete preference, autocorrect defaulting on for the iOS
command bar, and the props-derived Android remount key.
This commit is contained in:
Brennan Benson
2026-07-29 17:00:53 -07:00
parent 7f6705a142
commit e2cbcb56da
4 changed files with 78 additions and 163 deletions
@@ -902,7 +902,7 @@ export default function SessionScreen() {
const [terminalTextScale, setTerminalTextScale] = useState(1)
// Why: terminal command-bar autocomplete opt-in, reloaded on focus so a Settings → Terminal toggle takes effect on return.
const [autocompletePref, setAutocompletePref] = useState<TerminalAutocompletePreference>('unset')
const commandAutocorrect = isTerminalAutocorrectEnabled('command', Platform.OS, autocompletePref)
const commandAutocorrect = isTerminalAutocorrectEnabled(Platform.OS, autocompletePref)
const [terminalLinkOpenMode, setTerminalLinkOpenMode] =
useState<MobileTerminalLinkOpenMode>('orca-browser')
const [liveInputCapture, setLiveInputCapture] = useState('')
@@ -5025,8 +5025,8 @@ export default function SessionScreen() {
placeholder=""
showSoftInputOnFocus
autoCapitalize="none"
// Why: a hardcoded false here becomes Android's NO_SUGGESTIONS inputType, which Samsung Keyboard answers by killing IME composition (#6995).
{...getTerminalImeInputProps('live', Platform.OS, autocompletePref)}
autoCorrect={false}
spellCheck={false}
smartInsertDelete={false}
// Why: iOS textContentType overrides autoComplete and can narrow the keyboard; keep IME switching available.
autoComplete="off"
@@ -5042,7 +5042,7 @@ export default function SessionScreen() {
<TextInput
ref={commandInputRef}
// Why: Android caches IME inputType at mount, so toggling autocomplete must remount there; iOS updates in place.
key={getTerminalImeRemountKey('command', Platform.OS, autocompletePref)}
key={getTerminalImeRemountKey(Platform.OS, autocompletePref)}
style={styles.textInput}
value={input}
// Why: iOS kills active dictation/IME if JS writes a value differing from native text; store raw, normalize at send.
@@ -5050,7 +5050,7 @@ export default function SessionScreen() {
placeholder="Type a command…"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
{...getTerminalImeInputProps('command', Platform.OS, autocompletePref)}
{...getTerminalImeInputProps(Platform.OS, autocompletePref)}
smartInsertDelete={false}
// Why: not autofill content, but keyboard must stay default so non-Latin IMEs remain selectable.
autoComplete="off"
+5 -8
View File
@@ -159,7 +159,7 @@ export default function TerminalSettingsScreen() {
// Why: the switch must show what the command bar actually does, so an unset
// preference resolves through the same per-platform default the input uses.
const [autocompleteEnabled, setAutocompleteEnabled] = useState(() =>
isTerminalAutocorrectEnabled('command', Platform.OS, 'unset')
isTerminalAutocorrectEnabled(Platform.OS, 'unset')
)
// Why: a fast toggle before the initial load resolves must win — otherwise the
// delayed read would clobber the user's choice with the stored (stale) value.
@@ -168,7 +168,7 @@ export default function TerminalSettingsScreen() {
let stale = false
void loadTerminalAutocompletePreference().then((preference) => {
if (!stale && !userToggledAutocompleteRef.current) {
setAutocompleteEnabled(isTerminalAutocorrectEnabled('command', Platform.OS, preference))
setAutocompleteEnabled(isTerminalAutocorrectEnabled(Platform.OS, preference))
}
})
return () => {
@@ -334,12 +334,9 @@ export default function TerminalSettingsScreen() {
<Text style={[styles.groupHeading, styles.inputGroupGap]}>KEYBOARD INPUT</Text>
<Text style={styles.groupDescription}>
Enable phone-style autocomplete, autocorrect, and spelling suggestions in the terminal
command bar.{' '}
{isTerminalAutocorrectEnabled('command', Platform.OS, 'unset') ? 'On' : 'Off'} by default.
Direct keyboard input (when keys go straight to the terminal) never autocorrects.
{Platform.OS === 'android'
? ' Left untouched on Android the keyboard may still offer suggestions you can tap; turning this off suppresses them, but also stops Korean and Chinese composing.'
: ''}
command bar. {isTerminalAutocorrectEnabled(Platform.OS, 'unset') ? 'On' : 'Off'} by
default. Direct keyboard input (when keys go straight to the terminal) always sends raw
keystrokes, so suggestions don&apos;t apply there.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<View style={styles.row}>
@@ -5,7 +5,8 @@ import {
getTerminalImeRemountKey,
isTerminalAutocorrectEnabled,
parseTerminalAutocompletePreference,
type TerminalAutocompletePreference
type TerminalAutocompletePreference,
type TerminalImePlatform
} from './terminal-ime-input-props'
const sessionRouteSource = readFileSync(
@@ -19,6 +20,7 @@ const terminalSettingsSource = readFileSync(
)
const PREFERENCES: readonly TerminalAutocompletePreference[] = ['on', 'off', 'unset']
const PLATFORMS: readonly TerminalImePlatform[] = ['android', 'ios', 'web', 'windows', 'macos']
describe('parseTerminalAutocompletePreference', () => {
it('keeps a missing value distinguishable from an explicit off', () => {
@@ -36,87 +38,43 @@ describe('parseTerminalAutocompletePreference', () => {
})
describe('isTerminalAutocorrectEnabled', () => {
it('never enables autocorrect for direct (live) entry on any platform or preference', () => {
for (const platform of ['android', 'ios', 'web', 'windows', 'macos'] as const) {
for (const preference of PREFERENCES) {
expect(isTerminalAutocorrectEnabled('live', platform, preference)).toBe(false)
}
}
})
// #4606: non-direct entry is reviewed before send, so iOS gets phone-style typing by default.
// #4606: command-bar entry is buffered and reviewed before send, so iOS gets
// phone-style typing by default.
it('defaults command-bar autocorrect on for iOS and off elsewhere', () => {
expect(isTerminalAutocorrectEnabled('command', 'ios', 'unset')).toBe(true)
expect(isTerminalAutocorrectEnabled('command', 'android', 'unset')).toBe(false)
expect(isTerminalAutocorrectEnabled('command', 'web', 'unset')).toBe(false)
expect(isTerminalAutocorrectEnabled('ios', 'unset')).toBe(true)
expect(isTerminalAutocorrectEnabled('android', 'unset')).toBe(false)
expect(isTerminalAutocorrectEnabled('web', 'unset')).toBe(false)
expect(isTerminalAutocorrectEnabled('windows', 'unset')).toBe(false)
expect(isTerminalAutocorrectEnabled('macos', 'unset')).toBe(false)
})
it('lets an explicit preference override the per-platform default in both directions', () => {
expect(isTerminalAutocorrectEnabled('command', 'ios', 'off')).toBe(false)
expect(isTerminalAutocorrectEnabled('command', 'android', 'on')).toBe(true)
expect(isTerminalAutocorrectEnabled('ios', 'off')).toBe(false)
expect(isTerminalAutocorrectEnabled('android', 'on')).toBe(true)
})
})
describe('getTerminalImeInputProps', () => {
// #6995: RN maps autoCorrect={false} to Android's NO_SUGGESTIONS inputType, and Samsung
// Keyboard answers that flag by disabling IME composition — Hangul arrives as raw jamo.
// The one deliberate exception is an explicit command-bar Off — the user asked for
// suppression and it is reversible. Nothing else on Android may emit the flag.
it('emits an explicit false autoCorrect on Android only where the user asked for it', () => {
for (const entryMode of ['live', 'command'] as const) {
it('emits autoCorrect and spellCheck together from the resolved preference', () => {
for (const platform of PLATFORMS) {
for (const preference of PREFERENCES) {
const props = getTerminalImeInputProps(entryMode, 'android', preference)
expect(props.autoCorrect === false, `${entryMode}/${preference}`).toBe(
entryMode === 'command' && preference === 'off'
const enabled = isTerminalAutocorrectEnabled(platform, preference)
expect(getTerminalImeInputProps(platform, preference), `${platform}/${preference}`).toEqual(
{ autoCorrect: enabled, spellCheck: enabled }
)
}
}
})
it('omits autoCorrect entirely on Android when the preference is untouched', () => {
expect(getTerminalImeInputProps('live', 'android', 'unset')).toEqual({})
expect(getTerminalImeInputProps('command', 'android', 'unset')).toEqual({})
})
// Otherwise Off emits the same props as untouched — a control that does nothing, with no
// way back to suppression. It costs IME composition, which is the point of asking.
it('honours an explicit Android Off instead of making the switch a no-op', () => {
expect(getTerminalImeInputProps('command', 'android', 'off')).toEqual({ autoCorrect: false })
expect(getTerminalImeInputProps('command', 'android', 'off')).not.toEqual(
getTerminalImeInputProps('command', 'android', 'unset')
)
})
// The toggle never claimed to govern direct entry, and #6995 was reported on the live path,
// so an explicit Off must not reintroduce NO_SUGGESTIONS there.
it('keeps Android direct entry composing even when the user turned autocorrect off', () => {
for (const preference of PREFERENCES) {
expect(getTerminalImeInputProps('live', 'android', preference)).toEqual({})
}
})
it('still opts Android into autocorrect when the user asked for it', () => {
expect(getTerminalImeInputProps('command', 'android', 'on')).toEqual({ autoCorrect: true })
})
// spellCheck is iOS-only in RN, so it must not ride along on the Android path.
it('keeps spellCheck off the Android props entirely', () => {
for (const preference of PREFERENCES) {
expect(
Object.hasOwn(getTerminalImeInputProps('command', 'android', preference), 'spellCheck')
).toBe(false)
}
})
it('keeps iOS direct entry raw while defaulting the command bar to autocorrect', () => {
expect(getTerminalImeInputProps('live', 'ios', 'unset')).toEqual({
autoCorrect: false,
spellCheck: false
})
expect(getTerminalImeInputProps('command', 'ios', 'unset')).toEqual({
it('defaults the iOS command bar to autocorrect while Android stays raw (#4606)', () => {
expect(getTerminalImeInputProps('ios', 'unset')).toEqual({
autoCorrect: true,
spellCheck: true
})
expect(getTerminalImeInputProps('android', 'unset')).toEqual({
autoCorrect: false,
spellCheck: false
})
})
})
@@ -131,36 +89,32 @@ function sliceBetween(source: string, startAnchor: string, endAnchor: string): s
}
describe('getTerminalImeRemountKey', () => {
// Android caches inputType at mount. Before the explicit-Off case existed the key tracked a
// boolean, which cannot distinguish {} from {autoCorrect:false} — so unset -> off would have
// kept the key and the user's Off would never have reached the IME.
it('gives every distinct Android prop shape a distinct key', () => {
const keys = PREFERENCES.map((preference) =>
getTerminalImeRemountKey('command', 'android', preference)
)
expect(new Set(keys).size).toBe(PREFERENCES.length)
})
// Android caches IME inputType at mount, so the key must change exactly when the emitted
// props change. Deriving it from the props (not a separately-maintained boolean) makes
// key-vs-props drift structurally impossible.
it('changes the Android key exactly when the emitted props change', () => {
for (const a of PREFERENCES) {
for (const b of PREFERENCES) {
const sameProps =
JSON.stringify(getTerminalImeInputProps('command', 'android', a)) ===
JSON.stringify(getTerminalImeInputProps('command', 'android', b))
JSON.stringify(getTerminalImeInputProps('android', a)) ===
JSON.stringify(getTerminalImeInputProps('android', b))
const sameKey =
getTerminalImeRemountKey('command', 'android', a) ===
getTerminalImeRemountKey('command', 'android', b)
getTerminalImeRemountKey('android', a) === getTerminalImeRemountKey('android', b)
expect(sameKey, `${a} vs ${b}`).toBe(sameProps)
}
}
})
it('remounts the Android input when the user opts in to autocorrect', () => {
expect(getTerminalImeRemountKey('android', 'on')).not.toBe(
getTerminalImeRemountKey('android', 'unset')
)
})
// iOS updates inputType in place, so remounting would only cost focus and IME state.
it('keeps one stable key off Android so the input is never remounted', () => {
for (const platform of ['ios', 'web', 'windows', 'macos'] as const) {
const keys = PREFERENCES.map((preference) =>
getTerminalImeRemountKey('command', platform, preference)
)
const keys = PREFERENCES.map((preference) => getTerminalImeRemountKey(platform, preference))
expect(new Set(keys).size).toBe(1)
}
})
@@ -174,8 +128,7 @@ describe('getTerminalImeRemountKey', () => {
// here and be re-pinned. Known gap: swapping the argument for a different value of the same
// type would still pass — the unit tests above own correctness, these only own the hookup.
describe('session route IME wiring', () => {
// Element-scoped, not file-scoped: ~20 other mobile fields legitimately hardcode
// autoCorrect={false}, and this route has non-terminal inputs of its own.
// Element-scoped, not file-scoped: this route has non-terminal inputs of its own.
const liveInput = sliceBetween(
sessionRouteSource,
'ref={liveInputRef}',
@@ -187,32 +140,24 @@ describe('session route IME wiring', () => {
'onSubmitEditing={() => void handleSend()}'
)
// Guard the call sites: the pure module is only a fix if the route actually uses it.
it('routes both terminal inputs through the IME prop builder', () => {
expect(liveInput).toContain("getTerminalImeInputProps('live', Platform.OS, autocompletePref)")
expect(commandInput).toContain(
"getTerminalImeInputProps('command', Platform.OS, autocompletePref)"
)
// Guard the call site: the pure module is only a fix if the route actually uses it.
it('routes the command input through the IME prop builder', () => {
expect(commandInput).toContain('getTerminalImeInputProps(Platform.OS, autocompletePref)')
expect(commandInput).not.toMatch(/autoCorrect=\{/)
expect(commandInput).not.toMatch(/spellCheck=\{/)
})
// #6995: a literal false on either terminal input reintroduces NO_SUGGESTIONS on Android.
it('hardcodes neither autoCorrect nor spellCheck on either terminal input', () => {
for (const [name, element] of [
['live', liveInput],
['command', commandInput]
] as const) {
expect(element, `${name} input`).not.toMatch(/autoCorrect=\{(false|true)\}/)
expect(element, `${name} input`).not.toMatch(/spellCheck=\{(false|true)\}/)
}
// The settings copy promises direct input always sends raw keystrokes; the live input
// must stay hardcoded raw for that sentence to remain true.
it('keeps the live input hardcoded raw on every platform', () => {
expect(liveInput).toContain('autoCorrect={false}')
expect(liveInput).toContain('spellCheck={false}')
})
// Android caches inputType at mount, so the key must derive from the emitted props. A key
// computed from the boolean instead cannot tell {} from {autoCorrect:false}, and an explicit
// Off would silently never reach the IME.
// Android caches inputType at mount, so the key must derive from the emitted props — a
// separately-maintained boolean can drift from what the input actually received.
it('derives the command-input remount key from the emitted props, not a boolean', () => {
expect(commandInput).toContain(
"key={getTerminalImeRemountKey('command', Platform.OS, autocompletePref)}"
)
expect(commandInput).toContain('key={getTerminalImeRemountKey(Platform.OS, autocompletePref)}')
expect(commandInput).not.toMatch(/key=\{[^}]*commandAutocorrect/)
})
@@ -226,11 +171,13 @@ describe('session route IME wiring', () => {
// Both the initial value and the post-load value must go through the resolver,
// or the row renders a default the command bar does not actually use.
expect(autocompleteState).not.toContain('useState(false)')
expect(
autocompleteState.match(/isTerminalAutocorrectEnabled\('command', Platform\.OS,/g)?.length
).toBe(2)
expect(autocompleteState.match(/isTerminalAutocorrectEnabled\(Platform\.OS,/g)?.length).toBe(2)
// ...and so must the "On/Off by default" sentence in the group description.
const groupDescription = sliceBetween(terminalSettingsSource, 'KEYBOARD INPUT', 'by default')
expect(groupDescription).toContain("isTerminalAutocorrectEnabled('command', Platform.OS,")
const groupDescription = sliceBetween(
terminalSettingsSource,
'KEYBOARD INPUT',
'Direct keyboard input'
)
expect(groupDescription).toContain('isTerminalAutocorrectEnabled(Platform.OS,')
})
})
+11 -40
View File
@@ -1,14 +1,11 @@
export type TerminalImePlatform = 'android' | 'ios' | 'web' | 'windows' | 'macos'
/** `live` streams keystrokes straight to the PTY; `command` buffers a line until send. */
export type TerminalInputEntryMode = 'live' | 'command'
/** Tri-state: `unset` still needs a per-platform default, so it can't collapse to a boolean. */
export type TerminalAutocompletePreference = 'on' | 'off' | 'unset'
export type TerminalImeInputProps = {
readonly autoCorrect?: boolean
readonly spellCheck?: boolean
readonly autoCorrect: boolean
readonly spellCheck: boolean
}
export function parseTerminalAutocompletePreference(
@@ -24,61 +21,35 @@ export function parseTerminalAutocompletePreference(
}
export function isTerminalAutocorrectEnabled(
entryMode: TerminalInputEntryMode,
platform: TerminalImePlatform,
preference: TerminalAutocompletePreference
): boolean {
// Why: direct keystrokes reach the shell with no review step, so a correction there
// is unrecoverable; the settings copy promises direct input always stays raw.
if (entryMode === 'live') {
return false
}
if (preference !== 'unset') {
return preference === 'on'
}
// Why: iOS phones are used to type agent prompts into the buffered command bar, which
// is reviewed before send, so autocorrect helps there (#4606). Android keeps the old
// default because its suggestion strip already commits inline.
// Why: iOS phones type agent prompts into the buffered command bar, which is reviewed
// before send, so autocorrect helps there (#4606). Everywhere else keeps the old
// raw-keystrokes default.
return platform === 'ios'
}
export function getTerminalImeInputProps(
entryMode: TerminalInputEntryMode,
platform: TerminalImePlatform,
preference: TerminalAutocompletePreference
): TerminalImeInputProps {
const enabled = isTerminalAutocorrectEnabled(entryMode, platform, preference)
if (platform === 'android') {
// Why: React Native turns autoCorrect={false} into Android's NO_SUGGESTIONS inputType,
// and Samsung Keyboard answers that flag by switching IME composition off entirely —
// Hangul then arrives as raw jamo (#6995). Omitting the prop drops the flag without
// asking for autocorrect. spellCheck is iOS-only, so it stays off the Android path.
if (enabled) {
return { autoCorrect: true }
}
// Why: an Off switch that emitted the same props as untouched would be a control that
// does nothing, leaving no way back to suppression. Honour it with NO_SUGGESTIONS even
// though that costs IME composition — the user asked, and it is reversible. Only the
// command bar, because the toggle never claimed to govern direct entry.
if (preference === 'off' && entryMode === 'command') {
return { autoCorrect: false }
}
return {}
}
const enabled = isTerminalAutocorrectEnabled(platform, preference)
return { autoCorrect: enabled, spellCheck: enabled }
}
// Why: Android caches IME inputType at mount, so the remount key must change whenever the
// emitted props change — and on Android that is a three-way split ({} vs true vs false), not
// the boolean the key used to track. Keying off the props themselves keeps the two in step.
// Why: Android caches IME inputType at mount, so the remount key must change exactly when
// the emitted props change. Deriving it from the props themselves keeps the two in step.
export function getTerminalImeRemountKey(
entryMode: TerminalInputEntryMode,
platform: TerminalImePlatform,
preference: TerminalAutocompletePreference
): string {
if (platform !== 'android') {
return `${entryMode}-input`
return 'command-input'
}
const { autoCorrect } = getTerminalImeInputProps(entryMode, platform, preference)
return `${entryMode}-input-ac-${autoCorrect === undefined ? 'omitted' : String(autoCorrect)}`
const { autoCorrect } = getTerminalImeInputProps(platform, preference)
return `command-input-ac-${String(autoCorrect)}`
}