feat(mobile): drag-to-reorder terminal shortcut keys in settings (#5076)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-11 14:05:07 -07:00 committed by GitHub
parent 64072f0128
commit 469cbe387d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1288 additions and 332 deletions

View File

@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.12",
"version": "0.0.13",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",

View File

@ -81,6 +81,7 @@ import {
isTerminalLiveInputWithinByteLimit,
scheduleTerminalLiveInputFocus
} from '../../../../src/terminal/terminal-live-input'
import { normalizeTerminalTextInput } from '../../../../src/terminal/terminal-text-input-normalization'
import { countTerminalGestureInputSequences } from '../../../../src/terminal/terminal-gesture-input'
import { MobileBrowserPane, type MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane'
import { isBlankBrowserUrl, normalizeBrowserUrl } from '../../../../src/browser/browser-url'
@ -2663,7 +2664,7 @@ export default function SessionScreen() {
}
sendingRef.current = true
const text = input
const text = normalizeTerminalTextInput(input)
setInput('')
try {
@ -2706,10 +2707,11 @@ export default function SessionScreen() {
const sendLiveTerminalInput = useCallback(
(handle: string, bytes: string) => {
if (bytes.length === 0) {
const text = normalizeTerminalTextInput(bytes)
if (text.length === 0) {
return
}
if (!isTerminalLiveInputWithinByteLimit(bytes)) {
if (!isTerminalLiveInputWithinByteLimit(text)) {
triggerError()
showToast('Input too large (max 256 KiB)', 1500)
return
@ -2726,7 +2728,7 @@ export default function SessionScreen() {
void rpc
.sendRequest('terminal.send', {
terminal: handle,
text: bytes,
text,
enter: false,
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
@ -2791,8 +2793,9 @@ export default function SessionScreen() {
liveInputRef.current?.setNativeProps({ text: '' })
return
}
if (text.length > 0) {
sendLiveTerminalInput(activeHandle, text)
const normalizedText = normalizeTerminalTextInput(text)
if (normalizedText.length > 0) {
sendLiveTerminalInput(activeHandle, normalizedText)
}
setLiveInputCapture('')
// Why: the field is only a keyboard capture surface. Clearing the
@ -3859,11 +3862,16 @@ export default function SessionScreen() {
{visibleTabs.length > 0 && (
<View style={styles.tabBar}>
{/* Why: tab taps must register on the first press while the live
keyboard is open instead of being eaten by keyboard dismissal
(#5106); leaving a non-live tab still closes the keyboard
because the live input unmounts. */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabScroll}
contentContainerStyle={styles.tabContent}
keyboardShouldPersistTaps="handled"
>
{visibleTabs.map((t) => (
<Pressable
@ -4099,10 +4107,14 @@ export default function SessionScreen() {
>
{/* Accessory keys */}
<View style={styles.accessoryBar}>
{/* Why: with default tap handling the first tap on any accessory
key dismisses the open keyboard and is swallowed, so live
input lost its keyboard on every Esc/Tab press (#5106). */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.accessoryContent}
keyboardShouldPersistTaps="always"
>
<Pressable
style={({ pressed }) => [
@ -4270,6 +4282,7 @@ export default function SessionScreen() {
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
smartInsertDelete={false}
keyboardType={Platform.OS === 'ios' ? 'ascii-capable' : 'visible-password'}
returnKeyType="default"
blurOnSubmit={false}
@ -4283,11 +4296,13 @@ export default function SessionScreen() {
<TextInput
style={styles.textInput}
value={input}
onChangeText={setInput}
onChangeText={(text) => setInput(normalizeTerminalTextInput(text))}
placeholder="Type a command…"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
smartInsertDelete={false}
returnKeyType="send"
editable={canSend}
onSubmitEditing={() => void handleSend()}
@ -4354,6 +4369,7 @@ export default function SessionScreen() {
visible={showCreateTabDrawer}
title="New Tab"
actions={[
...createTabAgentActions,
{
label: 'Terminal',
icon: SquareTerminal,
@ -4381,8 +4397,7 @@ export default function SessionScreen() {
setShowCreateTabDrawer(false)
void handleCreateMarkdownNote()
}
},
...createTabAgentActions
}
]}
onClose={() => setShowCreateTabDrawer(false)}
/>

View File

@ -1,40 +1,21 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
AppState,
View,
Text,
StyleSheet,
Pressable,
ScrollView,
Switch,
type AppStateStatus
} from 'react-native'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { View, Text, StyleSheet, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Smartphone, X } from 'lucide-react-native'
import {
CustomKeyModal,
loadCustomKeys,
saveCustomKeys,
type CustomKey
} from '../src/components/CustomKeyModal'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import Animated, {
useAnimatedRef,
useAnimatedScrollHandler,
useSharedValue
} from 'react-native-reanimated'
import { useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Smartphone } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useAllHostClients } from '../src/transport/client-context'
import type { RpcClient } from '../src/transport/rpc-client'
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
import {
TERMINAL_ACCESSORY_KEYS,
type TerminalAccessoryKey
} from '../src/terminal/terminal-accessory-keys'
import {
getDefaultTerminalAccessoryBuiltInIds,
loadTerminalAccessoryLayout,
resetTerminalAccessoryBuiltInIds,
saveTerminalAccessoryLayout,
setTerminalAccessoryBuiltInVisible
} from '../src/terminal/terminal-accessory-layout'
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state'
type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
@ -111,33 +92,6 @@ function HostFitRow({
)
}
function ShortcutBarRow({
shortcutKey,
visible,
onToggle
}: {
shortcutKey: TerminalAccessoryKey
visible: boolean
onToggle: (visible: boolean) => void
}): React.JSX.Element {
return (
<View style={styles.row}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{shortcutKey.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{shortcutKey.accessibilityLabel ?? shortcutKey.label}</Text>
</View>
<Switch
value={visible}
onValueChange={onToggle}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
)
}
export default function TerminalSettingsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
@ -152,9 +106,6 @@ export default function TerminalSettingsScreen() {
[hostClients]
)
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
// 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
@ -162,81 +113,6 @@ export default function TerminalSettingsScreen() {
// drawer appear cut-off.
const [hostMs, setHostMs] = useState<Record<string, number | null | undefined>>({})
const [pickerHostId, setPickerHostId] = useState<string | null>(null)
const [visibleBuiltInIds, setVisibleBuiltInIds] = useState<string[]>(
getDefaultTerminalAccessoryBuiltInIds
)
const layoutWriteChainRef = useRef<Promise<void>>(Promise.resolve())
const layoutWriteSeqRef = useRef(0)
const pendingLayoutWritesRef = useRef(0)
const persistLayout = useCallback((nextIds: string[]) => {
layoutWriteSeqRef.current += 1
pendingLayoutWritesRef.current += 1
layoutWriteChainRef.current = layoutWriteChainRef.current
.catch(() => {})
.then(() => saveTerminalAccessoryLayout(nextIds))
.catch(() => {})
.finally(() => {
pendingLayoutWritesRef.current -= 1
})
}, [])
const refreshShortcutLayout = useCallback(() => {
const refreshSeq = layoutWriteSeqRef.current
void loadTerminalAccessoryLayout().then((layout) => {
if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) {
return
}
setVisibleBuiltInIds(layout.visibleBuiltInIds)
})
}, [])
const refreshCustomKeys = useCallback(() => {
void loadCustomKeys().then(setCustomKeys)
}, [])
const handleDeleteCustomKey = useCallback(
async (key: CustomKey) => {
const updated = customKeys.filter((k) => k.id !== key.id)
setCustomKeys(updated)
await saveCustomKeys(updated)
},
[customKeys]
)
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) => {
setVisibleBuiltInIds((current) => {
const next = setTerminalAccessoryBuiltInVisible(current, id, visible)
persistLayout(next)
return next
})
},
[persistLayout]
)
const resetBuiltInKeys = useCallback(() => {
const next = resetTerminalAccessoryBuiltInIds()
setVisibleBuiltInIds(next)
persistLayout(next)
}, [persistLayout])
useEffect(() => {
let cancelled = false
@ -295,10 +171,28 @@ export default function TerminalSettingsScreen() {
}
const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null
const visibleBuiltInSet = useMemo(() => new Set(visibleBuiltInIds), [visibleBuiltInIds])
const scrollRef = useAnimatedRef<Animated.ScrollView>()
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 (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<GestureHandlerRootView style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
@ -306,7 +200,16 @@ export default function TerminalSettingsScreen() {
<Text style={styles.heading}>Terminal</Text>
</View>
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
<Animated.ScrollView
ref={scrollRef}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
onContentSizeChange={(_width, height) => {
scrollContentHeight.value = height
}}
>
<Text style={styles.groupHeading}>WHEN YOU LEAVE THE APP</Text>
<Text style={styles.groupDescription}>
While you&apos;re using a terminal on your phone, Orca shrinks it to fit your screen. When
@ -340,76 +243,13 @@ export default function TerminalSettingsScreen() {
</View>
)}
<Text style={[styles.groupHeading, styles.groupTopGap]}>SHORTCUT BAR</Text>
<View style={[styles.section, styles.sectionTopGap]}>
{TERMINAL_ACCESSORY_KEYS.map((shortcutKey, idx) => (
<View key={shortcutKey.id}>
{idx > 0 && <View style={styles.separator} />}
<ShortcutBarRow
shortcutKey={shortcutKey}
visible={visibleBuiltInSet.has(shortcutKey.id)}
onToggle={(visible) => toggleBuiltInKey(shortcutKey.id, visible)}
/>
</View>
))}
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={resetBuiltInKeys}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Reset Defaults</Text>
<Text style={styles.rowSublabel}>Show every built-in shortcut key</Text>
</View>
</Pressable>
</View>
<Text style={[styles.groupHeading, styles.groupTopGap]}>CUSTOM SHORTCUTS</Text>
<View style={[styles.section, styles.sectionTopGap]}>
{customKeys.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No custom shortcuts defined yet.</Text>
</View>
) : (
customKeys.map((key, idx) => (
<View key={key.id}>
{idx > 0 && <View style={styles.separator} />}
<View style={styles.row}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{key.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{key.label}</Text>
<Text style={styles.rowSublabel} numberOfLines={1} ellipsizeMode="tail">
{key.bytes.replace(/\r/g, ' ↵')}
</Text>
</View>
<Pressable
style={({ pressed }) => [
styles.deleteButton,
pressed && styles.deleteButtonPressed
]}
onPress={() => handleDeleteCustomKey(key)}
>
<X size={16} color={colors.statusRed} />
</Pressable>
</View>
</View>
))
)}
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setShowCustomKeyModal(true)}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Add Custom Shortcut</Text>
<Text style={styles.rowSublabel}>Create key combo or text macro</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
</ScrollView>
<TerminalShortcutSettings
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
scrollContentHeight={scrollContentHeight}
onDragActiveChange={handleDragActiveChange}
/>
</Animated.ScrollView>
<PickerModal<RestoreValue>
visible={pickerHost != null}
@ -423,15 +263,7 @@ export default function TerminalSettingsScreen() {
}}
onClose={() => setPickerHostId(null)}
/>
<CustomKeyModal
visible={showCustomKeyModal}
onClose={() => setShowCustomKeyModal(false)}
onKeysChanged={(keys) => {
setCustomKeys(keys)
}}
/>
</View>
</GestureHandlerRootView>
)
}
@ -472,9 +304,6 @@ const styles = StyleSheet.create({
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupTopGap: {
marginTop: spacing.xl
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
@ -517,38 +346,9 @@ const styles = StyleSheet.create({
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'
},
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)'
}
})

View File

@ -231,7 +231,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
onPress={onManageShortcuts}
>
<Text style={styles.rowLabel}>Manage Shortcuts</Text>
<Text style={styles.rowHint}>Show or hide default shortcut keys</Text>
<Text style={styles.rowHint}>Show, hide, or reorder shortcut keys</Text>
</Pressable>
</>
) : null}

View File

@ -0,0 +1,349 @@
import { useCallback, useEffect, type ReactNode } from 'react'
import { StyleSheet, View } from 'react-native'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { GripVertical } from 'lucide-react-native'
import Animated, {
measure,
runOnJS,
scrollTo,
useAnimatedStyle,
useFrameCallback,
useSharedValue,
withSpring,
type AnimatedRef,
type SharedValue
} from 'react-native-reanimated'
import { colors, spacing } from '../theme/mobile-theme'
import { triggerMediumImpact, triggerSelection } from '../platform/haptics'
import {
clampDragReorderIndex,
dragReorderPositionsFromKeys,
moveDragReorderKey,
orderedKeysFromDragReorderPositions,
type DragReorderPositions
} from './drag-reorder-positions'
const ROW_SPRING = { damping: 28, stiffness: 350 }
const LONG_PRESS_ACTIVATION_MS = 200
// Why: joins row keys into a change-detection signature; NUL cannot occur in
// a key, so the joined string is unambiguous.
const KEY_SEPARATOR = '\u0000'
// Why: drags near the viewport edges scroll the outer ScrollView so rows can
// travel further than one screen; speed ramps up the closer the finger gets.
const AUTO_SCROLL_EDGE = 72
const AUTO_SCROLL_MAX_SPEED = 560
type DragSharedState = {
positions: SharedValue<DragReorderPositions>
activeKey: SharedValue<string | null>
activeTop: SharedValue<number>
dragStartTop: SharedValue<number>
dragStartScrollY: SharedValue<number>
dragTranslationY: SharedValue<number>
dragPointerAbsY: SharedValue<number>
}
export type DragReorderListProps<ItemT> = {
items: ItemT[]
itemKey: (item: ItemT) => string
rowHeight: number
renderRow: (item: ItemT) => ReactNode
/** Called with every item key in the new order after a drop changes it. */
onReorder: (orderedKeys: string[]) => void
/** Lets the owning screen disable its ScrollView while a row is held. */
onDragActiveChange?: (active: boolean) => void
scrollRef: AnimatedRef<Animated.ScrollView>
scrollOffsetY: SharedValue<number>
scrollContentHeight: SharedValue<number>
}
export function DragReorderList<ItemT>({
items,
itemKey,
rowHeight,
renderRow,
onReorder,
onDragActiveChange,
scrollRef,
scrollOffsetY,
scrollContentHeight
}: DragReorderListProps<ItemT>): React.JSX.Element {
const keys = items.map(itemKey)
const count = keys.length
const positions = useSharedValue<DragReorderPositions>(dragReorderPositionsFromKeys(keys))
const activeKey = useSharedValue<string | null>(null)
const activeTop = useSharedValue(0)
const dragStartTop = useSharedValue(0)
const dragStartScrollY = useSharedValue(0)
const dragTranslationY = useSharedValue(0)
const dragPointerAbsY = useSharedValue(0)
// Why: rows can be added, removed, or reordered by the owning screen;
// rebuild the position map whenever the rendered key order changes.
const keySignature = keys.join(KEY_SEPARATOR)
useEffect(() => {
positions.value = dragReorderPositionsFromKeys(
keySignature ? keySignature.split(KEY_SEPARATOR) : []
)
}, [keySignature, positions])
const updateDragPosition = (key: string): void => {
'worklet'
const rawTop =
dragStartTop.value + dragTranslationY.value + (scrollOffsetY.value - dragStartScrollY.value)
const top = Math.min(Math.max(rawTop, 0), Math.max(0, (count - 1) * rowHeight))
activeTop.value = top
const target = clampDragReorderIndex(Math.round(top / rowHeight), count)
if (positions.value[key] !== target) {
positions.value = moveDragReorderKey(positions.value, key, target)
runOnJS(triggerSelection)()
}
}
// Why: pan updates stop while the finger holds still at a screen edge, so a
// frame callback keeps scrolling (and re-slotting the row) until it moves.
const autoScroll = useFrameCallback((frame) => {
const key = activeKey.value
if (key === null) {
return
}
const viewport = measure(scrollRef)
if (viewport) {
const topEdge = viewport.pageY + AUTO_SCROLL_EDGE
const bottomEdge = viewport.pageY + viewport.height - AUTO_SCROLL_EDGE
let velocity = 0
if (dragPointerAbsY.value < topEdge) {
velocity =
-AUTO_SCROLL_MAX_SPEED * Math.min(1, (topEdge - dragPointerAbsY.value) / AUTO_SCROLL_EDGE)
} else if (dragPointerAbsY.value > bottomEdge) {
velocity =
AUTO_SCROLL_MAX_SPEED *
Math.min(1, (dragPointerAbsY.value - bottomEdge) / AUTO_SCROLL_EDGE)
}
if (velocity !== 0) {
const maxOffset = Math.max(0, scrollContentHeight.value - viewport.height)
const dtMs = frame.timeSincePreviousFrame ?? 16
const next = Math.min(
Math.max(scrollOffsetY.value + (velocity * dtMs) / 1000, 0),
maxOffset
)
if (next !== scrollOffsetY.value) {
scrollOffsetY.value = next
scrollTo(scrollRef, 0, next, false)
}
}
}
updateDragPosition(key)
}, false)
const setAutoScrollActive = autoScroll.setActive
const handleDragActiveChange = useCallback(
(active: boolean) => {
setAutoScrollActive(active)
onDragActiveChange?.(active)
},
[setAutoScrollActive, onDragActiveChange]
)
const commitReorder = useCallback(
(orderedKeys: string[]) => {
// Why: a cancelled or no-op drag should not trigger a persisted write.
if (orderedKeys.join(KEY_SEPARATOR) !== keySignature) {
onReorder(orderedKeys)
}
},
[onReorder, keySignature]
)
// Why: screen-reader users can't long-press-drag; the handle exposes
// move up/down accessibility actions that commit the same reorder.
const moveRowByAccessibilityAction = useCallback(
(key: string, delta: number) => {
const fromIndex = keys.indexOf(key)
if (fromIndex === -1) {
return
}
const toIndex = Math.min(Math.max(fromIndex + delta, 0), keys.length - 1)
if (toIndex === fromIndex) {
return
}
const next = [...keys]
next.splice(fromIndex, 1)
next.splice(toIndex, 0, key)
onReorder(next)
},
[keys, onReorder]
)
const shared: DragSharedState = {
positions,
activeKey,
activeTop,
dragStartTop,
dragStartScrollY,
dragTranslationY,
dragPointerAbsY
}
return (
<View style={{ height: count * rowHeight }}>
{items.map((item) => (
<DragReorderRow
key={itemKey(item)}
rowKey={itemKey(item)}
rowHeight={rowHeight}
shared={shared}
scrollOffsetY={scrollOffsetY}
updateDragPosition={updateDragPosition}
onDragActiveChange={handleDragActiveChange}
onCommit={commitReorder}
onAccessibilityMove={moveRowByAccessibilityAction}
>
{renderRow(item)}
</DragReorderRow>
))}
</View>
)
}
function DragReorderRow({
rowKey,
rowHeight,
shared,
scrollOffsetY,
updateDragPosition,
onDragActiveChange,
onCommit,
onAccessibilityMove,
children
}: {
rowKey: string
rowHeight: number
shared: DragSharedState
scrollOffsetY: SharedValue<number>
updateDragPosition: (key: string) => void
onDragActiveChange: (active: boolean) => void
onCommit: (orderedKeys: string[]) => void
onAccessibilityMove: (key: string, delta: number) => void
children: ReactNode
}): React.JSX.Element {
const {
positions,
activeKey,
activeTop,
dragStartTop,
dragStartScrollY,
dragTranslationY,
dragPointerAbsY
} = shared
const pan = Gesture.Pan()
.activateAfterLongPress(LONG_PRESS_ACTIVATION_MS)
.shouldCancelWhenOutside(false)
.onStart((event) => {
const index = positions.value[rowKey] ?? 0
dragStartTop.value = index * rowHeight
dragStartScrollY.value = scrollOffsetY.value
dragTranslationY.value = 0
dragPointerAbsY.value = event.absoluteY
activeTop.value = dragStartTop.value
activeKey.value = rowKey
runOnJS(onDragActiveChange)(true)
runOnJS(triggerMediumImpact)()
})
.onUpdate((event) => {
dragTranslationY.value = event.translationY
dragPointerAbsY.value = event.absoluteY
updateDragPosition(rowKey)
})
.onFinalize(() => {
if (activeKey.value !== rowKey) {
return
}
activeKey.value = null
const orderedKeys = orderedKeysFromDragReorderPositions(positions.value)
runOnJS(onCommit)(orderedKeys)
runOnJS(onDragActiveChange)(false)
})
const rowStyle = useAnimatedStyle(() => {
const index = positions.value[rowKey] ?? 0
if (activeKey.value === rowKey) {
return {
top: activeTop.value,
zIndex: 2,
elevation: 4,
shadowOpacity: 0.3,
backgroundColor: colors.bgRaised,
transform: [{ scale: 1.02 }]
}
}
return {
top: withSpring(index * rowHeight, ROW_SPRING),
zIndex: 0,
elevation: 0,
shadowOpacity: 0,
backgroundColor: colors.bgPanel,
transform: [{ scale: 1 }]
}
})
return (
<Animated.View style={[styles.row, { height: rowHeight }, rowStyle]}>
<View style={styles.rowContent}>{children}</View>
<GestureDetector gesture={pan}>
<Animated.View
style={styles.handle}
accessible
accessibilityRole="button"
accessibilityLabel="Drag to reorder"
accessibilityHint="Use the move up and move down actions to reorder without dragging"
accessibilityActions={[
{ name: 'moveUp', label: 'Move up' },
{ name: 'moveDown', label: 'Move down' }
]}
onAccessibilityAction={(event) => {
if (event.nativeEvent.actionName === 'moveUp') {
onAccessibilityMove(rowKey, -1)
} else if (event.nativeEvent.actionName === 'moveDown') {
onAccessibilityMove(rowKey, 1)
}
}}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<GripVertical size={18} color={colors.textMuted} />
</Animated.View>
</GestureDetector>
<View style={styles.rowSeparator} />
</Animated.View>
)
}
const styles = StyleSheet.create({
row: {
position: 'absolute',
left: 0,
right: 0,
flexDirection: 'row',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowRadius: 8
},
rowContent: {
flex: 1
},
handle: {
alignSelf: 'stretch',
justifyContent: 'center',
paddingHorizontal: spacing.md
},
rowSeparator: {
position: 'absolute',
bottom: 0,
left: spacing.md,
right: spacing.md,
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle
}
})

View File

@ -0,0 +1,428 @@
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 { 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 { DragReorderList } from './DragReorderList'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
TERMINAL_ACCESSORY_KEYS,
type TerminalAccessoryKey
} from '../terminal/terminal-accessory-keys'
import {
getDefaultTerminalAccessoryLayout,
loadTerminalAccessoryLayout,
reorderTerminalAccessoryBuiltInIds,
saveTerminalAccessoryLayout,
setTerminalAccessoryBuiltInVisible,
type TerminalAccessoryLayout
} from '../terminal/terminal-accessory-layout'
// Why: DragReorderList absolutely positions rows, so every row in a
// reorderable section must share one fixed height.
const REORDER_ROW_HEIGHT = 56
function ShortcutBarRow({
shortcutKey,
visible,
onToggle
}: {
shortcutKey: TerminalAccessoryKey
visible: boolean
onToggle: (visible: boolean) => void
}): React.JSX.Element {
return (
<View style={styles.reorderRowContent}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{shortcutKey.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{shortcutKey.accessibilityLabel ?? shortcutKey.label}</Text>
</View>
<Switch
value={visible}
onValueChange={onToggle}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
)
}
type Props = {
scrollRef: AnimatedRef<Animated.ScrollView>
scrollOffsetY: SharedValue<number>
scrollContentHeight: SharedValue<number>
onDragActiveChange: (active: boolean) => void
}
export function TerminalShortcutSettings({
scrollRef,
scrollOffsetY,
scrollContentHeight,
onDragActiveChange
}: Props): React.JSX.Element {
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
const [shortcutLayout, setShortcutLayout] = useState<TerminalAccessoryLayout>(
getDefaultTerminalAccessoryLayout
)
const layoutWriteChainRef = useRef<Promise<void>>(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<void>>(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])
return (
<>
<Text style={[styles.groupHeading, styles.groupTopGap]}>SHORTCUT BAR</Text>
<Text style={styles.groupDescription}>
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.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<DragReorderList
items={orderedAccessoryKeys}
itemKey={(shortcutKey) => shortcutKey.id}
rowHeight={REORDER_ROW_HEIGHT}
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
scrollContentHeight={scrollContentHeight}
onDragActiveChange={onDragActiveChange}
onReorder={reorderBuiltInKeys}
renderRow={(shortcutKey) => (
<ShortcutBarRow
shortcutKey={shortcutKey}
visible={visibleBuiltInSet.has(shortcutKey.id)}
onToggle={(visible) => toggleBuiltInKey(shortcutKey.id, visible)}
/>
)}
/>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={resetBuiltInKeys}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Reset Defaults</Text>
<Text style={styles.rowSublabel}>
Show every built-in shortcut key in the original order
</Text>
</View>
</Pressable>
</View>
<Text style={[styles.groupHeading, styles.groupTopGap]}>CUSTOM SHORTCUTS</Text>
<View style={[styles.section, styles.sectionTopGap]}>
{customKeys.length === 0 ? (
<>
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No custom shortcuts defined yet.</Text>
</View>
<View style={styles.separator} />
</>
) : (
<DragReorderList
items={customKeys}
itemKey={(key) => key.id}
rowHeight={REORDER_ROW_HEIGHT}
scrollRef={scrollRef}
scrollOffsetY={scrollOffsetY}
scrollContentHeight={scrollContentHeight}
onDragActiveChange={onDragActiveChange}
onReorder={reorderCustomKeys}
renderRow={(key) => (
<View style={styles.reorderRowContent}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{key.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{key.label}</Text>
<Text style={styles.rowSublabel} numberOfLines={1} ellipsizeMode="tail">
{key.bytes.replace(/\r/g, ' ↵')}
</Text>
</View>
<Pressable
style={({ pressed }) => [
styles.deleteButton,
pressed && styles.deleteButtonPressed
]}
onPress={() => handleDeleteCustomKey(key)}
>
<X size={16} color={colors.statusRed} />
</Pressable>
</View>
)}
/>
)}
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setShowCustomKeyModal(true)}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Add Custom Shortcut</Text>
<Text style={styles.rowSublabel}>Create key combo or text macro</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
<CustomKeyModal
visible={showCustomKeyModal}
onClose={() => setShowCustomKeyModal(false)}
onKeysChanged={(keys) => {
// Why: the modal already persisted this list; bumping the sequence
// discards refreshes that read storage before its save landed.
customKeysWriteSeqRef.current += 1
setCustomKeys(keys)
}}
/>
</>
)
}
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)'
}
})

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import {
clampDragReorderIndex,
dragReorderPositionsFromKeys,
moveDragReorderKey,
orderedKeysFromDragReorderPositions
} from './drag-reorder-positions'
describe('drag reorder positions', () => {
it('round-trips keys through positions', () => {
const keys = ['escape', 'tab', 'enter']
expect(orderedKeysFromDragReorderPositions(dragReorderPositionsFromKeys(keys))).toEqual(keys)
})
it('clamps drag indexes to the list bounds', () => {
expect(clampDragReorderIndex(-2, 3)).toBe(0)
expect(clampDragReorderIndex(1, 3)).toBe(1)
expect(clampDragReorderIndex(7, 3)).toBe(2)
expect(clampDragReorderIndex(0, 0)).toBe(0)
})
it('shifts intermediate rows down when dragging a row later', () => {
const positions = dragReorderPositionsFromKeys(['a', 'b', 'c', 'd'])
expect(orderedKeysFromDragReorderPositions(moveDragReorderKey(positions, 'a', 2))).toEqual([
'b',
'c',
'a',
'd'
])
})
it('shifts intermediate rows up when dragging a row earlier', () => {
const positions = dragReorderPositionsFromKeys(['a', 'b', 'c', 'd'])
expect(orderedKeysFromDragReorderPositions(moveDragReorderKey(positions, 'd', 1))).toEqual([
'a',
'd',
'b',
'c'
])
})
it('returns the same positions for no-op or unknown moves', () => {
const positions = dragReorderPositionsFromKeys(['a', 'b'])
expect(moveDragReorderKey(positions, 'a', 0)).toBe(positions)
expect(moveDragReorderKey(positions, 'missing', 1)).toBe(positions)
})
})

View File

@ -0,0 +1,54 @@
// Index math for DragReorderList. Kept worklet-safe (no captures, plain
// objects) because moveDragReorderKey runs on the UI thread during a drag.
export type DragReorderPositions = Record<string, number>
export function dragReorderPositionsFromKeys(keys: string[]): DragReorderPositions {
'worklet'
const positions: DragReorderPositions = {}
for (let i = 0; i < keys.length; i++) {
positions[keys[i]!] = i
}
return positions
}
export function orderedKeysFromDragReorderPositions(positions: DragReorderPositions): string[] {
'worklet'
const keys = Object.keys(positions)
keys.sort((a, b) => positions[a]! - positions[b]!)
return keys
}
export function clampDragReorderIndex(index: number, count: number): number {
'worklet'
if (count <= 0) {
return 0
}
return Math.min(Math.max(index, 0), count - 1)
}
export function moveDragReorderKey(
positions: DragReorderPositions,
key: string,
toIndex: number
): DragReorderPositions {
'worklet'
const fromIndex = positions[key]
if (fromIndex === undefined || fromIndex === toIndex) {
return positions
}
const next: DragReorderPositions = {}
for (const currentKey of Object.keys(positions)) {
const position = positions[currentKey]!
if (currentKey === key) {
next[currentKey] = toIndex
} else if (fromIndex < toIndex && position > fromIndex && position <= toIndex) {
next[currentKey] = position - 1
} else if (toIndex < fromIndex && position >= toIndex && position < fromIndex) {
next[currentKey] = position + 1
} else {
next[currentKey] = position
}
}
return next
}

View File

@ -28,4 +28,18 @@ describe('mobile session startup', () => {
expect(autoCreateEffect).toContain("setCreateError('')")
expect(autoCreateEffect).toContain('void handleCreateTerminal()')
})
it('keeps dynamic agent rows above fixed New Tab actions', () => {
const newTabActions = sliceBetween('title="New Tab"', 'onClose={() => setShowCreateTabDrawer')
expect(newTabActions.indexOf('...createTabAgentActions')).toBeLessThan(
newTabActions.indexOf("label: 'Terminal'")
)
expect(newTabActions.indexOf("label: 'Terminal'")).toBeLessThan(
newTabActions.indexOf("label: 'Browser'")
)
expect(newTabActions.indexOf("label: 'Browser'")).toBeLessThan(
newTabActions.indexOf("label: 'Markdown Note'")
)
})
})

View File

@ -4,10 +4,11 @@ import {
TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY,
createTerminalAccessoryLayoutPreference,
getDefaultTerminalAccessoryBuiltInIds,
getDefaultTerminalAccessoryLayout,
getVisibleTerminalAccessoryKeys,
loadTerminalAccessoryLayout,
normalizeTerminalAccessoryLayoutPreference,
resetTerminalAccessoryBuiltInIds,
reorderTerminalAccessoryBuiltInIds,
saveTerminalAccessoryLayout,
setTerminalAccessoryBuiltInVisible
} from './terminal-accessory-layout'
@ -45,6 +46,13 @@ describe('terminal accessory layout', () => {
)
})
it('default layout shows every built-in in canonical order', () => {
expect(getDefaultTerminalAccessoryLayout()).toEqual({
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
visibleBuiltInIds: getDefaultTerminalAccessoryBuiltInIds()
})
})
it('normalizes invalid storage to defaults', () => {
expect(normalizeTerminalAccessoryLayoutPreference(null).visibleBuiltInIds).toEqual(
getDefaultTerminalAccessoryBuiltInIds()
@ -55,31 +63,112 @@ describe('terminal accessory layout', () => {
visibleBuiltInIds: ['escape']
}).visibleBuiltInIds
).toEqual(getDefaultTerminalAccessoryBuiltInIds())
expect(
normalizeTerminalAccessoryLayoutPreference({
version: 2,
visibleBuiltInIds: ['escape']
}).visibleBuiltInIds
).toEqual(getDefaultTerminalAccessoryBuiltInIds())
})
it('returns defaults for corrupt or unreadable storage', async () => {
asyncStorageMock.getItem.mockResolvedValueOnce('{')
await expect(loadTerminalAccessoryLayout()).resolves.toEqual(
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds())
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryLayout())
)
asyncStorageMock.getItem.mockRejectedValueOnce(new Error('unreadable'))
await expect(loadTerminalAccessoryLayout()).resolves.toEqual(
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds())
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryLayout())
)
})
it('ignores removed ids and de-dupes visible ids', () => {
it('preserves a custom v2 order and its visible subset', () => {
const reversed = [...getDefaultTerminalAccessoryBuiltInIds()].reverse()
expect(
normalizeTerminalAccessoryLayoutPreference({
version: 2,
orderedBuiltInIds: reversed,
visibleBuiltInIds: ['tab', 'escape']
})
).toEqual({
version: 2,
orderedBuiltInIds: reversed,
visibleBuiltInIds: ['tab', 'escape']
})
})
it('ignores removed ids and de-dupes ids in v2 storage', () => {
const current = ['escape', 'tab', 'enter']
expect(
normalizeTerminalAccessoryLayoutPreference(
{
version: 2,
orderedBuiltInIds: ['tab', 'removed', 'tab', 'escape', 'enter'],
visibleBuiltInIds: ['escape', 'removed', 'escape', 'tab']
},
current
)
).toEqual({
version: 2,
orderedBuiltInIds: ['tab', 'escape', 'enter'],
visibleBuiltInIds: ['tab', 'escape']
})
})
it('inserts new built-ins next to their canonical neighbors in a custom order', () => {
const current = ['escape', 'tab', 'space', 'enter']
expect(
normalizeTerminalAccessoryLayoutPreference(
{
version: 2,
orderedBuiltInIds: ['enter', 'tab', 'escape'],
visibleBuiltInIds: ['enter', 'escape']
},
current
)
).toEqual({
version: 2,
// Why asserted: 'space' follows its canonical predecessor 'tab' even
// though the user moved 'tab' into the middle of the bar.
orderedBuiltInIds: ['enter', 'tab', 'space', 'escape'],
visibleBuiltInIds: ['enter', 'space', 'escape']
})
})
it('puts a new built-in with no surviving predecessor at the front', () => {
const current = ['escape', 'tab', 'enter']
expect(
normalizeTerminalAccessoryLayoutPreference(
{
version: 2,
orderedBuiltInIds: ['enter', 'tab'],
visibleBuiltInIds: ['enter']
},
current
).orderedBuiltInIds
).toEqual(['escape', 'enter', 'tab'])
})
it('migrates v1 layouts to canonical order', () => {
expect(
normalizeTerminalAccessoryLayoutPreference({
version: 1,
visibleBuiltInIds: ['escape', 'removed', 'escape', 'tab'],
visibleBuiltInIds: ['tab', 'escape'],
knownBuiltInIds: getDefaultTerminalAccessoryBuiltInIds()
}).visibleBuiltInIds
).toEqual(['escape', 'tab'])
})
).toEqual({
version: 2,
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
visibleBuiltInIds: ['escape', 'tab']
})
})
it('appends new defaults only when absent from known ids', () => {
it('appends new defaults only when absent from v1 known ids', () => {
const current = ['escape', 'tab', 'enter']
expect(
@ -129,11 +218,14 @@ describe('terminal accessory layout', () => {
).toEqual(['space'])
})
it('keeps Space hidden after that choice is persisted with current known ids', () => {
it('keeps hidden built-ins hidden across v2 round-trips', () => {
const visibleBuiltInIds = getDefaultTerminalAccessoryBuiltInIds().filter((id) => id !== 'space')
const persisted = createTerminalAccessoryLayoutPreference(visibleBuiltInIds)
const persisted = createTerminalAccessoryLayoutPreference({
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
visibleBuiltInIds
})
expect(persisted.knownBuiltInIds).toContain('space')
expect(persisted.orderedBuiltInIds).toContain('space')
expect(normalizeTerminalAccessoryLayoutPreference(persisted).visibleBuiltInIds).not.toContain(
'space'
)
@ -145,43 +237,100 @@ describe('terminal accessory layout', () => {
expect(
normalizeTerminalAccessoryLayoutPreference(
{
version: 1,
visibleBuiltInIds: [],
knownBuiltInIds: current
version: 2,
orderedBuiltInIds: current,
visibleBuiltInIds: []
},
current
).visibleBuiltInIds
).toEqual([])
})
it('toggle and reset helpers preserve built-in order', () => {
expect(setTerminalAccessoryBuiltInVisible(['tab'], 'escape', true, ['escape', 'tab'])).toEqual([
'escape',
'tab'
])
it('toggles visibility while preserving the custom order', () => {
const layout = { orderedBuiltInIds: ['tab', 'escape'], visibleBuiltInIds: ['tab'] }
expect(setTerminalAccessoryBuiltInVisible(layout, 'escape', true, ['escape', 'tab'])).toEqual({
orderedBuiltInIds: ['tab', 'escape'],
visibleBuiltInIds: ['tab', 'escape']
})
expect(
setTerminalAccessoryBuiltInVisible(['escape', 'tab'], 'escape', false, ['escape', 'tab'])
).toEqual(['tab'])
expect(resetTerminalAccessoryBuiltInIds()).toEqual(getDefaultTerminalAccessoryBuiltInIds())
setTerminalAccessoryBuiltInVisible(
{ orderedBuiltInIds: ['tab', 'escape'], visibleBuiltInIds: ['tab', 'escape'] },
'tab',
false,
['escape', 'tab']
).visibleBuiltInIds
).toEqual(['escape'])
expect(setTerminalAccessoryBuiltInVisible(layout, 'unknown', true, ['escape', 'tab'])).toEqual({
orderedBuiltInIds: ['tab', 'escape'],
visibleBuiltInIds: ['tab']
})
})
it('saves visible ids with current known built-in ids', async () => {
it('reorders built-ins and keeps the visible subset in the new order', () => {
const layout = {
orderedBuiltInIds: ['escape', 'tab', 'enter'],
visibleBuiltInIds: ['escape', 'enter']
}
expect(
reorderTerminalAccessoryBuiltInIds(
layout,
['enter', 'escape', 'tab'],
['escape', 'tab', 'enter']
)
).toEqual({
orderedBuiltInIds: ['enter', 'escape', 'tab'],
visibleBuiltInIds: ['enter', 'escape']
})
// Why asserted: a stale drag result missing an id must not drop that key.
expect(
reorderTerminalAccessoryBuiltInIds(layout, ['enter', 'escape'], ['escape', 'tab', 'enter'])
.orderedBuiltInIds
).toEqual(['enter', 'escape', 'tab'])
})
it('keeps visible terminal keys in the order of their ids', () => {
expect(getVisibleTerminalAccessoryKeys(['enter', 'escape']).map((key) => key.id)).toEqual([
'enter',
'escape'
])
})
it('saves the sanitized v2 preference', async () => {
asyncStorageMock.setItem.mockResolvedValueOnce(undefined)
await saveTerminalAccessoryLayout(['tab', 'tab', 'missing'])
await saveTerminalAccessoryLayout({
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
visibleBuiltInIds: ['tab', 'tab', 'missing']
})
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY,
JSON.stringify(createTerminalAccessoryLayoutPreference(['tab']))
JSON.stringify(
createTerminalAccessoryLayoutPreference({
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
visibleBuiltInIds: ['tab']
})
)
)
})
it('rejects write failures without mutating helper output', async () => {
asyncStorageMock.setItem.mockRejectedValueOnce(new Error('nope'))
await expect(saveTerminalAccessoryLayout(['escape'])).rejects.toThrow('nope')
expect(createTerminalAccessoryLayoutPreference(['escape']).visibleBuiltInIds).toEqual([
'escape'
])
await expect(
saveTerminalAccessoryLayout({
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
visibleBuiltInIds: ['escape']
})
).rejects.toThrow('nope')
expect(
createTerminalAccessoryLayoutPreference({
orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
visibleBuiltInIds: ['escape']
}).visibleBuiltInIds
).toEqual(['escape'])
})
})

View File

@ -4,10 +4,13 @@ import { TERMINAL_ACCESSORY_KEYS, type TerminalAccessoryKey } from './terminal-a
export const TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY = 'orca:terminal-accessory-layout'
export type TerminalAccessoryLayoutPreference = {
version: 1
export type TerminalAccessoryLayout = {
orderedBuiltInIds: string[]
visibleBuiltInIds: string[]
knownBuiltInIds: string[]
}
export type TerminalAccessoryLayoutPreference = TerminalAccessoryLayout & {
version: 2
}
function builtInIds(): string[] {
@ -16,9 +19,9 @@ function builtInIds(): string[] {
function defaultPreference(ids = builtInIds()): TerminalAccessoryLayoutPreference {
return {
version: 1,
visibleBuiltInIds: [...ids],
knownBuiltInIds: [...ids]
version: 2,
orderedBuiltInIds: [...ids],
visibleBuiltInIds: [...ids]
}
}
@ -42,15 +45,44 @@ function dedupeKnownIds(ids: string[], builtInSet: Set<string>): string[] {
return out
}
function orderBuiltInIds(ids: Set<string>, currentBuiltInIds: string[]): string[] {
// Why: migrated terminal bars should match the Settings -> Terminal order.
return currentBuiltInIds.filter((id) => ids.has(id))
// Why: built-ins added after the user saved a custom order should land next
// to their canonical neighbors, not dangle at the end of the bar.
function insertMissingBuiltInIds(
ordered: string[],
currentBuiltInIds: string[]
): { ordered: string[]; inserted: string[] } {
const present = new Set(ordered)
const out = [...ordered]
const inserted: string[] = []
for (let i = 0; i < currentBuiltInIds.length; i++) {
const id = currentBuiltInIds[i]!
if (present.has(id)) {
continue
}
let insertAt = 0
for (let j = i - 1; j >= 0; j--) {
const at = out.indexOf(currentBuiltInIds[j]!)
if (at !== -1) {
insertAt = at + 1
break
}
}
out.splice(insertAt, 0, id)
present.add(id)
inserted.push(id)
}
return { ordered: out, inserted }
}
export function getDefaultTerminalAccessoryBuiltInIds(): string[] {
return builtInIds()
}
export function getDefaultTerminalAccessoryLayout(): TerminalAccessoryLayout {
const ids = builtInIds()
return { orderedBuiltInIds: ids, visibleBuiltInIds: [...ids] }
}
export function normalizeTerminalAccessoryLayoutPreference(
value: unknown,
currentBuiltInIds = builtInIds()
@ -62,66 +94,112 @@ export function normalizeTerminalAccessoryLayoutPreference(
const candidate = value as {
version?: unknown
orderedBuiltInIds?: unknown
visibleBuiltInIds?: unknown
knownBuiltInIds?: unknown
}
const visibleInput = stringArray(candidate.visibleBuiltInIds)
const knownInput = stringArray(candidate.knownBuiltInIds)
if (candidate.version !== 1 || !visibleInput || !knownInput) {
return fallback
}
const builtInSet = new Set(currentBuiltInIds)
const knownInputSet = new Set(knownInput.filter((id) => builtInSet.has(id)))
const visibleBuiltInSet = new Set(dedupeKnownIds(visibleInput, builtInSet))
for (const id of currentBuiltInIds) {
if (!knownInputSet.has(id)) {
visibleBuiltInSet.add(id)
if (candidate.version === 2) {
const orderedInput = stringArray(candidate.orderedBuiltInIds)
const visibleInput = stringArray(candidate.visibleBuiltInIds)
if (!orderedInput || !visibleInput) {
return fallback
}
const { ordered, inserted } = insertMissingBuiltInIds(
dedupeKnownIds(orderedInput, builtInSet),
currentBuiltInIds
)
const visibleSet = new Set(dedupeKnownIds(visibleInput, builtInSet))
for (const id of inserted) {
visibleSet.add(id)
}
return {
version: 2,
orderedBuiltInIds: ordered,
visibleBuiltInIds: ordered.filter((id) => visibleSet.has(id))
}
}
return {
version: 1,
visibleBuiltInIds: orderBuiltInIds(visibleBuiltInSet, currentBuiltInIds),
knownBuiltInIds: [...currentBuiltInIds]
if (candidate.version === 1) {
const visibleInput = stringArray(candidate.visibleBuiltInIds)
const knownInput = stringArray(candidate.knownBuiltInIds)
if (!visibleInput || !knownInput) {
return fallback
}
const knownInputSet = new Set(knownInput.filter((id) => builtInSet.has(id)))
const visibleSet = new Set(dedupeKnownIds(visibleInput, builtInSet))
for (const id of currentBuiltInIds) {
if (!knownInputSet.has(id)) {
visibleSet.add(id)
}
}
// Why: v1 layouts never had a custom order, so migrate to canonical order.
return {
version: 2,
orderedBuiltInIds: [...currentBuiltInIds],
visibleBuiltInIds: currentBuiltInIds.filter((id) => visibleSet.has(id))
}
}
return fallback
}
export function createTerminalAccessoryLayoutPreference(
visibleBuiltInIds: string[],
layout: TerminalAccessoryLayout,
currentBuiltInIds = builtInIds()
): TerminalAccessoryLayoutPreference {
const builtInSet = new Set(currentBuiltInIds)
const { ordered } = insertMissingBuiltInIds(
dedupeKnownIds(layout.orderedBuiltInIds, builtInSet),
currentBuiltInIds
)
const visibleSet = new Set(dedupeKnownIds(layout.visibleBuiltInIds, builtInSet))
return {
version: 1,
visibleBuiltInIds: dedupeKnownIds(visibleBuiltInIds, new Set(currentBuiltInIds)),
knownBuiltInIds: [...currentBuiltInIds]
version: 2,
orderedBuiltInIds: ordered,
visibleBuiltInIds: ordered.filter((id) => visibleSet.has(id))
}
}
export function setTerminalAccessoryBuiltInVisible(
visibleBuiltInIds: string[],
layout: TerminalAccessoryLayout,
id: string,
visible: boolean,
currentBuiltInIds = builtInIds()
): string[] {
const builtInSet = new Set(currentBuiltInIds)
if (!builtInSet.has(id)) {
return createTerminalAccessoryLayoutPreference(visibleBuiltInIds, currentBuiltInIds)
.visibleBuiltInIds
): TerminalAccessoryLayout {
const preference = createTerminalAccessoryLayoutPreference(layout, currentBuiltInIds)
if (!new Set(currentBuiltInIds).has(id)) {
return {
orderedBuiltInIds: preference.orderedBuiltInIds,
visibleBuiltInIds: preference.visibleBuiltInIds
}
}
const selected = new Set(dedupeKnownIds(visibleBuiltInIds, builtInSet))
const visibleSet = new Set(preference.visibleBuiltInIds)
if (visible) {
selected.add(id)
visibleSet.add(id)
} else {
selected.delete(id)
visibleSet.delete(id)
}
return {
orderedBuiltInIds: preference.orderedBuiltInIds,
visibleBuiltInIds: preference.orderedBuiltInIds.filter((builtInId) => visibleSet.has(builtInId))
}
return currentBuiltInIds.filter((builtInId) => selected.has(builtInId))
}
export function resetTerminalAccessoryBuiltInIds(): string[] {
return builtInIds()
export function reorderTerminalAccessoryBuiltInIds(
layout: TerminalAccessoryLayout,
orderedBuiltInIds: string[],
currentBuiltInIds = builtInIds()
): TerminalAccessoryLayout {
const preference = createTerminalAccessoryLayoutPreference(
{ orderedBuiltInIds, visibleBuiltInIds: layout.visibleBuiltInIds },
currentBuiltInIds
)
return {
orderedBuiltInIds: preference.orderedBuiltInIds,
visibleBuiltInIds: preference.visibleBuiltInIds
}
}
export function getVisibleTerminalAccessoryKeys(
@ -146,7 +224,7 @@ export async function loadTerminalAccessoryLayout(): Promise<TerminalAccessoryLa
}
}
export async function saveTerminalAccessoryLayout(visibleBuiltInIds: string[]): Promise<void> {
const preference = createTerminalAccessoryLayoutPreference(visibleBuiltInIds)
export async function saveTerminalAccessoryLayout(layout: TerminalAccessoryLayout): Promise<void> {
const preference = createTerminalAccessoryLayoutPreference(layout)
await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference))
}

View File

@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { normalizeTerminalTextInput } from './terminal-text-input-normalization'
describe('normalizeTerminalTextInput', () => {
it('converts iOS smart dash replacements back to terminal hyphens', () => {
expect(normalizeTerminalTextInput('git checkout file')).toBe('git checkout -- file')
expect(normalizeTerminalTextInput('git checkout — file')).toBe('git checkout -- file')
})
it('keeps ASCII hyphens unchanged', () => {
expect(normalizeTerminalTextInput('git checkout -- file')).toBe('git checkout -- file')
})
})

View File

@ -0,0 +1,7 @@
// Why: iOS smart punctuation can rewrite two ASCII hyphens into a single
// Unicode dash before React Native delivers terminal text input.
const IOS_SMART_DASH_REPLACEMENT_PATTERN = /[\u2013\u2014]/g
export function normalizeTerminalTextInput(text: string): string {
return text.replace(IOS_SMART_DASH_REPLACEMENT_PATTERN, '--')
}