diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 53cad8369..51e075274 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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([]) 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>( () => 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() { ) : ( @@ -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()} diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index 7f8ed322b..eb78e52db 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -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>({}) const [pickerHostId, setPickerHostId] = useState(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() { )} + KEYBOARD INPUT + + 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't apply there. + + + + + Autocomplete & autocorrect + {autocompleteEnabled ? 'On' : 'Off'} + + + + + ({ + 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') + }) +}) diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts index 4bb837b30..6b85bbd26 100644 --- a/mobile/src/storage/preferences.ts +++ b/mobile/src/storage/preferences.ts @@ -25,6 +25,24 @@ export async function savePushNotificationsEnabled(enabled: boolean): Promise { + try { + const raw = await AsyncStorage.getItem(AUTOCOMPLETE_KEY) + return raw === 'true' + } catch { + return false + } +} + +export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise { + await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled)) +} + export type HostPreferences = { sortMode: string filterMode: string