diff --git a/mobile/app/about.tsx b/mobile/app/about.tsx
index 70a1effa44b..7328e6d9b8d 100644
--- a/mobile/app/about.tsx
+++ b/mobile/app/about.tsx
@@ -1,11 +1,7 @@
-import { View, Text, StyleSheet, Pressable, Linking, Platform } from 'react-native'
-import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { Linking, Platform } from 'react-native'
import { useRouter } from 'expo-router'
-import { ChevronLeft, Globe } from 'lucide-react-native'
-import Svg, { Path } from 'react-native-svg'
import Constants from 'expo-constants'
-import { OrcaLogo } from '../src/components/OrcaLogo'
-import { colors, spacing, typography } from '../src/theme/mobile-theme'
+import AboutScreen from '../src/settings/about-screen'
// Why: read version + native build identifier from expo-constants at
// runtime so the About screen never drifts out of sync with app.json.
@@ -20,148 +16,13 @@ function getVersionLabel(): string {
return build ? `v${version} (${build})` : `v${version}`
}
-function GithubIcon({ size = 16, color = colors.textSecondary }) {
- return (
-
- )
-}
-
-function XIcon({ size = 16, color = colors.textSecondary }) {
- return (
-
- )
-}
-
-export default function AboutScreen() {
+export default function NativeAboutRoute() {
const router = useRouter()
- const insets = useSafeAreaInsets()
-
return (
-
-
- router.back()}>
-
-
- About
-
-
-
-
- Orca
- Open-source agent IDE for 100x builders
-
-
-
- [styles.row, pressed && styles.rowPressed]}
- onPress={() => void Linking.openURL('https://onOrca.dev')}
- >
-
- onOrca.dev
-
-
- [styles.row, pressed && styles.rowPressed]}
- onPress={() => void Linking.openURL('https://github.com/stablyai/orca')}
- >
-
- stablyai/orca
-
-
- [styles.row, pressed && styles.rowPressed]}
- onPress={() => void Linking.openURL('https://x.com/orca_build')}
- >
-
- @orca_build
-
-
-
- {getVersionLabel()}
-
+ router.back()}
+ openExternal={(url) => Linking.openURL(url)}
+ versionLabel={getVersionLabel()}
+ />
)
}
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- backgroundColor: colors.bgBase,
- padding: spacing.lg
- },
- topRow: {
- flexDirection: 'row',
- alignItems: 'center',
- marginBottom: spacing.xl
- },
- backButton: {
- width: 36,
- height: 36,
- borderRadius: 18,
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: spacing.sm
- },
- heading: {
- fontSize: 20,
- fontWeight: '700',
- color: colors.textPrimary
- },
- brand: {
- alignItems: 'center',
- paddingVertical: spacing.xl,
- marginBottom: spacing.lg
- },
- brandName: {
- fontSize: 22,
- fontWeight: '800',
- color: colors.textPrimary,
- marginTop: spacing.sm
- },
- brandSub: {
- fontSize: 13,
- color: colors.textMuted,
- marginTop: spacing.xs
- },
- section: {
- backgroundColor: colors.bgPanel,
- borderRadius: 12,
- overflow: 'hidden'
- },
- row: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm + 2,
- paddingVertical: spacing.md,
- paddingHorizontal: spacing.md + 2
- },
- rowPressed: {
- backgroundColor: colors.bgRaised
- },
- rowLabel: {
- flex: 1,
- fontSize: typography.bodySize,
- fontWeight: '500',
- color: colors.textPrimary
- },
- rowValue: {
- flex: 1,
- textAlign: 'right',
- fontSize: typography.bodySize,
- color: colors.textSecondary
- },
- separator: {
- height: StyleSheet.hairlineWidth,
- backgroundColor: colors.borderSubtle,
- marginHorizontal: spacing.md
- },
- versionText: {
- marginTop: spacing.lg,
- textAlign: 'center',
- fontSize: typography.metaSize,
- color: colors.textMuted
- }
-})
diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx
index b4f3abe7ed8..3281afe7baf 100644
--- a/mobile/app/terminal-settings.tsx
+++ b/mobile/app/terminal-settings.tsx
@@ -1,388 +1,43 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { View, Text, Pressable, Switch } from 'react-native'
-import { useSafeAreaInsets } from 'react-native-safe-area-context'
-import { GestureHandlerRootView } from 'react-native-gesture-handler'
-import Animated, {
- useAnimatedRef,
- useAnimatedScrollHandler,
- useSharedValue
-} from 'react-native-reanimated'
+import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'expo-router'
-import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native'
-import { colors, spacing } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections'
-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 { terminalSettingsScreenStyles as styles } from '../src/terminal/terminal-settings-screen-styles'
-import {
- loadTerminalAutocompleteEnabled,
- loadTerminalTextScale,
- saveTerminalAutocompleteEnabled,
- saveTerminalTextScale
-} from '../src/storage/preferences'
+import { nativeTerminalSettingsOperations } from '../src/terminal/terminal-settings-operations'
+import { nativeTerminalSettingsHost } from '../src/terminal/native-terminal-settings-host'
+import TerminalSettingsScreen from '../src/terminal/terminal-settings-screen'
-type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
-
-type TextSizeValue = 'smallest' | 'smaller' | 'default' | 'large' | 'larger' | 'largest'
-
-// scale = baseline zoom the terminal WebView applies on top of fit-to-width.
-// Keep in sync with TERMINAL_TEXT_SCALES; pinch-to-zoom snaps to these values.
-const TEXT_SIZE_OPTIONS: (PickerOption & { scale: number })[] = [
- { value: 'smallest', label: 'Smallest (50%)', scale: 0.5 },
- { value: 'smaller', label: 'Smaller (75%)', scale: 0.75 },
- { value: 'default', label: 'Default (100%)', scale: 1 },
- { value: 'large', label: 'Large (125%)', scale: 1.25 },
- { value: 'larger', label: 'Larger (150%)', scale: 1.5 },
- { value: 'largest', label: 'Largest (200%)', scale: 2 }
-]
-
-function textSizeValueFromScale(scale: number): TextSizeValue {
- return TEXT_SIZE_OPTIONS.find((o) => o.scale === scale)?.value ?? 'default'
-}
-
-function textSizeSummary(scale: number): string {
- return (TEXT_SIZE_OPTIONS.find((o) => o.scale === scale) ?? TEXT_SIZE_OPTIONS[0]!).label
-}
-
-const AUTO_RESTORE_FIT_OPTIONS: (PickerOption & { ms: number | null })[] = [
- { value: 'indefinite', label: 'Keep at phone size (default)', ms: null },
- { value: '60s', label: 'After 1 minute', ms: 60_000 },
- { value: '5m', label: 'After 5 minutes', ms: 5 * 60_000 },
- { value: '30m', label: 'After 30 minutes', ms: 30 * 60_000 }
-]
-
-function valueFromMs(ms: number | null | undefined): RestoreValue {
- if (ms == null) {
- return 'indefinite'
- }
- const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms)
- if (exact) {
- return exact.value
- }
- // Why: server may return a non-preset ms (custom value, future preset,
- // or server-side clamp). Snap to the closest finite preset so the
- // picker's selected radio agrees with the row sublabel rendered by
- // autoRestoreSummary ("After Xs").
- let closest: (typeof AUTO_RESTORE_FIT_OPTIONS)[number] | null = null
- let bestDelta = Infinity
- for (const opt of AUTO_RESTORE_FIT_OPTIONS) {
- if (opt.ms == null) {
- continue
- }
- const delta = Math.abs(opt.ms - ms)
- if (delta < bestDelta) {
- bestDelta = delta
- closest = opt
- }
- }
- return closest ? closest.value : 'indefinite'
-}
-
-function autoRestoreSummary(ms: number | null | undefined): string {
- if (ms === undefined) {
- return '…'
- }
- if (ms === null) {
- return AUTO_RESTORE_FIT_OPTIONS[0]!.label
- }
- const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms)
- return exact ? exact.label : `After ${Math.round(ms / 1000)}s`
-}
-
-function HostFitRow({
- client,
- hostName,
- ms,
- onPress
-}: {
- client: RpcClient | null
- hostName: string
- ms: number | null | undefined
- onPress: () => void
-}): React.JSX.Element {
- return (
- [styles.row, pressed && styles.rowPressed]}
- onPress={onPress}
- disabled={!client}
- >
-
-
- {hostName}
- {autoRestoreSummary(ms)}
-
-
-
- )
-}
-
-export default function TerminalSettingsScreen() {
+export default function NativeTerminalSettingsRoute() {
const router = useRouter()
- const insets = useSafeAreaInsets()
const [hosts, setHosts] = useState([])
useEffect(() => {
- void loadHosts().then(setHosts)
- }, [])
- const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
- const { clients: hostClients } = useFocusedSettingsHostClients(hostIds)
- const hostClientsById = useMemo(
- () => new Map(hostClients.map((entry) => [entry.hostId, entry.client])),
- [hostClients]
- )
-
- // Why: per-host current value, lazily fetched. We keep state at the
- // screen level rather than per-row so the picker can render at root
- // level — embedding PickerModal inside a row clipped its BottomDrawer
- // absoluteFill backdrop to the ScrollView content frame and made the
- // drawer appear cut-off.
- const [hostMs, setHostMs] = useState>({})
- const [pickerHostId, setPickerHostId] = useState(null)
-
- const [textScale, setTextScale] = useState(1)
- const [textSizePickerOpen, setTextSizePickerOpen] = useState(false)
- useEffect(() => {
- void loadTerminalTextScale().then(setTextScale)
- }, [])
- const selectTextSize = useCallback((value: TextSizeValue) => {
- const opt = TEXT_SIZE_OPTIONS.find((o) => o.value === value)
- if (!opt) {
- return
- }
- setTextScale(opt.scale)
- void saveTerminalTextScale(opt.scale)
- }, [])
-
- 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)
+ let active = true
+ void loadHosts().then((next) => {
+ if (active) {
+ setHosts(next)
}
})
return () => {
- stale = true
+ active = false
}
}, [])
- const toggleAutocomplete = useCallback((next: boolean) => {
- userToggledAutocompleteRef.current = true
- setAutocompleteEnabled(next)
- void saveTerminalAutocompleteEnabled(next)
- }, [])
-
- useEffect(() => {
- let cancelled = false
- for (const host of hosts) {
- const client = hostClientsById.get(host.id) ?? null
- if (!client) {
- continue
- }
- void client
- .sendRequest('terminal.getAutoRestoreFit')
- .then((resp) => {
- if (cancelled) {
- return
- }
- const value = (resp as { ms?: number | null } | null)?.ms
- // Why: reconnect/status ticks can replay the same value; preserving
- // object identity avoids rerendering every settings row again.
- setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, host.id, value))
- })
- .catch(() => {
- if (!cancelled) {
- setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, host.id, null))
- }
- })
- }
- return () => {
- cancelled = true
- }
- }, [hosts, hostClientsById])
-
- async function selectValue(hostId: string, value: RestoreValue) {
- const client = hostClientsById.get(hostId) ?? null
- if (!client) {
- return
- }
- const opt = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.value === value)
- if (!opt) {
- return
- }
- setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, opt.ms))
- try {
- const resp = (await client.sendRequest('terminal.setAutoRestoreFit', {
- ms: opt.ms
- })) as { ms?: number | null } | null
- setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, resp?.ms))
- } catch {
- try {
- const resp = (await client.sendRequest('terminal.getAutoRestoreFit')) as {
- ms?: number | null
- } | null
- setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, resp?.ms))
- } catch {
- // give up silently — the next mount retries
- }
- }
- }
-
- const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null
-
- const scrollRef = useAnimatedRef()
- const scrollOffsetY = useSharedValue(0)
- const scrollContentHeight = useSharedValue(0)
- const scrollHandler = useAnimatedScrollHandler((event) => {
- scrollOffsetY.value = event.contentOffset.y
- })
- // Why: imperative toggle instead of state — a re-render while a drag gesture
- // is active would rebuild the row gestures and could cancel the drag.
- const setScrollEnabled = useCallback(
- (enabled: boolean) => {
- scrollRef.current?.setNativeProps({ scrollEnabled: enabled })
- },
- [scrollRef]
+ const hostIds = useMemo(() => hosts.map((host) => host.id), [hosts])
+ const { clients } = useFocusedSettingsHostClients(hostIds)
+ const settingsHosts = useMemo(
+ () =>
+ hosts.map((host) =>
+ nativeTerminalSettingsHost(
+ host,
+ clients.find((entry) => entry.hostId === host.id)?.client ?? null
+ )
+ ),
+ [hosts, clients]
)
- const handleDragActiveChange = useCallback(
- (active: boolean) => setScrollEnabled(!active),
- [setScrollEnabled]
- )
-
return (
-
-
- router.back()}
- >
-
-
- Terminal
-
-
- {
- scrollContentHeight.value = height
- }}
- >
- WHEN YOU LEAVE THE APP
-
- While you're using a terminal on your phone, Orca shrinks it to fit your screen. When
- you close the app or switch away, this controls whether it stays at phone size (so
- interactive CLI tools don't reflow) or resizes back to your desktop. You can always
- use Restore this terminal or Restore all terminals on the banner to resize manually.
-
-
- {hosts.length === 0 ? (
-
-
- No paired desktops yet. Pair one to control terminal behavior.
-
-
- ) : (
-
- {hosts.map((host, idx) => {
- const client = hostClientsById.get(host.id) ?? null
- return (
-
- {idx > 0 && }
- setPickerHostId(host.id)}
- />
-
- )
- })}
-
- )}
-
- TEXT SIZE
-
- Scale the terminal text. Smaller sizes fit more columns with side margins; larger sizes
- show fewer columns — drag sideways to pan. You can also pinch to zoom in the terminal
- itself, which updates this setting. Per-device display only; doesn't change the
- desktop terminal.
-
-
- [styles.row, pressed && styles.rowPressed]}
- onPress={() => setTextSizePickerOpen(true)}
- >
-
-
- Text size
- {textSizeSummary(textScale)}
-
-
-
-
-
- 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'}
-
-
-
-
-
-
-
-
-
- visible={pickerHost != null}
- title={pickerHost ? `Restore ${pickerHost.name}` : ''}
- options={AUTO_RESTORE_FIT_OPTIONS}
- selected={valueFromMs(pickerHost ? hostMs[pickerHost.id] : null)}
- onSelect={(v) => {
- if (pickerHost) {
- void selectValue(pickerHost.id, v)
- }
- }}
- onClose={() => setPickerHostId(null)}
- />
-
-
- visible={textSizePickerOpen}
- title="Terminal text size"
- options={TEXT_SIZE_OPTIONS}
- selected={textSizeValueFromScale(textScale)}
- onSelect={selectTextSize}
- onClose={() => setTextSizePickerOpen(false)}
- />
-
+ router.back()}
+ />
)
}
diff --git a/mobile/host-web-app/about.tsx b/mobile/host-web-app/about.tsx
new file mode 100644
index 00000000000..9044f57937f
--- /dev/null
+++ b/mobile/host-web-app/about.tsx
@@ -0,0 +1,31 @@
+import { useRouter } from 'expo-router'
+import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel'
+import AboutScreen from '../src/settings/about-screen'
+
+export default function HostedAboutRoute() {
+ const router = useRouter()
+ const shell = useMobileWebNativeShell()
+ return (
+ {
+ if (router.canGoBack()) {
+ router.back()
+ } else {
+ router.replace('/settings')
+ }
+ }}
+ linksAvailable={shell.client?.native.supports('openExternal') ?? false}
+ openExternal={async (url) => {
+ if (!shell.client) {
+ throw new Error('Native shell channel unavailable')
+ }
+ await shell.client.native.openExternal(url)
+ }}
+ versionLabel={
+ shell.context
+ ? `Interface build ${shell.context.buildId.slice(0, 12)}`
+ : 'Loading interface build…'
+ }
+ />
+ )
+}
diff --git a/mobile/host-web-app/h/[hostId]/session/[worktreeId].tsx b/mobile/host-web-app/h/[hostId]/session/[worktreeId].tsx
index 7f5d40a7382..2df9052100d 100644
--- a/mobile/host-web-app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/host-web-app/h/[hostId]/session/[worktreeId].tsx
@@ -1,3 +1,4 @@
+import { useRouter } from 'expo-router'
import { useEffect, useMemo } from 'react'
import { SessionScreen } from '../../../../app/h/[hostId]/session/[worktreeId]'
import { useMobileWebNativeShell } from '../../../../../src/mobile-web/src/native-shell-channel'
@@ -16,6 +17,7 @@ import { webHostSessionChatPendingDeliveryOperations } from '../../../../src/ses
import { useMobileWebRouteParams } from '../../../../src/mobile-web/use-mobile-web-route-params'
export default function HostMobileWebSessionRoute() {
+ const router = useRouter()
const shell = useMobileWebNativeShell()
const { worktreeId, name } = useMobileWebRouteParams<{
worktreeId: string
@@ -55,8 +57,11 @@ export default function HostMobileWebSessionRoute() {
[shell.client]
)
const sessionDeviceOperations = useMemo(
- () => (shell.client ? webHostSessionDeviceOperations(shell.client) : undefined),
- [shell.client]
+ () =>
+ shell.client
+ ? webHostSessionDeviceOperations(shell.client, (target) => router.push(target))
+ : undefined,
+ [shell.client, router]
)
const sessionBrowserOperations = useMemo(
() => (shell.client ? webHostSessionBrowserOperations(shell.client) : undefined),
diff --git a/mobile/host-web-app/settings.tsx b/mobile/host-web-app/settings.tsx
index 4f36c7cf028..df601c8470a 100644
--- a/mobile/host-web-app/settings.tsx
+++ b/mobile/host-web-app/settings.tsx
@@ -1,12 +1,23 @@
import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel'
+import { useState } from 'react'
+import { Text } from 'react-native'
+import { colors, typography, spacing } from '../src/theme/mobile-theme'
import { useRouter } from 'expo-router'
-import { Globe, MessageSquare } from 'lucide-react-native'
+import { Globe, MessageSquare, Terminal, Info, Shield, LifeBuoy } from 'lucide-react-native'
import { MobileSettingsFrame, MobileSettingsSection } from '../src/settings/mobile-settings-menu'
export default function HostedSettingsRoute() {
const router = useRouter()
+ const [linkError, setLinkError] = useState(null)
const shell = useMobileWebNativeShell()
const disabled = !(shell.client?.native.supports('pagePreferences') ?? false)
+ const linksDisabled = !(shell.client?.native.supports('openExternal') ?? false)
+ const openExternal = (url: string) => {
+ setLinkError(null)
+ void shell.client?.native
+ .openExternal(url)
+ .catch(() => setLinkError('Could not open the link. Try again.'))
+ }
return (
{
@@ -19,6 +30,20 @@ export default function HostedSettingsRoute() {
>
{
+ if (shell.client?.native.supports('pagePreferences')) {
+ router.push('/terminal-settings')
+ } else {
+ void shell.client
+ ?.navigationRoute({ destination: 'terminalSettings' })
+ .catch(() => {})
+ }
+ }
+ },
{
label: 'Chat UI',
disabled,
@@ -30,9 +55,41 @@ export default function HostedSettingsRoute() {
disabled,
icon: Globe,
onPress: () => router.push('/browser-settings')
+ },
+ { label: 'About', icon: Info, onPress: () => router.push('/about') }
+ ]}
+ />
+ openExternal('https://www.onorca.dev/privacy')
+ },
+ {
+ label: 'Support',
+ icon: LifeBuoy,
+ external: true,
+ disabled: linksDisabled,
+ onPress: () => openExternal('https://github.com/stablyai/orca/issues')
}
]}
/>
+ {linkError && (
+
+ {linkError}
+
+ )}
)
}
diff --git a/mobile/host-web-app/terminal-settings.tsx b/mobile/host-web-app/terminal-settings.tsx
new file mode 100644
index 00000000000..60e6a325e40
--- /dev/null
+++ b/mobile/host-web-app/terminal-settings.tsx
@@ -0,0 +1,91 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useRouter } from 'expo-router'
+import { Pressable, Text } from 'react-native'
+import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel'
+import { MobileSettingsFrame } from '../src/settings/mobile-settings-menu'
+import TerminalSettingsScreen from '../src/terminal/terminal-settings-screen'
+import {
+ webTerminalSettingsHost,
+ webTerminalSettingsOperations
+} from '../src/terminal/web-terminal-settings-operations'
+import type { TerminalSettingsHost } from '../src/terminal/terminal-settings-operations'
+import { terminalSettingsScreenStyles as styles } from '../src/terminal/terminal-settings-screen-styles'
+
+export default function HostedTerminalSettingsRoute() {
+ const shell = useMobileWebNativeShell()
+ return
+}
+function HostedTerminalSettings() {
+ const router = useRouter()
+ const shell = useMobileWebNativeShell()
+ const client = shell.client
+ const operations = useMemo(
+ () => (client ? webTerminalSettingsOperations(client) : null),
+ [client]
+ )
+ const [hosts, setHosts] = useState([])
+ const [loadingHost, setLoadingHost] = useState(true)
+ const [hostLoadFailed, setHostLoadFailed] = useState(false)
+ useEffect(() => {
+ let active = true
+ if (client) {
+ void webTerminalSettingsHost(client)
+ .then((host) => {
+ if (active) {
+ setHosts(host ? [host] : [])
+ }
+ })
+ .catch(() => {
+ if (active) {
+ setHostLoadFailed(true)
+ }
+ })
+ .finally(() => {
+ if (active) {
+ setLoadingHost(false)
+ }
+ })
+ }
+ return () => {
+ active = false
+ }
+ }, [client])
+ const onBack = () => {
+ if (router.canGoBack()) {
+ router.back()
+ } else {
+ router.replace('/settings')
+ }
+ }
+ if (!client?.native.supports('pagePreferences') || !operations) {
+ return (
+
+ {
+ void client?.navigationRoute({ destination: 'terminalSettings' }).catch(() => {})
+ }}
+ >
+ Open device terminal settings
+
+
+ )
+ }
+ return (
+
+ )
+}
diff --git a/mobile/src/components/TerminalShortcutSettings.tsx b/mobile/src/components/TerminalShortcutSettings.tsx
index 18d882aa3fb..39b29712e67 100644
--- a/mobile/src/components/TerminalShortcutSettings.tsx
+++ b/mobile/src/components/TerminalShortcutSettings.tsx
@@ -1,32 +1,17 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import {
- AppState,
- View,
- Text,
- StyleSheet,
- Pressable,
- Switch,
- type AppStateStatus
-} from 'react-native'
-import { useFocusEffect } from 'expo-router'
+import { View, Text, Pressable, Switch, Platform } from 'react-native'
import { ChevronRight, X } from 'lucide-react-native'
import type Animated from 'react-native-reanimated'
import type { AnimatedRef, SharedValue } from 'react-native-reanimated'
-import { CustomKeyModal, loadCustomKeys, saveCustomKeys, type CustomKey } from './CustomKeyModal'
+import { CustomKeyModal } from './CustomKeyModal'
+import { useTerminalShortcutSettings } from './use-terminal-shortcut-settings'
import { DragReorderList } from './DragReorderList'
-import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+import { colors } from '../theme/mobile-theme'
+import { terminalShortcutSettingsStyles as styles } from './terminal-shortcut-settings-styles'
import {
- TERMINAL_ACCESSORY_KEYS,
- type TerminalAccessoryKey
-} from '../terminal/terminal-accessory-keys'
-import {
- getDefaultTerminalAccessoryLayout,
- loadTerminalAccessoryLayout,
- reorderTerminalAccessoryBuiltInIds,
- saveTerminalAccessoryLayout,
- setTerminalAccessoryBuiltInVisible,
- type TerminalAccessoryLayout
-} from '../terminal/terminal-accessory-layout'
+ nativeTerminalSettingsOperations,
+ type TerminalShortcutPreferences
+} from '../terminal/terminal-settings-operations'
+import type { TerminalAccessoryKey } from '../terminal/terminal-accessory-keys'
// Why: DragReorderList absolutely positions rows, so every row in a
// reorderable section must share one fixed height.
@@ -35,11 +20,13 @@ const REORDER_ROW_HEIGHT = 56
function ShortcutBarRow({
shortcutKey,
visible,
- onToggle
+ onToggle,
+ disabled
}: {
shortcutKey: TerminalAccessoryKey
visible: boolean
onToggle: (visible: boolean) => void
+ disabled: boolean
}): React.JSX.Element {
return (
@@ -50,10 +37,14 @@ function ShortcutBarRow({
{shortcutKey.accessibilityLabel ?? shortcutKey.label}
)
@@ -64,258 +55,145 @@ type Props = {
scrollOffsetY: SharedValue
scrollContentHeight: SharedValue
onDragActiveChange: (active: boolean) => void
+ preferences?: TerminalShortcutPreferences
}
export function TerminalShortcutSettings({
scrollRef,
scrollOffsetY,
scrollContentHeight,
- onDragActiveChange
+ onDragActiveChange,
+ preferences = nativeTerminalSettingsOperations
}: Props): React.JSX.Element {
- const [customKeys, setCustomKeys] = useState([])
- const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
- const [shortcutLayout, setShortcutLayout] = useState(
- getDefaultTerminalAccessoryLayout
- )
- const layoutWriteChainRef = useRef>(Promise.resolve())
- const layoutWriteSeqRef = useRef(0)
- const pendingLayoutWritesRef = useRef(0)
-
- const persistLayout = useCallback((next: TerminalAccessoryLayout) => {
- layoutWriteSeqRef.current += 1
- pendingLayoutWritesRef.current += 1
- layoutWriteChainRef.current = layoutWriteChainRef.current
- .catch(() => {})
- .then(() => saveTerminalAccessoryLayout(next))
- .catch(() => {})
- .finally(() => {
- pendingLayoutWritesRef.current -= 1
- })
- }, [])
-
- const refreshShortcutLayout = useCallback(() => {
- const refreshSeq = layoutWriteSeqRef.current
- void loadTerminalAccessoryLayout().then((layout) => {
- if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) {
- return
- }
- setShortcutLayout({
- orderedBuiltInIds: layout.orderedBuiltInIds,
- visibleBuiltInIds: layout.visibleBuiltInIds
- })
- })
- }, [])
-
- const customKeysWriteChainRef = useRef>(Promise.resolve())
- const customKeysWriteSeqRef = useRef(0)
- const pendingCustomKeysWritesRef = useRef(0)
-
- // Why: same stale-snapshot guard as persistLayout — a focus/AppState refresh
- // racing an in-flight save must not overwrite the optimistic state.
- const persistCustomKeys = useCallback((next: CustomKey[]) => {
- customKeysWriteSeqRef.current += 1
- pendingCustomKeysWritesRef.current += 1
- customKeysWriteChainRef.current = customKeysWriteChainRef.current
- .catch(() => {})
- .then(() => saveCustomKeys(next))
- .catch(() => {})
- .finally(() => {
- pendingCustomKeysWritesRef.current -= 1
- })
- }, [])
-
- const refreshCustomKeys = useCallback(() => {
- const refreshSeq = customKeysWriteSeqRef.current
- void loadCustomKeys().then((keys) => {
- if (pendingCustomKeysWritesRef.current > 0 || refreshSeq !== customKeysWriteSeqRef.current) {
- return
- }
- setCustomKeys(keys)
- })
- }, [])
-
- const handleDeleteCustomKey = useCallback(
- (key: CustomKey) => {
- setCustomKeys((current) => {
- const updated = current.filter((k) => k.id !== key.id)
- persistCustomKeys(updated)
- return updated
- })
- },
- [persistCustomKeys]
- )
-
- useFocusEffect(
- useCallback(() => {
- refreshShortcutLayout()
- refreshCustomKeys()
- }, [refreshShortcutLayout, refreshCustomKeys])
- )
-
- useEffect(() => {
- const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
- if (s === 'active') {
- refreshShortcutLayout()
- refreshCustomKeys()
- }
- })
- return () => sub.remove()
- }, [refreshShortcutLayout, refreshCustomKeys])
-
- const toggleBuiltInKey = useCallback(
- (id: string, visible: boolean) => {
- setShortcutLayout((current) => {
- const next = setTerminalAccessoryBuiltInVisible(current, id, visible)
- persistLayout(next)
- return next
- })
- },
- [persistLayout]
- )
-
- const reorderBuiltInKeys = useCallback(
- (orderedKeys: string[]) => {
- setShortcutLayout((current) => {
- const next = reorderTerminalAccessoryBuiltInIds(current, orderedKeys)
- persistLayout(next)
- return next
- })
- },
- [persistLayout]
- )
-
- const resetBuiltInKeys = useCallback(() => {
- const next = getDefaultTerminalAccessoryLayout()
- setShortcutLayout(next)
- persistLayout(next)
- }, [persistLayout])
-
- const reorderCustomKeys = useCallback(
- (orderedKeys: string[]) => {
- setCustomKeys((current) => {
- const byId = new Map(current.map((key) => [key.id, key]))
- const reordered = orderedKeys.flatMap((id) => {
- const key = byId.get(id)
- return key ? [key] : []
- })
- if (reordered.length !== current.length) {
- return current
- }
- persistCustomKeys(reordered)
- return reordered
- })
- },
- [persistCustomKeys]
- )
-
- const visibleBuiltInSet = useMemo(
- () => new Set(shortcutLayout.visibleBuiltInIds),
- [shortcutLayout.visibleBuiltInIds]
- )
- const orderedAccessoryKeys = useMemo(() => {
- const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key]))
- return shortcutLayout.orderedBuiltInIds.flatMap((id) => {
- const key = byId.get(id)
- return key ? [key] : []
- })
- }, [shortcutLayout.orderedBuiltInIds])
+ const {
+ busy,
+ error,
+ customKeys,
+ showCustomKeyModal,
+ setShowCustomKeyModal,
+ visibleBuiltInSet,
+ orderedAccessoryKeys,
+ handleDeleteCustomKey,
+ toggleBuiltInKey,
+ reorderBuiltInKeys,
+ resetBuiltInKeys,
+ reorderCustomKeys,
+ customKeysWriteSeqRef,
+ setCustomKeys
+ } = useTerminalShortcutSettings(preferences)
return (
<>
- SHORTCUT BAR
-
- Toggle keys to show or hide them, and hold the grip to drag a key into the order you want on
- the terminal shortcut bar.
-
-
- shortcutKey.id}
- rowHeight={REORDER_ROW_HEIGHT}
- scrollRef={scrollRef}
- scrollOffsetY={scrollOffsetY}
- scrollContentHeight={scrollContentHeight}
- onDragActiveChange={onDragActiveChange}
- onReorder={reorderBuiltInKeys}
- renderRow={(shortcutKey) => (
- toggleBuiltInKey(shortcutKey.id, visible)}
- />
- )}
- />
- [styles.row, pressed && styles.rowPressed]}
- onPress={resetBuiltInKeys}
- >
-
- Reset Defaults
-
- Show every built-in shortcut key in the original order
-
-
-
-
-
- CUSTOM SHORTCUTS
-
- {customKeys.length === 0 ? (
- <>
-
- No custom shortcuts defined yet.
-
-
- >
- ) : (
+ {error && (
+
+ {error}
+
+ )}
+
+ SHORTCUT BAR
+
+ Toggle keys to show or hide them, and hold the grip to drag a key into the order you want
+ on the terminal shortcut bar.
+
+
key.id}
+ items={orderedAccessoryKeys}
+ itemKey={(shortcutKey) => shortcutKey.id}
rowHeight={REORDER_ROW_HEIGHT}
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
scrollContentHeight={scrollContentHeight}
onDragActiveChange={onDragActiveChange}
- onReorder={reorderCustomKeys}
- renderRow={(key) => (
-
-
- {key.label}
-
-
- {key.label}
-
- {key.bytes.replace(/\r/g, ' ↵')}
-
-
- [
- styles.deleteButton,
- pressed && styles.deleteButtonPressed
- ]}
- onPress={() => handleDeleteCustomKey(key)}
- >
-
-
-
+ onReorder={reorderBuiltInKeys}
+ renderRow={(shortcutKey) => (
+ toggleBuiltInKey(shortcutKey.id, visible)}
+ />
)}
/>
- )}
- [styles.row, pressed && styles.rowPressed]}
- onPress={() => setShowCustomKeyModal(true)}
- >
-
- Add Custom Shortcut…
- Create key combo or text macro
-
-
-
-
+ [styles.row, pressed && styles.rowPressed]}
+ disabled={busy}
+ onPress={resetBuiltInKeys}
+ >
+
+ Reset Defaults
+
+ Show every built-in shortcut key in the original order
+
+
+
+
+ CUSTOM SHORTCUTS
+
+ {customKeys.length === 0 ? (
+ <>
+
+ No custom shortcuts defined yet.
+
+
+ >
+ ) : (
+ key.id}
+ rowHeight={REORDER_ROW_HEIGHT}
+ scrollRef={scrollRef}
+ scrollOffsetY={scrollOffsetY}
+ scrollContentHeight={scrollContentHeight}
+ onDragActiveChange={onDragActiveChange}
+ onReorder={reorderCustomKeys}
+ renderRow={(key) => (
+
+
+ {key.label}
+
+
+ {key.label}
+
+ {key.bytes.replace(/\r/g, ' ↵')}
+
+
+ [
+ styles.deleteButton,
+ pressed && styles.deleteButtonPressed
+ ]}
+ disabled={busy}
+ accessibilityRole="button"
+ accessibilityLabel={`Delete ${key.label}`}
+ onPress={() => handleDeleteCustomKey(key)}
+ >
+
+
+
+ )}
+ />
+ )}
+ [styles.row, pressed && styles.rowPressed]}
+ disabled={busy}
+ accessibilityRole="button"
+ accessibilityLabel="Add custom shortcut"
+ onPress={() => setShowCustomKeyModal(true)}
+ >
+
+ Add Custom Shortcut…
+ Create key combo or text macro
+
+
+
+
+
setShowCustomKeyModal(false)}
+ loadKeys={preferences.loadKeys}
+ saveKeys={preferences.saveKeys}
onKeysChanged={(keys) => {
// Why: the modal already persisted this list; bumping the sequence
// discards refreshes that read storage before its save landed.
@@ -326,103 +204,3 @@ export function TerminalShortcutSettings({
>
)
}
-
-const styles = StyleSheet.create({
- groupHeading: {
- fontSize: 11,
- fontWeight: '600',
- color: colors.textMuted,
- letterSpacing: 0.5,
- marginBottom: spacing.xs,
- paddingHorizontal: spacing.xs
- },
- groupTopGap: {
- marginTop: spacing.xl
- },
- groupDescription: {
- fontSize: typography.bodySize - 1,
- color: colors.textSecondary,
- lineHeight: 20,
- paddingHorizontal: spacing.xs
- },
- section: {
- backgroundColor: colors.bgPanel,
- borderRadius: radii.card,
- overflow: 'hidden'
- },
- sectionTopGap: {
- marginTop: spacing.sm
- },
- row: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm + 2,
- paddingVertical: spacing.md,
- paddingHorizontal: spacing.md + 2
- },
- rowPressed: {
- backgroundColor: colors.bgRaised
- },
- // Why: rows inside DragReorderList get a fixed height and a trailing grip
- // handle from the list itself, so content only pads on the left.
- reorderRowContent: {
- flex: 1,
- height: '100%',
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.sm + 2,
- paddingLeft: spacing.md + 2
- },
- rowContent: {
- flex: 1
- },
- rowLabel: {
- fontSize: typography.bodySize,
- fontWeight: '500',
- color: colors.textPrimary
- },
- rowSublabel: {
- fontSize: typography.bodySize - 2,
- color: colors.textSecondary,
- marginTop: 2
- },
- keycap: {
- minWidth: 62,
- alignItems: 'center',
- backgroundColor: colors.bgRaised,
- borderRadius: radii.button,
- paddingHorizontal: spacing.sm,
- paddingVertical: spacing.xs
- },
- keycapText: {
- color: colors.textSecondary,
- fontSize: typography.metaSize,
- fontFamily: typography.monoFamily
- },
- separator: {
- height: StyleSheet.hairlineWidth,
- backgroundColor: colors.borderSubtle,
- marginHorizontal: spacing.md
- },
- emptyContainer: {
- padding: spacing.md,
- alignItems: 'center',
- justifyContent: 'center'
- },
- emptyText: {
- fontSize: typography.bodySize,
- color: colors.textSecondary,
- padding: spacing.md
- },
- deleteButton: {
- width: 32,
- height: 32,
- borderRadius: 16,
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: 'rgba(239, 68, 68, 0.1)'
- },
- deleteButtonPressed: {
- backgroundColor: 'rgba(239, 68, 68, 0.2)'
- }
-})
diff --git a/mobile/src/components/terminal-shortcut-settings-styles.ts b/mobile/src/components/terminal-shortcut-settings-styles.ts
new file mode 100644
index 00000000000..aacc8cb3ae6
--- /dev/null
+++ b/mobile/src/components/terminal-shortcut-settings-styles.ts
@@ -0,0 +1,101 @@
+import { StyleSheet } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+export const terminalShortcutSettingsStyles = StyleSheet.create({
+ groupHeading: {
+ fontSize: 11,
+ fontWeight: '600',
+ color: colors.textMuted,
+ letterSpacing: 0.5,
+ marginBottom: spacing.xs,
+ paddingHorizontal: spacing.xs
+ },
+ groupTopGap: {
+ marginTop: spacing.xl
+ },
+ groupDescription: {
+ fontSize: typography.bodySize - 1,
+ color: colors.textSecondary,
+ lineHeight: 20,
+ paddingHorizontal: spacing.xs
+ },
+ section: {
+ backgroundColor: colors.bgPanel,
+ borderRadius: radii.card,
+ overflow: 'hidden'
+ },
+ sectionTopGap: {
+ marginTop: spacing.sm
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm + 2,
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.md + 2
+ },
+ rowPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ // Why: rows inside DragReorderList get a fixed height and a trailing grip
+ // handle from the list itself, so content only pads on the left.
+ reorderRowContent: {
+ flex: 1,
+ height: '100%',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm + 2,
+ paddingLeft: spacing.md + 2
+ },
+ rowContent: {
+ flex: 1
+ },
+ rowLabel: {
+ fontSize: typography.bodySize,
+ fontWeight: '500',
+ color: colors.textPrimary
+ },
+ rowSublabel: {
+ fontSize: typography.bodySize - 2,
+ color: colors.textSecondary,
+ marginTop: 2
+ },
+ keycap: {
+ minWidth: 62,
+ alignItems: 'center',
+ backgroundColor: colors.bgRaised,
+ borderRadius: radii.button,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: spacing.xs
+ },
+ keycapText: {
+ color: colors.textSecondary,
+ fontSize: typography.metaSize,
+ fontFamily: typography.monoFamily
+ },
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.borderSubtle,
+ marginHorizontal: spacing.md
+ },
+ emptyContainer: {
+ padding: spacing.md,
+ alignItems: 'center',
+ justifyContent: 'center'
+ },
+ emptyText: {
+ fontSize: typography.bodySize,
+ color: colors.textSecondary,
+ padding: spacing.md
+ },
+ deleteButton: {
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: 'rgba(239, 68, 68, 0.1)'
+ },
+ deleteButtonPressed: {
+ backgroundColor: 'rgba(239, 68, 68, 0.2)'
+ }
+})
diff --git a/mobile/src/components/use-terminal-shortcut-settings.test.ts b/mobile/src/components/use-terminal-shortcut-settings.test.ts
new file mode 100644
index 00000000000..cfd73c1cefc
--- /dev/null
+++ b/mobile/src/components/use-terminal-shortcut-settings.test.ts
@@ -0,0 +1,67 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useTerminalShortcutSettings } from './use-terminal-shortcut-settings'
+import { getDefaultTerminalAccessoryLayout } from '../terminal/terminal-accessory-layout'
+import type { TerminalShortcutPreferences } from '../terminal/terminal-settings-operations'
+vi.mock('react-native', () => ({ AppState: { addEventListener: () => ({ remove() {} }) } }))
+vi.mock('@react-native-async-storage/async-storage', () => ({ default: {} }))
+vi.mock('expo-router', async () => {
+ const { useEffect } = await import('react')
+ return { useFocusEffect: (callback: () => void) => useEffect(callback, [callback]) }
+})
+let renderer: ReactTestRenderer | undefined
+let state: ReturnType
+afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = undefined
+})
+function preferences() {
+ return {
+ loadLayout: vi.fn().mockResolvedValue(getDefaultTerminalAccessoryLayout()),
+ loadKeys: vi.fn().mockResolvedValue([]),
+ saveLayout: vi.fn().mockResolvedValue(undefined),
+ saveKeys: vi.fn().mockResolvedValue(undefined)
+ }
+}
+async function mount(storage: TerminalShortcutPreferences) {
+ function Harness() {
+ state = useTerminalShortcutSettings(storage)
+ return null
+ }
+ await act(async () => {
+ renderer = create(createElement(Harness))
+ })
+}
+describe('terminal shortcut settings persistence', () => {
+ it('writes layout changes to the injected page or native preference owner', async () => {
+ const storage = preferences()
+ await mount(storage)
+ expect(state.busy).toBe(false)
+ await act(async () => {
+ state.toggleBuiltInKey('escape', false)
+ })
+ expect(storage.saveLayout).toHaveBeenCalledTimes(1)
+ expect(storage.saveLayout.mock.calls[0]?.[0].visibleBuiltInIds).not.toContain('escape')
+ expect(state.visibleBuiltInSet.has('escape')).toBe(false)
+ expect(state.busy).toBe(false)
+ })
+ it('restores the last saved layout and reports failed persistence', async () => {
+ const storage = preferences()
+ storage.saveLayout.mockRejectedValue(new Error('storage failed'))
+ await mount(storage)
+ await act(async () => {
+ state.toggleBuiltInKey('escape', false)
+ })
+ expect(state.visibleBuiltInSet.has('escape')).toBe(true)
+ expect(state.error).toContain('Could not save shortcut layout')
+ expect(storage.saveLayout).toHaveBeenCalledTimes(1)
+ })
+ it('never enables controls over unreadable initial data', async () => {
+ const storage = preferences()
+ storage.loadKeys.mockRejectedValue(new Error('storage failed'))
+ await mount(storage)
+ expect(state.busy).toBe(true)
+ expect(state.error).toContain('Could not load custom shortcuts')
+ })
+})
diff --git a/mobile/src/components/use-terminal-shortcut-settings.ts b/mobile/src/components/use-terminal-shortcut-settings.ts
new file mode 100644
index 00000000000..f15a36e53cb
--- /dev/null
+++ b/mobile/src/components/use-terminal-shortcut-settings.ts
@@ -0,0 +1,210 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { AppState, type AppStateStatus } from 'react-native'
+import { useFocusEffect } from 'expo-router'
+import type { CustomKey } from '../storage/terminal-custom-key-storage'
+import type { TerminalShortcutPreferences } from '../terminal/terminal-settings-operations'
+import { TERMINAL_ACCESSORY_KEYS } from '../terminal/terminal-accessory-keys'
+import {
+ getDefaultTerminalAccessoryLayout,
+ reorderTerminalAccessoryBuiltInIds,
+ setTerminalAccessoryBuiltInVisible,
+ type TerminalAccessoryLayout
+} from '../terminal/terminal-accessory-layout'
+
+export function useTerminalShortcutSettings(preferences: TerminalShortcutPreferences) {
+ const [loadedKeys, setLoadedKeys] = useState(false)
+ const [loadedLayout, setLoadedLayout] = useState(false)
+ const [pendingWrites, setPendingWrites] = useState(0)
+ const [error, setError] = useState(null)
+ const [customKeys, setCustomKeys] = useState([])
+ const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
+ const [shortcutLayout, setShortcutLayout] = useState(
+ getDefaultTerminalAccessoryLayout
+ )
+ const layoutWriteChainRef = useRef>(Promise.resolve())
+ const layoutWriteSeqRef = useRef(0)
+ const pendingLayoutWritesRef = useRef(0)
+
+ const persistLayout = useCallback(
+ (next: TerminalAccessoryLayout) => {
+ setPendingWrites((count) => count + 1)
+ setError(null)
+ layoutWriteSeqRef.current += 1
+ pendingLayoutWritesRef.current += 1
+ layoutWriteChainRef.current = layoutWriteChainRef.current
+ .catch(() => {})
+ .then(() => preferences.saveLayout(next))
+ .catch(async () => {
+ setError('Could not save shortcut layout. Try again.')
+ try {
+ setShortcutLayout(await preferences.loadLayout())
+ } catch {
+ setLoadedLayout(false)
+ }
+ })
+ .finally(() => {
+ pendingLayoutWritesRef.current -= 1
+ setPendingWrites((count) => count - 1)
+ })
+ },
+ [preferences]
+ )
+
+ const refreshShortcutLayout = useCallback(() => {
+ const refreshSeq = layoutWriteSeqRef.current
+ void preferences
+ .loadLayout()
+ .then((layout) => {
+ if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) {
+ return
+ }
+ setLoadedLayout(true)
+ setShortcutLayout({
+ orderedBuiltInIds: layout.orderedBuiltInIds,
+ visibleBuiltInIds: layout.visibleBuiltInIds
+ })
+ })
+ .catch(() => setError('Could not load shortcut layout. Go back and try again.'))
+ }, [preferences])
+
+ const customKeysWriteChainRef = useRef>(Promise.resolve())
+ const customKeysWriteSeqRef = useRef(0)
+ const pendingCustomKeysWritesRef = useRef(0)
+
+ // Why: same stale-snapshot guard as persistLayout — a focus/AppState refresh
+ // racing an in-flight save must not overwrite the optimistic state.
+ const persistCustomKeys = useCallback(
+ (next: CustomKey[]) => {
+ setPendingWrites((count) => count + 1)
+ setError(null)
+ customKeysWriteSeqRef.current += 1
+ pendingCustomKeysWritesRef.current += 1
+ customKeysWriteChainRef.current = customKeysWriteChainRef.current
+ .catch(() => {})
+ .then(() => preferences.saveKeys(next))
+ .catch(async () => {
+ setError('Could not save custom shortcuts. Try again.')
+ try {
+ setCustomKeys(await preferences.loadKeys())
+ } catch {
+ setLoadedKeys(false)
+ }
+ })
+ .finally(() => {
+ pendingCustomKeysWritesRef.current -= 1
+ setPendingWrites((count) => count - 1)
+ })
+ },
+ [preferences]
+ )
+
+ const refreshCustomKeys = useCallback(() => {
+ const refreshSeq = customKeysWriteSeqRef.current
+ void preferences
+ .loadKeys()
+ .then((keys) => {
+ if (
+ pendingCustomKeysWritesRef.current > 0 ||
+ refreshSeq !== customKeysWriteSeqRef.current
+ ) {
+ return
+ }
+ setLoadedKeys(true)
+ setCustomKeys(keys)
+ })
+ .catch(() => setError('Could not load custom shortcuts. Go back and try again.'))
+ }, [preferences])
+
+ const handleDeleteCustomKey = useCallback(
+ (key: CustomKey) => {
+ const updated = customKeys.filter((entry) => entry.id !== key.id)
+ setCustomKeys(updated)
+ persistCustomKeys(updated)
+ },
+ [customKeys, persistCustomKeys]
+ )
+
+ useFocusEffect(
+ useCallback(() => {
+ refreshShortcutLayout()
+ refreshCustomKeys()
+ }, [refreshShortcutLayout, refreshCustomKeys])
+ )
+
+ useEffect(() => {
+ const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
+ if (s === 'active') {
+ refreshShortcutLayout()
+ refreshCustomKeys()
+ }
+ })
+ return () => sub.remove()
+ }, [refreshShortcutLayout, refreshCustomKeys])
+
+ const toggleBuiltInKey = useCallback(
+ (id: string, visible: boolean) => {
+ const next = setTerminalAccessoryBuiltInVisible(shortcutLayout, id, visible)
+ setShortcutLayout(next)
+ persistLayout(next)
+ },
+ [shortcutLayout, persistLayout]
+ )
+ const reorderBuiltInKeys = useCallback(
+ (orderedKeys: string[]) => {
+ const next = reorderTerminalAccessoryBuiltInIds(shortcutLayout, orderedKeys)
+ setShortcutLayout(next)
+ persistLayout(next)
+ },
+ [shortcutLayout, persistLayout]
+ )
+ const resetBuiltInKeys = useCallback(() => {
+ const next = getDefaultTerminalAccessoryLayout()
+ setShortcutLayout(next)
+ persistLayout(next)
+ }, [persistLayout])
+
+ const reorderCustomKeys = useCallback(
+ (orderedKeys: string[]) => {
+ const byId = new Map(customKeys.map((key) => [key.id, key]))
+ const reordered = orderedKeys.flatMap((id) => {
+ const key = byId.get(id)
+ return key ? [key] : []
+ })
+ if (reordered.length !== customKeys.length) {
+ return
+ }
+ setCustomKeys(reordered)
+ persistCustomKeys(reordered)
+ },
+ [customKeys, persistCustomKeys]
+ )
+ const visibleBuiltInSet = useMemo(
+ () => new Set(shortcutLayout.visibleBuiltInIds),
+ [shortcutLayout.visibleBuiltInIds]
+ )
+ const orderedAccessoryKeys = useMemo(() => {
+ const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key]))
+ return shortcutLayout.orderedBuiltInIds.flatMap((id) => {
+ const key = byId.get(id)
+ return key ? [key] : []
+ })
+ }, [shortcutLayout.orderedBuiltInIds])
+
+ return {
+ busy: !loadedKeys || !loadedLayout || pendingWrites > 0,
+ error,
+ customKeys,
+ showCustomKeyModal,
+ setShowCustomKeyModal,
+ shortcutLayout,
+ visibleBuiltInSet,
+ orderedAccessoryKeys,
+ handleDeleteCustomKey,
+ toggleBuiltInKey,
+ reorderBuiltInKeys,
+ resetBuiltInKeys,
+ reorderCustomKeys,
+ customKeysWriteSeqRef,
+ setCustomKeys
+ }
+}
diff --git a/mobile/src/mobile-web/hosted-page-route-state.test.ts b/mobile/src/mobile-web/hosted-page-route-state.test.ts
index 79e080f9afb..a80ae67c44f 100644
--- a/mobile/src/mobile-web/hosted-page-route-state.test.ts
+++ b/mobile/src/mobile-web/hosted-page-route-state.test.ts
@@ -2,14 +2,17 @@ import { describe, expect, it } from 'vitest'
import { hostedPageRouteState, hostedPageStateTarget } from './hosted-page-route-state'
describe('hosted page route state', () => {
- it.each(['/settings', '/native-chat-settings', '/browser-settings'])(
- 'roundtrips %s without document-scoped handles',
- (pathname) => {
- const state = hostedPageRouteState(pathname)
- expect(hostedPageStateTarget(state)).toBe(pathname)
- expect(JSON.parse(state!)).toEqual({ version: 1, pathname })
- }
- )
+ it.each([
+ '/settings',
+ '/native-chat-settings',
+ '/browser-settings',
+ '/terminal-settings',
+ '/about'
+ ])('roundtrips %s without document-scoped handles', (pathname) => {
+ const state = hostedPageRouteState(pathname)
+ expect(hostedPageStateTarget(state)).toBe(pathname)
+ expect(JSON.parse(state!)).toEqual({ version: 1, pathname })
+ })
it.each([
undefined,
diff --git a/mobile/src/mobile-web/hosted-page-route-state.ts b/mobile/src/mobile-web/hosted-page-route-state.ts
index 29a770a02a5..635526c4157 100644
--- a/mobile/src/mobile-web/hosted-page-route-state.ts
+++ b/mobile/src/mobile-web/hosted-page-route-state.ts
@@ -1,5 +1,11 @@
// Only page-owned, durable route names belong here; document-scoped resource handles do not.
-const HOSTED_PAGE_ROUTES = new Set(['/settings', '/native-chat-settings', '/browser-settings'])
+const HOSTED_PAGE_ROUTES = new Set([
+ '/settings',
+ '/about',
+ '/native-chat-settings',
+ '/browser-settings',
+ '/terminal-settings'
+])
export function hostedPageRouteState(pathname: string): string | undefined {
return HOSTED_PAGE_ROUTES.has(pathname) ? JSON.stringify({ version: 1, pathname }) : undefined
diff --git a/mobile/src/mobile-web/hosted-route-native-api-census.test.ts b/mobile/src/mobile-web/hosted-route-native-api-census.test.ts
index 16dd1cd4742..940a379ac4c 100644
--- a/mobile/src/mobile-web/hosted-route-native-api-census.test.ts
+++ b/mobile/src/mobile-web/hosted-route-native-api-census.test.ts
@@ -6,7 +6,7 @@ import { hostedModuleGraph, hostedRoot, mobileRoot } from './hosted-module-graph
const HOSTED_SESSION_ROUTE = join(hostedRoot, 'h', '[hostId]', 'session', '[worktreeId].tsx')
// Native-only entry points: on the hosted page these either throw or silently do nothing.
-const BANNED_IN_HOSTED_BUNDLE = ['Clipboard.setStringAsync', "router.push('/terminal-settings')"]
+const BANNED_IN_HOSTED_BUNDLE = ['Clipboard.setStringAsync']
const BANNED_IN_SESSION_ROUTE = [
...BANNED_IN_HOSTED_BUNDLE,
'Linking.openURL',
@@ -32,6 +32,8 @@ describe('hosted route native API census', () => {
const graph = hostedModuleGraph()
expect(graph.length).toBeGreaterThan(500)
+ expect(graph).toContain(join(hostedRoot, 'terminal-settings.tsx'))
+ // Terminal settings now has a real hosted route; it is no longer a native-only destination.
// AsyncStorage is deliberately not censused: it is reachable today through the shared
// storage and transport modules, and on web it resolves to localStorage.
expect(offenders(graph, BANNED_IN_HOSTED_BUNDLE)).toEqual([])
diff --git a/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts b/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts
index 8e5facbbee6..fd1c1c44978 100644
--- a/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts
+++ b/mobile/src/mobile-web/mobile-native-shell-route-ownership.test.ts
@@ -65,7 +65,13 @@ describe('mobile native shell route ownership', () => {
it('hosts migrated preferences while retaining native recovery screens', () => {
const hostedRoutePaths = listRouteFiles(hostedRouteRoot)
- const hostedSettings = ['settings', 'native-chat-settings', 'browser-settings']
+ const hostedSettings = [
+ 'settings',
+ 'native-chat-settings',
+ 'browser-settings',
+ 'terminal-settings',
+ 'about'
+ ]
for (const routeName of hostedSettings) {
expect(hostedRoutePaths).toContain(`${routeName}.tsx`)
}
diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts
index 6132a9ad18b..e0ecc57bc47 100644
--- a/mobile/src/session/mobile-session-route-parity.test.ts
+++ b/mobile/src/session/mobile-session-route-parity.test.ts
@@ -71,8 +71,8 @@ const HEAD_MAIN_HOOK_SHA256 = '8a65c402639980ffda9132ce3dba84da8d85998f5c6b933a8
const HEAD_HOOK_BINDING_SHA256 = 'e43ab1ae9e6fd0eb126558207868f1da6322ba50bdeb4a6f7132e6a1f29a9474'
const HEAD_CALLBACK_IDENTITY_SHA256 =
'3ad3c833aa99bbfd3a4038bae70a0247192f51fb938a2fe3df86626dcfa3386e'
-const HEAD_CALLBACK_BODY_SHA256 = '105697776621cd4eaa4242191844d562c6bf3673fdb42a0736da959a2ca8a574'
-const HEAD_EFFECT_SHA256 = 'f81ef4b4794875643dd429e9dfb6cffab037feb68a334e260c0045a258c07d51'
+const HEAD_CALLBACK_BODY_SHA256 = '4fd9fef0051a6eacd9c870bd577aeb17e6fac734d998c2a5023800eb335c573b'
+const HEAD_EFFECT_SHA256 = '36e816e2114ad1b21e3bc88fea12f0ffdd8a830a5f2aeff90146ca71e626b2ab'
const HEAD_CONTENT_HOOK_SHA256 = 'd74431115b27c22dd38c29a510604554ca767cdd2585beaa73ec2e2dae0c5de4'
// Re-frozen when main's structured Claude chat (#18741) generalized the bare-launch gate
// from an `agent === 'codex'` literal to isAgentSessionHandleProvider.
diff --git a/mobile/src/session/mobile-web-session-screen-binding-source.test.ts b/mobile/src/session/mobile-web-session-screen-binding-source.test.ts
index 63efd4a919c..d0d1422ce21 100644
--- a/mobile/src/session/mobile-web-session-screen-binding-source.test.ts
+++ b/mobile/src/session/mobile-web-session-screen-binding-source.test.ts
@@ -22,7 +22,7 @@ describe('mobile web session screen binding', () => {
expect(hostedSessionRoute).toContain('webHostSessionTerminalOperations(shell.client)')
expect(hostedSessionRoute).toContain('webHostSessionFileOperations(shell.client)')
expect(hostedSessionRoute).toContain('webHostSessionMarkdownOperations(shell.client)')
- expect(hostedSessionRoute).toContain('webHostSessionDeviceOperations(shell.client)')
+ expect(hostedSessionRoute).toContain('webHostSessionDeviceOperations(shell.client,')
expect(hostedSessionRoute).toContain('webHostSessionBrowserOperations(shell.client)')
expect(hostedSessionRoute).toContain('webHostSessionDictationOperations(shell.client)')
expect(hostedSessionRoute).toContain('webHostSessionNativeChatOperations(shell.client)')
diff --git a/mobile/src/session/use-mobile-session-lifecycle.ts b/mobile/src/session/use-mobile-session-lifecycle.ts
index 587e2f3ed47..ae0f43cfe95 100644
--- a/mobile/src/session/use-mobile-session-lifecycle.ts
+++ b/mobile/src/session/use-mobile-session-lifecycle.ts
@@ -46,12 +46,15 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
useFocusEffect(
useCallback(() => {
let stale = false
- void sessionDeviceOperations?.loadTerminalAccessoryPreferences().then((preferences) => {
- if (!stale) {
- setCustomKeys(preferences.customKeys)
- setVisibleBuiltInIds(preferences.visibleBuiltInIds)
- }
- })
+ void sessionDeviceOperations
+ ?.loadTerminalAccessoryPreferences()
+ .then((preferences) => {
+ if (!stale) {
+ setCustomKeys(preferences.customKeys)
+ setVisibleBuiltInIds(preferences.visibleBuiltInIds)
+ }
+ })
+ .catch(() => {})
return () => {
stale = true
}
@@ -61,12 +64,15 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
useEffect(() => {
let mounted = true
const refresh = () => {
- void sessionDeviceOperations?.loadTerminalAccessoryPreferences().then((preferences) => {
- if (mounted) {
- setCustomKeys(preferences.customKeys)
- setVisibleBuiltInIds(preferences.visibleBuiltInIds)
- }
- })
+ void sessionDeviceOperations
+ ?.loadTerminalAccessoryPreferences()
+ .then((preferences) => {
+ if (mounted) {
+ setCustomKeys(preferences.customKeys)
+ setVisibleBuiltInIds(preferences.visibleBuiltInIds)
+ }
+ })
+ .catch(() => {})
}
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
if (s === 'active') {
diff --git a/mobile/src/session/web-host-session-device-operations.test.ts b/mobile/src/session/web-host-session-device-operations.test.ts
index 4385d1b6eb4..0453ab145e3 100644
--- a/mobile/src/session/web-host-session-device-operations.test.ts
+++ b/mobile/src/session/web-host-session-device-operations.test.ts
@@ -4,7 +4,7 @@ import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-w
import { webHostSessionDeviceOperations } from './web-host-session-device-operations'
vi.mock('@react-native-async-storage/async-storage', () => ({
- default: { getItem: vi.fn() }
+ default: { getItem: vi.fn(), setItem: vi.fn().mockResolvedValue(undefined) }
}))
describe('web host session device operations', () => {
@@ -15,7 +15,9 @@ describe('web host session device operations', () => {
it('applies the paired-host page preference to terminal link behavior', async () => {
const client = bridgeClient()
client.native.supports.mockReturnValue(true)
- vi.mocked(AsyncStorage.getItem).mockResolvedValue('orca-browser')
+ vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) =>
+ key === 'orca:terminalLinkOpenMode' ? 'orca-browser' : null
+ )
const operations = webHostSessionDeviceOperations(client as unknown as MobileWebBridgeClient)
await expect(operations.loadTerminalPreferences()).resolves.toEqual({
textScale: 1.25,
@@ -31,7 +33,7 @@ describe('web host session device operations', () => {
const operations = webHostSessionDeviceOperations(client as unknown as MobileWebBridgeClient)
expect((await operations.loadTerminalPreferences()).linkOpenMode).toBe('phone-browser')
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('temporarily unavailable'))
- expect((await operations.loadTerminalPreferences()).linkOpenMode).toBe('phone-browser')
+ await expect(operations.loadTerminalPreferences()).rejects.toThrow('temporarily unavailable')
})
it('retains the native preference on shells without page storage', async () => {
@@ -40,6 +42,57 @@ describe('web host session device operations', () => {
expect((await operations.loadTerminalPreferences()).linkOpenMode).toBe('phone-browser')
expect(AsyncStorage.getItem).not.toHaveBeenCalled()
})
+
+ it('uses saved scale and autocomplete and stores pinch changes in the same page scope', async () => {
+ const client = bridgeClient()
+ client.native.supports.mockReturnValue(true)
+ vi.mocked(AsyncStorage.getItem).mockImplementation(
+ async (key) =>
+ ({
+ 'orca:terminalTextScale': '1.5',
+ 'orca:terminalAutocompleteEnabled': 'false'
+ })[key] ?? null
+ )
+ const navigate = vi.fn()
+ const operations = webHostSessionDeviceOperations(
+ client as unknown as MobileWebBridgeClient,
+ navigate
+ )
+ expect(await operations.loadTerminalPreferences()).toEqual({
+ textScale: 1.5,
+ autocompleteEnabled: false,
+ linkOpenMode: 'phone-browser'
+ })
+ await operations.saveTerminalTextScale(2)
+ expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:terminalTextScale', '2')
+ expect(client.native.terminalTextScaleUpdate).not.toHaveBeenCalled()
+ operations.openTerminalSettings()
+ expect(navigate).toHaveBeenCalledWith('/terminal-settings')
+ expect(client.navigationRoute).not.toHaveBeenCalled()
+ })
+
+ it('keeps an explicitly empty custom-key list and shares session edits with settings', async () => {
+ const client = bridgeClient()
+ client.native.supports.mockReturnValue(true)
+ const key = { id: 'build', label: 'Build', bytes: 'make', enter: true }
+ client.native.terminalAccessoryPreferences.mockResolvedValue({
+ customKeys: [key],
+ orderedBuiltInIds: ['escape', 'tab'],
+ visibleBuiltInIds: []
+ })
+ vi.mocked(AsyncStorage.getItem).mockImplementation(async (name) =>
+ name === 'orca:custom-accessory-keys' ? '[]' : null
+ )
+ const operations = webHostSessionDeviceOperations(client as unknown as MobileWebBridgeClient)
+ expect((await operations.loadTerminalAccessoryPreferences()).customKeys).toEqual([])
+ await operations.saveTerminalCustomKeys([key])
+ expect(AsyncStorage.setItem).toHaveBeenCalledWith(
+ 'orca:custom-accessory-keys',
+ JSON.stringify([key])
+ )
+ expect(client.native.terminalCustomKeysUpdate).not.toHaveBeenCalled()
+ })
+
it('routes shell-owned effects through named native bridge methods', async () => {
const client = bridgeClient()
const operations = webHostSessionDeviceOperations(client as unknown as MobileWebBridgeClient)
diff --git a/mobile/src/session/web-host-session-device-operations.ts b/mobile/src/session/web-host-session-device-operations.ts
index 87439a3a7d2..0430f62145f 100644
--- a/mobile/src/session/web-host-session-device-operations.ts
+++ b/mobile/src/session/web-host-session-device-operations.ts
@@ -1,9 +1,16 @@
import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
-import { loadTerminalLinkOpenMode } from '../storage/preferences'
+import { saveTerminalTextScale } from '../storage/preferences'
+import { saveCustomKeys } from '../storage/terminal-custom-key-storage'
+import {
+ loadWebHostTerminalPreferences,
+ loadWebHostTerminalAccessoryPreferences
+} from '../terminal/web-terminal-preferences'
+export { loadWebHostTerminalPreferences } from '../terminal/web-terminal-preferences'
import type { HostSessionDeviceOperations } from './host-session-device-operations'
export function webHostSessionDeviceOperations(
- client: MobileWebBridgeClient
+ client: MobileWebBridgeClient,
+ navigate?: (target: string) => void
): HostSessionDeviceOperations {
return {
hapticFeedback(kind) {
@@ -19,30 +26,31 @@ export function webHostSessionDeviceOperations(
await client.native.openExternal(url)
},
openTerminalSettings() {
- void client.navigationRoute({ destination: 'terminalSettings' }).catch(() => {})
+ if (navigate && client.native.supports('pagePreferences')) {
+ navigate('/terminal-settings')
+ } else {
+ void client.navigationRoute({ destination: 'terminalSettings' }).catch(() => {})
+ }
},
loadTerminalPreferences() {
return loadWebHostTerminalPreferences(client)
},
loadTerminalAccessoryPreferences() {
- return client.native.terminalAccessoryPreferences()
+ return loadWebHostTerminalAccessoryPreferences(client)
},
async saveTerminalCustomKeys(customKeys) {
- await client.native.terminalCustomKeysUpdate(customKeys)
+ if (client.native.supports('pagePreferences')) {
+ await saveCustomKeys([...customKeys])
+ } else {
+ await client.native.terminalCustomKeysUpdate(customKeys)
+ }
},
async saveTerminalTextScale(textScale) {
- await client.native.terminalTextScaleUpdate(textScale)
+ if (client.native.supports('pagePreferences')) {
+ await saveTerminalTextScale(textScale)
+ } else {
+ await client.native.terminalTextScaleUpdate(textScale)
+ }
}
}
}
-
-export async function loadWebHostTerminalPreferences(client: MobileWebBridgeClient) {
- const preferences = await client.native.terminalPreferences()
- if (!client.native.supports('pagePreferences')) {
- return preferences
- }
- return {
- ...preferences,
- linkOpenMode: await loadTerminalLinkOpenMode(preferences.linkOpenMode)
- }
-}
diff --git a/mobile/src/settings/about-screen.tsx b/mobile/src/settings/about-screen.tsx
new file mode 100644
index 00000000000..59e511f9c03
--- /dev/null
+++ b/mobile/src/settings/about-screen.tsx
@@ -0,0 +1,186 @@
+import { useState } from 'react'
+import { View, Text, StyleSheet, Pressable } from 'react-native'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { ChevronLeft, Globe } from 'lucide-react-native'
+import Svg, { Path } from 'react-native-svg'
+import { OrcaLogo } from '../components/OrcaLogo'
+import { colors, spacing, typography } from '../theme/mobile-theme'
+
+function GithubIcon({ size = 16, color = colors.textSecondary }) {
+ return (
+
+ )
+}
+
+function XIcon({ size = 16, color = colors.textSecondary }) {
+ return (
+
+ )
+}
+
+export default function AboutScreen({
+ onBack,
+ openExternal,
+ versionLabel,
+ linksAvailable = true
+}: {
+ onBack: () => void
+ openExternal: (url: string) => Promise
+ versionLabel: string
+ linksAvailable?: boolean
+}) {
+ const [error, setError] = useState(null)
+ const openLink = (url: string) => {
+ setError(null)
+ void openExternal(url).catch(() => setError('Could not open the link. Try again.'))
+ }
+ const insets = useSafeAreaInsets()
+
+ return (
+
+
+
+
+
+ About
+
+
+
+
+ Orca
+ Open-source agent IDE for 100x builders
+
+
+
+ [styles.row, pressed && styles.rowPressed]}
+ accessibilityRole="button"
+ accessibilityLabel="Orca website"
+ disabled={!linksAvailable}
+ onPress={() => openLink('https://onOrca.dev')}
+ >
+
+ onOrca.dev
+
+
+ [styles.row, pressed && styles.rowPressed]}
+ accessibilityRole="button"
+ accessibilityLabel="Orca source code"
+ disabled={!linksAvailable}
+ onPress={() => openLink('https://github.com/stablyai/orca')}
+ >
+
+ stablyai/orca
+
+
+ [styles.row, pressed && styles.rowPressed]}
+ accessibilityRole="button"
+ accessibilityLabel="Orca on X"
+ disabled={!linksAvailable}
+ onPress={() => openLink('https://x.com/orca_build')}
+ >
+
+ @orca_build
+
+
+
+ {versionLabel}
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: colors.bgBase,
+ padding: spacing.lg
+ },
+ topRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: spacing.xl
+ },
+ backButton: {
+ width: 36,
+ height: 36,
+ borderRadius: 18,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginRight: spacing.sm
+ },
+ heading: {
+ fontSize: 20,
+ fontWeight: '700',
+ color: colors.textPrimary
+ },
+ brand: {
+ alignItems: 'center',
+ paddingVertical: spacing.xl,
+ marginBottom: spacing.lg
+ },
+ brandName: {
+ fontSize: 22,
+ fontWeight: '800',
+ color: colors.textPrimary,
+ marginTop: spacing.sm
+ },
+ brandSub: {
+ fontSize: 13,
+ color: colors.textMuted,
+ marginTop: spacing.xs
+ },
+ section: {
+ backgroundColor: colors.bgPanel,
+ borderRadius: 12,
+ overflow: 'hidden'
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm + 2,
+ paddingVertical: spacing.md,
+ paddingHorizontal: spacing.md + 2
+ },
+ rowPressed: {
+ backgroundColor: colors.bgRaised
+ },
+ rowLabel: {
+ flex: 1,
+ fontSize: typography.bodySize,
+ fontWeight: '500',
+ color: colors.textPrimary
+ },
+ rowValue: {
+ flex: 1,
+ textAlign: 'right',
+ fontSize: typography.bodySize,
+ color: colors.textSecondary
+ },
+ separator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: colors.borderSubtle,
+ marginHorizontal: spacing.md
+ },
+ versionText: {
+ marginTop: spacing.lg,
+ textAlign: 'center',
+ fontSize: typography.metaSize,
+ color: colors.textMuted
+ }
+})
diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts
index 00dcc3a4869..e373b522a84 100644
--- a/mobile/src/storage/preferences.ts
+++ b/mobile/src/storage/preferences.ts
@@ -42,18 +42,22 @@ const TEXT_SCALE_KEY = 'orca:terminalTextScale'
export const TERMINAL_TEXT_SCALES = [0.5, 0.75, 1, 1.25, 1.5, 2] as const
const DEFAULT_TEXT_SCALE = 1
-export async function loadTerminalTextScale(): Promise {
+export async function loadTerminalTextScale(
+ options: { fallback?: number; rejectReadFailure?: boolean } = {}
+): Promise {
+ const fallback = options.fallback ?? DEFAULT_TEXT_SCALE
try {
const raw = await AsyncStorage.getItem(TEXT_SCALE_KEY)
if (raw === null) {
- return DEFAULT_TEXT_SCALE
+ return fallback
}
const parsed = Number(raw)
- return (TERMINAL_TEXT_SCALES as readonly number[]).includes(parsed)
- ? parsed
- : DEFAULT_TEXT_SCALE
- } catch {
- return DEFAULT_TEXT_SCALE
+ return (TERMINAL_TEXT_SCALES as readonly number[]).includes(parsed) ? parsed : fallback
+ } catch (error) {
+ if (options.rejectReadFailure) {
+ throw error
+ }
+ return fallback
}
}
@@ -66,12 +70,17 @@ 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 {
+export async function loadTerminalAutocompleteEnabled(
+ options: { fallback?: boolean; rejectReadFailure?: boolean } = {}
+): Promise {
try {
const raw = await AsyncStorage.getItem(AUTOCOMPLETE_KEY)
- return raw === 'true'
- } catch {
- return false
+ return raw === null ? (options.fallback ?? false) : raw === 'true'
+ } catch (error) {
+ if (options.rejectReadFailure) {
+ throw error
+ }
+ return options.fallback ?? false
}
}
diff --git a/mobile/src/storage/terminal-custom-key-storage.ts b/mobile/src/storage/terminal-custom-key-storage.ts
index 3ced7723890..ca88055a22f 100644
--- a/mobile/src/storage/terminal-custom-key-storage.ts
+++ b/mobile/src/storage/terminal-custom-key-storage.ts
@@ -9,12 +9,35 @@ export type CustomKey = {
enter: boolean
}
-export async function loadCustomKeys(): Promise {
+export async function loadCustomKeys(
+ options: { fallback?: CustomKey[]; rejectReadFailure?: boolean } = {}
+): Promise {
try {
const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY)
- return raw ? (JSON.parse(raw) as CustomKey[]) : []
- } catch {
- return []
+ if (raw === null) {
+ return options.fallback ?? []
+ }
+ const value: unknown = JSON.parse(raw)
+ if (
+ !Array.isArray(value) ||
+ !value.every(
+ (key) =>
+ key !== null &&
+ typeof key === 'object' &&
+ typeof key.id === 'string' &&
+ typeof key.label === 'string' &&
+ typeof key.bytes === 'string' &&
+ typeof key.enter === 'boolean'
+ )
+ ) {
+ throw new Error('Invalid custom shortcuts')
+ }
+ return value as CustomKey[]
+ } catch (error) {
+ if (options.rejectReadFailure) {
+ throw error
+ }
+ return options.fallback ?? []
}
}
diff --git a/mobile/src/terminal/native-terminal-settings-host.ts b/mobile/src/terminal/native-terminal-settings-host.ts
new file mode 100644
index 00000000000..7c0257958e5
--- /dev/null
+++ b/mobile/src/terminal/native-terminal-settings-host.ts
@@ -0,0 +1,28 @@
+import type { RpcClient } from '../transport/rpc-client'
+import type { TerminalSettingsHost } from './terminal-settings-operations'
+
+export function nativeTerminalSettingsHost(
+ host: { id: string; name: string },
+ client: RpcClient | null
+): TerminalSettingsHost {
+ async function request(method: string, params?: unknown): Promise {
+ if (!client) {
+ throw new Error('Host is disconnected')
+ }
+ const response = await client.sendRequest(method, params)
+ if (!response.ok) {
+ throw new Error(response.error.message)
+ }
+ const result = response.result as { ms?: unknown } | null
+ if (result?.ms !== null && (typeof result?.ms !== 'number' || !Number.isFinite(result.ms))) {
+ throw new Error('Invalid terminal restore preference')
+ }
+ return result.ms as number | null
+ }
+ return {
+ id: host.id,
+ name: host.name,
+ loadFit: () => request('terminal.getAutoRestoreFit'),
+ saveFit: (ms) => request('terminal.setAutoRestoreFit', { ms })
+ }
+}
diff --git a/mobile/src/terminal/terminal-accessory-layout.ts b/mobile/src/terminal/terminal-accessory-layout.ts
index cee7a74173e..ff3201aa731 100644
--- a/mobile/src/terminal/terminal-accessory-layout.ts
+++ b/mobile/src/terminal/terminal-accessory-layout.ts
@@ -212,15 +212,23 @@ export function getVisibleTerminalAccessoryKeys(
})
}
-export async function loadTerminalAccessoryLayout(): Promise {
+export async function loadTerminalAccessoryLayout(
+ options: { fallback?: TerminalAccessoryLayout; rejectReadFailure?: boolean } = {}
+): Promise {
+ const fallback = options.fallback
+ ? normalizeTerminalAccessoryLayoutPreference({ version: 2, ...options.fallback })
+ : defaultPreference()
try {
const raw = await AsyncStorage.getItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY)
if (!raw) {
- return defaultPreference()
+ return fallback
}
return normalizeTerminalAccessoryLayoutPreference(JSON.parse(raw))
- } catch {
- return defaultPreference()
+ } catch (error) {
+ if (options.rejectReadFailure) {
+ throw error
+ }
+ return fallback
}
}
diff --git a/mobile/src/terminal/terminal-settings-host.test.ts b/mobile/src/terminal/terminal-settings-host.test.ts
new file mode 100644
index 00000000000..aa9ae4bea6e
--- /dev/null
+++ b/mobile/src/terminal/terminal-settings-host.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it, vi } from 'vitest'
+import { nativeTerminalSettingsHost } from './native-terminal-settings-host'
+import {
+ webTerminalSettingsHost,
+ webTerminalSettingsOperations
+} from './web-terminal-settings-operations'
+import type { RpcClient } from '../transport/rpc-client'
+import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
+vi.mock('@react-native-async-storage/async-storage', () => ({
+ default: { getItem: async () => null }
+}))
+
+describe('terminal restore settings adapters', () => {
+ it('shares the single native accessory-read slot across settings sections', async () => {
+ const client = {
+ native: {
+ supports: () => true,
+ terminalAccessoryPreferences: vi
+ .fn()
+ .mockResolvedValue({
+ customKeys: [],
+ orderedBuiltInIds: ['escape'],
+ visibleBuiltInIds: []
+ })
+ }
+ }
+ const settings = webTerminalSettingsOperations(client as unknown as MobileWebBridgeClient)
+ await Promise.all([settings.loadKeys(), settings.loadLayout()])
+ expect(client.native.terminalAccessoryPreferences).toHaveBeenCalledTimes(1)
+ })
+
+ it('unwraps the actual native RPC result and surfaces refusal', async () => {
+ const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: { ms: 60000 } })
+ const host = nativeTerminalSettingsHost({ id: 'host', name: 'Desktop' }, {
+ sendRequest
+ } as unknown as RpcClient)
+ expect(await host.loadFit()).toBe(60000)
+ sendRequest.mockResolvedValue({ ok: true, result: { ms: null } })
+ expect(await host.saveFit(null)).toBe(null)
+ sendRequest.mockResolvedValue({ ok: false, error: { message: 'denied' } })
+ await expect(host.saveFit(60000)).rejects.toThrow('denied')
+ expect(sendRequest).toHaveBeenLastCalledWith('terminal.setAutoRestoreFit', { ms: 60000 })
+ })
+ it('negotiates both host methods and sends no invented workspace', async () => {
+ const client = fixture()
+ const host = await webTerminalSettingsHost(client as unknown as MobileWebBridgeClient)
+ expect(await host?.loadFit()).toBe(60000)
+ expect(client.host.request).toHaveBeenCalledWith({
+ method: 'terminal.getAutoRestoreFit',
+ params: {}
+ })
+ await host?.saveFit(null)
+ expect(client.host.request).toHaveBeenLastCalledWith({
+ method: 'terminal.setAutoRestoreFit',
+ params: { ms: null }
+ })
+ })
+ it('does not dispatch when the shell or catalog lacks host-scoped methods', async () => {
+ const client = fixture()
+ client.supportsShellFeature.mockReturnValue(false)
+ expect(await webTerminalSettingsHost(client as unknown as MobileWebBridgeClient)).toBe(null)
+ expect(client.host.catalog).not.toHaveBeenCalled()
+ client.supportsShellFeature.mockReturnValue(true)
+ client.host.catalog.mockResolvedValue({ grants: [] })
+ expect(await webTerminalSettingsHost(client as unknown as MobileWebBridgeClient)).toBe(null)
+ expect(client.host.request).not.toHaveBeenCalled()
+ })
+ it('does not retry an ambiguous host mutation', async () => {
+ const client = fixture()
+ const host = await webTerminalSettingsHost(client as unknown as MobileWebBridgeClient)
+ client.host.request.mockRejectedValue(new Error('connection lost'))
+ await expect(host?.saveFit(60000)).rejects.toThrow('connection lost')
+ expect(client.host.request).toHaveBeenCalledTimes(1)
+ })
+})
+function fixture() {
+ return {
+ supportsShellFeature: vi.fn(() => true),
+ host: {
+ catalog: vi.fn().mockResolvedValue({
+ grants: ['terminal.getAutoRestoreFit', 'terminal.setAutoRestoreFit'].map((method) => ({
+ method,
+ scope: 'host'
+ }))
+ }),
+ request: vi.fn().mockResolvedValue({ ms: 60000 })
+ }
+ }
+}
diff --git a/mobile/src/terminal/terminal-settings-operations.ts b/mobile/src/terminal/terminal-settings-operations.ts
new file mode 100644
index 00000000000..112d2254095
--- /dev/null
+++ b/mobile/src/terminal/terminal-settings-operations.ts
@@ -0,0 +1,49 @@
+import {
+ loadTerminalAutocompleteEnabled,
+ loadTerminalTextScale,
+ saveTerminalAutocompleteEnabled,
+ saveTerminalTextScale
+} from '../storage/preferences'
+import {
+ loadCustomKeys,
+ saveCustomKeys,
+ type CustomKey
+} from '../storage/terminal-custom-key-storage'
+import {
+ loadTerminalAccessoryLayout,
+ saveTerminalAccessoryLayout,
+ type TerminalAccessoryLayout
+} from './terminal-accessory-layout'
+
+export type TerminalShortcutPreferences = {
+ loadKeys: () => Promise
+ saveKeys: (keys: CustomKey[]) => Promise
+ loadLayout: () => Promise
+ saveLayout: (layout: TerminalAccessoryLayout) => Promise
+}
+export type TerminalSettingsOperations = TerminalShortcutPreferences & {
+ loadPreferences: () => Promise<{ textScale: number; autocompleteEnabled: boolean }>
+ saveTextScale: (scale: number) => Promise
+ saveAutocomplete: (enabled: boolean) => Promise
+}
+export type TerminalSettingsHost = {
+ id: string
+ name: string
+ loadFit: () => Promise
+ saveFit: (ms: number | null) => Promise
+}
+export const nativeTerminalSettingsOperations: TerminalSettingsOperations = {
+ async loadPreferences() {
+ const [textScale, autocompleteEnabled] = await Promise.all([
+ loadTerminalTextScale({ rejectReadFailure: true }),
+ loadTerminalAutocompleteEnabled({ rejectReadFailure: true })
+ ])
+ return { textScale, autocompleteEnabled }
+ },
+ saveTextScale: saveTerminalTextScale,
+ saveAutocomplete: saveTerminalAutocompleteEnabled,
+ loadKeys: () => loadCustomKeys({ rejectReadFailure: true }),
+ saveKeys: saveCustomKeys,
+ loadLayout: () => loadTerminalAccessoryLayout({ rejectReadFailure: true }),
+ saveLayout: saveTerminalAccessoryLayout
+}
diff --git a/mobile/src/terminal/terminal-settings-options.ts b/mobile/src/terminal/terminal-settings-options.ts
new file mode 100644
index 00000000000..c580acba1b5
--- /dev/null
+++ b/mobile/src/terminal/terminal-settings-options.ts
@@ -0,0 +1,68 @@
+import type { PickerOption } from '../components/PickerModal'
+export type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
+
+export type TextSizeValue = 'smallest' | 'smaller' | 'default' | 'large' | 'larger' | 'largest'
+
+// scale = baseline zoom the terminal WebView applies on top of fit-to-width.
+// Keep in sync with TERMINAL_TEXT_SCALES; pinch-to-zoom snaps to these values.
+export const TEXT_SIZE_OPTIONS: (PickerOption & { scale: number })[] = [
+ { value: 'smallest', label: 'Smallest (50%)', scale: 0.5 },
+ { value: 'smaller', label: 'Smaller (75%)', scale: 0.75 },
+ { value: 'default', label: 'Default (100%)', scale: 1 },
+ { value: 'large', label: 'Large (125%)', scale: 1.25 },
+ { value: 'larger', label: 'Larger (150%)', scale: 1.5 },
+ { value: 'largest', label: 'Largest (200%)', scale: 2 }
+]
+
+export function textSizeValueFromScale(scale: number): TextSizeValue {
+ return TEXT_SIZE_OPTIONS.find((o) => o.scale === scale)?.value ?? 'default'
+}
+
+export function textSizeSummary(scale: number): string {
+ return (TEXT_SIZE_OPTIONS.find((o) => o.scale === scale) ?? TEXT_SIZE_OPTIONS[0]!).label
+}
+
+export const AUTO_RESTORE_FIT_OPTIONS: (PickerOption & { ms: number | null })[] = [
+ { value: 'indefinite', label: 'Keep at phone size (default)', ms: null },
+ { value: '60s', label: 'After 1 minute', ms: 60_000 },
+ { value: '5m', label: 'After 5 minutes', ms: 5 * 60_000 },
+ { value: '30m', label: 'After 30 minutes', ms: 30 * 60_000 }
+]
+
+export function valueFromMs(ms: number | null | undefined): RestoreValue {
+ if (ms == null) {
+ return 'indefinite'
+ }
+ const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms)
+ if (exact) {
+ return exact.value
+ }
+ // Why: server may return a non-preset ms (custom value, future preset,
+ // or server-side clamp). Snap to the closest finite preset so the
+ // picker's selected radio agrees with the row sublabel rendered by
+ // autoRestoreSummary ("After Xs").
+ let closest: (typeof AUTO_RESTORE_FIT_OPTIONS)[number] | null = null
+ let bestDelta = Infinity
+ for (const opt of AUTO_RESTORE_FIT_OPTIONS) {
+ if (opt.ms == null) {
+ continue
+ }
+ const delta = Math.abs(opt.ms - ms)
+ if (delta < bestDelta) {
+ bestDelta = delta
+ closest = opt
+ }
+ }
+ return closest ? closest.value : 'indefinite'
+}
+
+export function autoRestoreSummary(ms: number | null | undefined): string {
+ if (ms === undefined) {
+ return '…'
+ }
+ if (ms === null) {
+ return AUTO_RESTORE_FIT_OPTIONS[0]!.label
+ }
+ const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms)
+ return exact ? exact.label : `After ${Math.round(ms / 1000)}s`
+}
diff --git a/mobile/src/terminal/terminal-settings-screen.tsx b/mobile/src/terminal/terminal-settings-screen.tsx
new file mode 100644
index 00000000000..4f046f06378
--- /dev/null
+++ b/mobile/src/terminal/terminal-settings-screen.tsx
@@ -0,0 +1,252 @@
+import { useCallback } from 'react'
+import { View, Text, Pressable, Switch, Platform } from 'react-native'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { GestureHandlerRootView } from 'react-native-gesture-handler'
+import Animated, {
+ useAnimatedRef,
+ useAnimatedScrollHandler,
+ useSharedValue
+} from 'react-native-reanimated'
+import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native'
+import { colors, spacing } from '../theme/mobile-theme'
+import { PickerModal } from '../components/PickerModal'
+import { TerminalShortcutSettings } from '../components/TerminalShortcutSettings'
+import { terminalSettingsScreenStyles as styles } from './terminal-settings-screen-styles'
+import {
+ TEXT_SIZE_OPTIONS,
+ AUTO_RESTORE_FIT_OPTIONS,
+ textSizeValueFromScale,
+ textSizeSummary,
+ valueFromMs,
+ autoRestoreSummary,
+ type RestoreValue,
+ type TextSizeValue
+} from './terminal-settings-options'
+import type {
+ TerminalSettingsHost,
+ TerminalSettingsOperations
+} from './terminal-settings-operations'
+import { useTerminalSettingsState } from './use-terminal-settings-state'
+function HostFitRow({
+ available,
+ hostName,
+ ms,
+ onPress
+}: {
+ available: boolean
+ hostName: string
+ ms: number | null | undefined
+ onPress: () => void
+}): React.JSX.Element {
+ return (
+ [styles.row, pressed && styles.rowPressed]}
+ onPress={onPress}
+ disabled={!available}
+ >
+
+
+ {hostName}
+ {autoRestoreSummary(ms)}
+
+
+
+ )
+}
+
+export default function TerminalSettingsScreen({
+ hosts,
+ operations,
+ onBack,
+ hostUnavailableMessage,
+ scope = 'device'
+}: {
+ scope?: 'device' | 'host'
+ hostUnavailableMessage?: string
+ hosts: TerminalSettingsHost[]
+ operations: TerminalSettingsOperations
+ onBack: () => void
+}) {
+ const insets = useSafeAreaInsets()
+ const {
+ hostMs,
+ pickerHostId,
+ setPickerHostId,
+ textScale,
+ textSizePickerOpen,
+ setTextSizePickerOpen,
+ autocompleteEnabled,
+ selectTextSize,
+ toggleAutocomplete,
+ selectValue,
+ busy,
+ error
+ } = useTerminalSettingsState(hosts, operations)
+ const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null
+ const scrollRef = useAnimatedRef()
+ const scrollOffsetY = useSharedValue(0)
+ const scrollContentHeight = useSharedValue(0)
+ const scrollHandler = useAnimatedScrollHandler((event) => {
+ scrollOffsetY.value = event.contentOffset.y
+ })
+ // Why: imperative toggle instead of state — a re-render while a drag gesture
+ // is active would rebuild the row gestures and could cancel the drag.
+ const setScrollEnabled = useCallback(
+ (enabled: boolean) => {
+ scrollRef.current?.setNativeProps({ scrollEnabled: enabled })
+ },
+ [scrollRef]
+ )
+ const handleDragActiveChange = useCallback(
+ (active: boolean) => setScrollEnabled(!active),
+ [setScrollEnabled]
+ )
+
+ return (
+
+
+
+
+
+ Terminal
+
+
+ {
+ scrollContentHeight.value = height
+ }}
+ >
+ {error && (
+
+ {error}
+
+ )}
+ WHEN YOU LEAVE THE APP
+
+ While you're using a terminal on your phone, Orca shrinks it to fit your screen. When
+ you close the app or switch away, this controls whether it stays at phone size (so
+ interactive CLI tools don't reflow) or resizes back to your desktop. You can always
+ use Restore this terminal or Restore all terminals on the banner to resize manually.
+
+
+ {hosts.length === 0 ? (
+
+
+ {hostUnavailableMessage ??
+ 'No paired desktops yet. Pair one to control terminal behavior.'}
+
+
+ ) : (
+
+ {hosts.map((host, idx) => {
+ return (
+
+ {idx > 0 && }
+ setPickerHostId(host.id)}
+ />
+
+ )
+ })}
+
+ )}
+
+ TEXT SIZE
+
+ Scale the terminal text. Smaller sizes fit more columns with side margins; larger sizes
+ show fewer columns — drag sideways to pan. You can also pinch to zoom in the terminal
+ itself, which updates this setting. Display only
+ {scope === 'host' ? ' on this device for this paired host' : ' on this device'};
+ doesn't change the desktop terminal.
+
+
+ [styles.row, pressed && styles.rowPressed]}
+ accessibilityRole="button"
+ accessibilityLabel="Text size"
+ disabled={busy}
+ onPress={() => setTextSizePickerOpen(true)}
+ >
+
+
+ Text size
+ {textSizeSummary(textScale)}
+
+
+
+
+
+ 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'}
+
+
+
+
+
+
+
+
+
+ visible={pickerHost != null}
+ title={pickerHost ? `Restore ${pickerHost.name}` : ''}
+ options={AUTO_RESTORE_FIT_OPTIONS}
+ selected={valueFromMs(pickerHost ? hostMs[pickerHost.id] : null)}
+ onSelect={(v) => {
+ if (pickerHost) {
+ void selectValue(pickerHost.id, v)
+ }
+ }}
+ onClose={() => setPickerHostId(null)}
+ />
+
+
+ visible={textSizePickerOpen}
+ title="Terminal text size"
+ options={TEXT_SIZE_OPTIONS}
+ selected={textSizeValueFromScale(textScale)}
+ onSelect={selectTextSize}
+ onClose={() => setTextSizePickerOpen(false)}
+ />
+
+ )
+}
diff --git a/mobile/src/terminal/use-terminal-settings-state.test.ts b/mobile/src/terminal/use-terminal-settings-state.test.ts
new file mode 100644
index 00000000000..a508422b4ef
--- /dev/null
+++ b/mobile/src/terminal/use-terminal-settings-state.test.ts
@@ -0,0 +1,100 @@
+import { createElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useTerminalSettingsState } from './use-terminal-settings-state'
+import type {
+ TerminalSettingsOperations,
+ TerminalSettingsHost
+} from './terminal-settings-operations'
+
+let renderer: ReactTestRenderer | undefined
+let state: ReturnType
+afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = undefined
+})
+function fixture() {
+ return {
+ loadPreferences: vi.fn().mockResolvedValue({ textScale: 1.25, autocompleteEnabled: true }),
+ saveTextScale: vi.fn().mockResolvedValue(undefined),
+ saveAutocomplete: vi.fn().mockResolvedValue(undefined)
+ } as unknown as TerminalSettingsOperations
+}
+async function mount(operations: TerminalSettingsOperations, hosts: TerminalSettingsHost[] = []) {
+ function Harness() {
+ state = useTerminalSettingsState(hosts, operations)
+ return null
+ }
+ await act(async () => {
+ renderer = create(createElement(Harness))
+ })
+}
+function deferred() {
+ let resolve!: (value: T) => void
+ const promise = new Promise((done) => {
+ resolve = done
+ })
+ return { resolve, promise }
+}
+describe('terminal settings state', () => {
+ it('does not enable preferences until storage loads or show a change before it saves', async () => {
+ const load = deferred<{ textScale: number; autocompleteEnabled: boolean }>()
+ const save = deferred()
+ const operations = fixture()
+ vi.mocked(operations.loadPreferences).mockReturnValue(load.promise)
+ vi.mocked(operations.saveAutocomplete).mockReturnValue(save.promise)
+ await mount(operations)
+ expect(state.busy).toBe(true)
+ await act(async () => {
+ load.resolve({ textScale: 1.5, autocompleteEnabled: false })
+ })
+ expect(state.textScale).toBe(1.5)
+ expect(state.busy).toBe(false)
+ act(() => state.toggleAutocomplete(true))
+ expect(state.busy).toBe(true)
+ expect(state.autocompleteEnabled).toBe(false)
+ await act(async () => {
+ save.resolve()
+ })
+ expect(state.busy).toBe(false)
+ expect(state.autocompleteEnabled).toBe(true)
+ })
+ it('keeps controls disabled when storage cannot be read', async () => {
+ const operations = fixture()
+ vi.mocked(operations.loadPreferences).mockRejectedValue(new Error('storage unavailable'))
+ await mount(operations)
+ expect(state.busy).toBe(true)
+ expect(state.error).toContain('Could not load')
+ })
+ it('preserves the confirmed setting when saving fails', async () => {
+ const operations = fixture()
+ vi.mocked(operations.saveTextScale).mockRejectedValue(new Error('storage unavailable'))
+ await mount(operations)
+ await act(async () => {
+ state.selectTextSize('largest')
+ })
+ expect(state.textScale).toBe(1.25)
+ expect(state.busy).toBe(false)
+ expect(state.error).toContain('Could not save')
+ })
+ it('reflects the host acknowledgement and never retries failed host mutations', async () => {
+ const host = {
+ id: 'host',
+ name: 'Desktop',
+ loadFit: vi.fn().mockResolvedValue(null),
+ saveFit: vi.fn().mockResolvedValue(75000)
+ }
+ await mount(fixture(), [host])
+ await act(async () => {
+ await state.selectValue('host', '60s')
+ })
+ expect(state.hostMs.host).toBe(75000)
+ host.saveFit.mockRejectedValue(new Error('connection lost'))
+ await act(async () => {
+ await state.selectValue('host', '5m')
+ })
+ expect(state.hostMs.host).toBe(75000)
+ expect(host.saveFit).toHaveBeenCalledTimes(2)
+ expect(state.error).toContain('Could not save')
+ })
+})
diff --git a/mobile/src/terminal/use-terminal-settings-state.ts b/mobile/src/terminal/use-terminal-settings-state.ts
new file mode 100644
index 00000000000..c0fb266eed1
--- /dev/null
+++ b/mobile/src/terminal/use-terminal-settings-state.ts
@@ -0,0 +1,137 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import type {
+ TerminalSettingsHost,
+ TerminalSettingsOperations
+} from './terminal-settings-operations'
+import {
+ AUTO_RESTORE_FIT_OPTIONS,
+ TEXT_SIZE_OPTIONS,
+ type RestoreValue,
+ type TextSizeValue
+} from './terminal-settings-options'
+import { setTerminalAutoRestoreFitMsForHost } from './terminal-auto-restore-fit-state'
+
+export function useTerminalSettingsState(
+ hosts: TerminalSettingsHost[],
+ operations: TerminalSettingsOperations
+) {
+ const [hostMs, setHostMs] = useState>({})
+ const [pickerHostId, setPickerHostId] = useState(null)
+ const [textScale, setTextScale] = useState(1)
+ const [textSizePickerOpen, setTextSizePickerOpen] = useState(false)
+ const [autocompleteEnabled, setAutocompleteEnabled] = useState(false)
+ const [busy, setBusy] = useState(true)
+ const [error, setError] = useState(null)
+ const mounted = useRef(false)
+ useEffect(() => {
+ let active = true
+ mounted.current = true
+ setBusy(true)
+ void operations
+ .loadPreferences()
+ .then((preferences) => {
+ if (!active) {
+ return
+ }
+ setTextScale(preferences.textScale)
+ setAutocompleteEnabled(preferences.autocompleteEnabled)
+ setBusy(false)
+ })
+ .catch(() => {
+ if (active) {
+ setError('Could not load terminal preferences. Go back and try again.')
+ }
+ })
+ return () => {
+ active = false
+ mounted.current = false
+ }
+ }, [operations])
+ useEffect(() => {
+ let active = true
+ for (const host of hosts) {
+ void host
+ .loadFit()
+ .then((ms) => {
+ if (active) {
+ setHostMs((current) => setTerminalAutoRestoreFitMsForHost(current, host.id, ms))
+ }
+ })
+ .catch(() => {
+ if (active) {
+ setError('Could not load terminal restore preferences. Reconnect and try again.')
+ }
+ })
+ }
+ return () => {
+ active = false
+ }
+ }, [hosts])
+ const save = useCallback(async (work: () => Promise, complete: (result: T) => void) => {
+ setBusy(true)
+ setError(null)
+ try {
+ const result = await work()
+ if (mounted.current) {
+ complete(result)
+ }
+ } catch {
+ if (mounted.current) {
+ setError('Could not save terminal preferences. Try again.')
+ }
+ } finally {
+ if (mounted.current) {
+ setBusy(false)
+ }
+ }
+ }, [])
+ const selectTextSize = useCallback(
+ (value: TextSizeValue) => {
+ const option = TEXT_SIZE_OPTIONS.find((entry) => entry.value === value)
+ if (option) {
+ void save(
+ () => operations.saveTextScale(option.scale),
+ () => setTextScale(option.scale)
+ )
+ }
+ },
+ [operations, save]
+ )
+ const toggleAutocomplete = useCallback(
+ (enabled: boolean) => {
+ void save(
+ () => operations.saveAutocomplete(enabled),
+ () => setAutocompleteEnabled(enabled)
+ )
+ },
+ [operations, save]
+ )
+ const selectValue = useCallback(
+ async (hostId: string, value: RestoreValue) => {
+ const host = hosts.find((entry) => entry.id === hostId)
+ const option = AUTO_RESTORE_FIT_OPTIONS.find((entry) => entry.value === value)
+ if (!host || !option) {
+ return
+ }
+ await save(
+ () => host.saveFit(option.ms),
+ (ms) => setHostMs((current) => setTerminalAutoRestoreFitMsForHost(current, hostId, ms))
+ )
+ },
+ [hosts, save]
+ )
+ return {
+ hostMs,
+ pickerHostId,
+ setPickerHostId,
+ textScale,
+ textSizePickerOpen,
+ setTextSizePickerOpen,
+ autocompleteEnabled,
+ selectTextSize,
+ toggleAutocomplete,
+ selectValue,
+ busy,
+ error
+ }
+}
diff --git a/mobile/src/terminal/web-terminal-preferences.ts b/mobile/src/terminal/web-terminal-preferences.ts
new file mode 100644
index 00000000000..c6c28dae476
--- /dev/null
+++ b/mobile/src/terminal/web-terminal-preferences.ts
@@ -0,0 +1,40 @@
+import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
+import type { MobileWebTerminalTextScale } from '../../../src/shared/mobile-web/native-operation-contract'
+import {
+ loadTerminalAutocompleteEnabled,
+ loadTerminalTextScale,
+ loadTerminalLinkOpenMode
+} from '../storage/preferences'
+import { loadCustomKeys } from '../storage/terminal-custom-key-storage'
+import { loadTerminalAccessoryLayout } from './terminal-accessory-layout'
+
+export async function loadWebHostTerminalPreferences(client: MobileWebBridgeClient) {
+ const native = await client.native.terminalPreferences()
+ if (!client.native.supports('pagePreferences')) {
+ return native
+ }
+ const [textScale, autocompleteEnabled, linkOpenMode] = await Promise.all([
+ loadTerminalTextScale({ fallback: native.textScale, rejectReadFailure: true }),
+ loadTerminalAutocompleteEnabled({
+ fallback: native.autocompleteEnabled,
+ rejectReadFailure: true
+ }),
+ loadTerminalLinkOpenMode(native.linkOpenMode)
+ ])
+ return { textScale: textScale as MobileWebTerminalTextScale, autocompleteEnabled, linkOpenMode }
+}
+export async function loadWebHostTerminalAccessoryPreferences(client: MobileWebBridgeClient) {
+ const native = await client.native.terminalAccessoryPreferences()
+ if (!client.native.supports('pagePreferences')) {
+ return native
+ }
+ const [customKeys, layout] = await Promise.all([
+ loadCustomKeys({ fallback: native.customKeys, rejectReadFailure: true }),
+ loadTerminalAccessoryLayout({ fallback: native, rejectReadFailure: true })
+ ])
+ return {
+ customKeys,
+ orderedBuiltInIds: layout.orderedBuiltInIds,
+ visibleBuiltInIds: layout.visibleBuiltInIds
+ }
+}
diff --git a/mobile/src/terminal/web-terminal-settings-operations.ts b/mobile/src/terminal/web-terminal-settings-operations.ts
new file mode 100644
index 00000000000..01b16e0c9e2
--- /dev/null
+++ b/mobile/src/terminal/web-terminal-settings-operations.ts
@@ -0,0 +1,59 @@
+import type { MobileWebBridgeClient } from '../../../src/mobile-web/src/mobile-web-bridge-client'
+import { MOBILE_WEB_SHELL_HOST_SCOPE_FEATURE } from '../../../src/shared/mobile-web/shell-feature-contract'
+import {
+ nativeTerminalSettingsOperations,
+ type TerminalSettingsHost,
+ type TerminalSettingsOperations
+} from './terminal-settings-operations'
+import {
+ loadWebHostTerminalAccessoryPreferences,
+ loadWebHostTerminalPreferences
+} from './web-terminal-preferences'
+
+export function webTerminalSettingsOperations(
+ client: MobileWebBridgeClient
+): TerminalSettingsOperations {
+ let accessoryRead: ReturnType | undefined
+ const readAccessories = () => {
+ // The two settings sections share the shell's single accessory-read slot.
+ accessoryRead ??= loadWebHostTerminalAccessoryPreferences(client).finally(() => {
+ accessoryRead = undefined
+ })
+ return accessoryRead
+ }
+ return {
+ ...nativeTerminalSettingsOperations,
+ loadPreferences: () => loadWebHostTerminalPreferences(client),
+ loadKeys: async () => (await readAccessories()).customKeys,
+ loadLayout: readAccessories
+ }
+}
+const fitMethods = ['terminal.getAutoRestoreFit', 'terminal.setAutoRestoreFit']
+export async function webTerminalSettingsHost(
+ client: MobileWebBridgeClient
+): Promise {
+ if (!client.supportsShellFeature(MOBILE_WEB_SHELL_HOST_SCOPE_FEATURE)) {
+ return null
+ }
+ const catalog = await client.host.catalog(fitMethods)
+ if (
+ !fitMethods.every((method) =>
+ catalog.grants.some((grant) => grant.method === method && grant.scope === 'host')
+ )
+ ) {
+ return null
+ }
+ async function request(method: string, params: Record = {}) {
+ const result = (await client.host.request({ method, params })) as { ms?: unknown } | null
+ if (result?.ms !== null && (typeof result?.ms !== 'number' || !Number.isFinite(result.ms))) {
+ throw new Error('Invalid terminal restore preference')
+ }
+ return result.ms as number | null
+ }
+ return {
+ id: 'paired-host',
+ name: 'Paired desktop',
+ loadFit: () => request(fitMethods[0]!),
+ saveFit: (ms) => request(fitMethods[1]!, { ms })
+ }
+}