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:
parent
5bf24368c0
commit
d17c22c826
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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't apply there.
|
||||
</Text>
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>Autocomplete & 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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue