feat(mobile): add terminal keyboard autocomplete toggle (#5348)

* release: v1.4.48-rc.0 [rc-slot:2026-06-05-15]

* release: v0.0.1-rc.0 [rc-slot:2026-06-06-03]

* feat(mobile): add terminal keyboard autocomplete toggle

Terminal command inputs disable autocorrect/suggestions and use a
password-class keyboard so the OS never rewrites commands, flags, or
paths. Some users want phone-style typing in the command bar.

Add a Settings → Terminal "Autocomplete & autocorrect" toggle
(persisted locally, default off). When on, the command bar enables
autoCorrect/spellCheck and uses the default keyboard so Android shows
its suggestion strip; iOS drops the ascii-capable restriction. The
direct keyboard-capture input is unchanged — it streams raw keystrokes
where suggestions cannot apply.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mobile): remount command input when autocomplete toggles

Android caches the IME inputType at mount, so flipping the toggle did
not switch suggestions on until the field remounted.

* fix(mobile): guard autocomplete pref loads against unmount and fast toggle

Address review feedback: the settings toggle's initial load could clobber
a fast user toggle, and the session focus-effect could setState after the
screen unfocused. Both now use a guard flag.

* test mobile terminal autocomplete preference

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
gsxdsm 2026-06-16 04:07:07 +08:00 committed by GitHub
parent 5bf24368c0
commit d17c22c826
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 159 additions and 5 deletions

View File

@ -45,6 +45,7 @@ import {
} from 'lucide-react-native'
import type { RpcClient } from '../../../../src/transport/rpc-client'
import { loadHosts } from '../../../../src/transport/host-store'
import { loadTerminalAutocompleteEnabled } from '../../../../src/storage/preferences'
import {
useHostClient,
useForceReconnect,
@ -738,6 +739,9 @@ export default function SessionScreen() {
const sessionTabsRef = useRef<MobileSessionTab[]>([])
const [terminalsLoaded, setTerminalsLoaded] = useState(false)
const [input, setInput] = useState('')
// Why: local opt-in for keyboard autocomplete/autocorrect on the terminal
// command bar; reloaded on focus so a Settings → Terminal toggle takes effect on return.
const [autocompleteEnabled, setAutocompleteEnabled] = useState(false)
const [liveInputCapture, setLiveInputCapture] = useState('')
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState<Set<string>>(
() => new Set()
@ -2302,6 +2306,21 @@ export default function SessionScreen() {
}, [connState, fetchSessionTabs, fetchTerminals])
)
// Why: pick up the Settings → Terminal autocomplete toggle when returning here.
useFocusEffect(
useCallback(() => {
let active = true
void loadTerminalAutocompleteEnabled().then((enabled) => {
if (active) {
setAutocompleteEnabled(enabled)
}
})
return () => {
active = false
}
}, [])
)
// Why: unsubscribe the old terminal so the server restores its desktop dims
// (clearing the phone-fit banner), then subscribe the new terminal with the
// measured viewport so the server phone-fits it. Also call terminal.focus
@ -4196,6 +4215,15 @@ export default function SessionScreen() {
) : (
<View style={styles.inputBar}>
<TextInput
// Why: Android caches the IME inputType at mount, so toggling
// autocomplete must remount there; iOS can update without a focus-costly remount.
key={
Platform.OS === 'android'
? autocompleteEnabled
? 'cmd-input-ac-on'
: 'cmd-input-ac-off'
: 'cmd-input'
}
style={styles.textInput}
value={input}
onChangeText={(text) =>
@ -4204,10 +4232,18 @@ export default function SessionScreen() {
placeholder="Type a command…"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
autoCorrect={autocompleteEnabled}
spellCheck={autocompleteEnabled}
smartInsertDelete={false}
keyboardType={Platform.OS === 'ios' ? 'ascii-capable' : 'visible-password'}
// Why: the default keyboard exposes autocomplete/autocorrect;
// ascii-capable (iOS) / visible-password (Android) suppress it.
keyboardType={
autocompleteEnabled
? 'default'
: Platform.OS === 'ios'
? 'ascii-capable'
: 'visible-password'
}
returnKeyType="send"
editable={canSend}
onSubmitEditing={() => void handleSend()}

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { View, Text, StyleSheet, Pressable } from 'react-native'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { View, Text, StyleSheet, Pressable, Switch } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import Animated, {
@ -17,6 +17,10 @@ import type { RpcClient } from '../src/transport/rpc-client'
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state'
import {
loadTerminalAutocompleteEnabled,
saveTerminalAutocompleteEnabled
} from '../src/storage/preferences'
type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
@ -114,6 +118,27 @@ export default function TerminalSettingsScreen() {
const [hostMs, setHostMs] = useState<Record<string, number | null | undefined>>({})
const [pickerHostId, setPickerHostId] = useState<string | null>(null)
const [autocompleteEnabled, setAutocompleteEnabled] = useState(false)
// 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.
const userToggledAutocompleteRef = useRef(false)
useEffect(() => {
let stale = false
void loadTerminalAutocompleteEnabled().then((enabled) => {
if (!stale && !userToggledAutocompleteRef.current) {
setAutocompleteEnabled(enabled)
}
})
return () => {
stale = true
}
}, [])
const toggleAutocomplete = useCallback((next: boolean) => {
userToggledAutocompleteRef.current = true
setAutocompleteEnabled(next)
void saveTerminalAutocompleteEnabled(next)
}, [])
useEffect(() => {
let cancelled = false
for (const host of hosts) {
@ -243,6 +268,28 @@ export default function TerminalSettingsScreen() {
</View>
)}
<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. Off by default so the keyboard never rewrites commands, flags, or paths.
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}>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Autocomplete &amp; autocorrect</Text>
<Text style={styles.rowSublabel}>{autocompleteEnabled ? 'On' : 'Off'}</Text>
</View>
<Switch
value={autocompleteEnabled}
onValueChange={toggleAutocomplete}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
</View>
<TerminalShortcutSettings
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
@ -318,6 +365,9 @@ const styles = StyleSheet.create({
sectionTopGap: {
marginTop: spacing.sm
},
inputGroupGap: {
marginTop: spacing.xl
},
emptyText: {
fontSize: typography.bodySize,
color: colors.textSecondary,

View File

@ -0,0 +1,50 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { loadTerminalAutocompleteEnabled, saveTerminalAutocompleteEnabled } from './preferences'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(),
setItem: vi.fn()
}
}))
describe('terminal autocomplete preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset()
})
it('defaults to disabled when unset', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
await expect(loadTerminalAutocompleteEnabled()).resolves.toBe(false)
expect(AsyncStorage.getItem).toHaveBeenCalledWith('orca:terminalAutocompleteEnabled')
})
it('loads enabled only from the persisted true value', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue('true')
await expect(loadTerminalAutocompleteEnabled()).resolves.toBe(true)
vi.mocked(AsyncStorage.getItem).mockResolvedValue('false')
await expect(loadTerminalAutocompleteEnabled()).resolves.toBe(false)
})
it('falls back to disabled when storage cannot be read', async () => {
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
await expect(loadTerminalAutocompleteEnabled()).resolves.toBe(false)
})
it('persists the selected value', async () => {
await saveTerminalAutocompleteEnabled(true)
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:terminalAutocompleteEnabled', 'true')
await saveTerminalAutocompleteEnabled(false)
expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:terminalAutocompleteEnabled', 'false')
})
})

View File

@ -25,6 +25,24 @@ export async function savePushNotificationsEnabled(enabled: boolean): Promise<vo
await AsyncStorage.setItem(NOTIF_KEY, String(enabled))
}
const AUTOCOMPLETE_KEY = 'orca:terminalAutocompleteEnabled'
// Why: terminal command inputs default to autocorrect/suggestions OFF so the
// keyboard never mangles commands, flags, or paths. Users who want phone-style
// typing opt in via Settings → Terminal; the choice persists locally per device.
export async function loadTerminalAutocompleteEnabled(): Promise<boolean> {
try {
const raw = await AsyncStorage.getItem(AUTOCOMPLETE_KEY)
return raw === 'true'
} catch {
return false
}
}
export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise<void> {
await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled))
}
export type HostPreferences = {
sortMode: string
filterMode: string