Files
orca/mobile/src/components/CustomKeyModal.tsx
T
Jinwoo-H 4ea945bc7e feat(mobile): let the text-input census read a literal already clear of the floor (OTA phase C, C7.2, ruling 12)
The floor is the rule and the seam is the mechanism. A binding rule alone made
the custom-key capture field an offender at 22, where nothing can zoom, and the
only way to satisfy it was to lower a one-character field to 16 — the tail
wagging the dog.

The seam's web half now exports the floor it already computed `Math.max` against,
and the census reads that number out of that file rather than carrying a second
copy of 16. The rule becomes "the seam's binding, or a literal at or above the
floor", with no per-site exemption: a literal under the floor is still reported,
which is the case the seam exists for. A tree whose seam declares no floor is
refused rather than judged against a number the census invented.

So the capture field goes back to 22 on both platforms and its split, its
override entry and its parity test go with it. The chat's two fields stay split,
because 15 is under the floor however it is spelled.

Red-first: with the rule removed, a planted literal 16 and a literal 22 are both
reported and the refusal case does not throw; a literal 15 is reported either
way. All three route closures that run this census — session, source-control,
review — report 0 offenders and 0 unresolved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 07:02:47 -04:00

414 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useMemo, useState } from 'react'
import { View, Text, Pressable, TextInput, Switch } from 'react-native'
import { ChevronLeft } from 'lucide-react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { colors } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import {
buildTerminalShortcutKey,
normalizeShortcutKeyInput,
TERMINAL_SHORTCUT_SPECIAL_KEYS,
type TerminalShortcutModifier,
type TerminalShortcutSpecialKey
} from '../terminal/terminal-accessory-keys'
import { customKeyModalStyles as styles } from './CustomKeyModal.styles'
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
export type CustomKey = {
id: string
label: string
bytes: string
enter: boolean
}
type Step = 'choose-type' | 'shortcut-combo' | 'special-keys' | 'text-macro'
// Why: Alt is rendered with the ⌥ glyph because on macOS hosts the Option key
// is the only modifier that produces an ESC-prefixed byte sequence terminals
// can read. Cmd is intentionally absent — macOS swallows it before keystrokes
// reach the shell, so there's nothing to encode.
const SHORTCUT_MODIFIERS: { id: TerminalShortcutModifier; label: string; glyph?: string }[] = [
{ id: 'ctrl', label: 'Ctrl' },
{ id: 'alt', label: 'Alt', glyph: '⌥' },
{ id: 'shift', label: 'Shift' }
]
// Why: special keys are grouped by purpose so the picker reads as three small
// fixed grids rather than one ragged wrap row that clipped F7-F12.
const SPECIAL_KEY_GROUPS: { title: string; ids: string[]; columns: number }[] = [
{
title: 'Editing',
ids: ['escape', 'tab', 'enter', 'backspace', 'delete', 'insert', 'space'],
columns: 4
},
{
title: 'Navigation',
ids: ['arrowUp', 'arrowDown', 'arrowLeft', 'arrowRight', 'home', 'end', 'pageUp', 'pageDown'],
columns: 4
},
{
title: 'Function',
ids: ['f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12'],
columns: 6
}
]
const SPECIAL_KEY_BY_ID: Record<string, TerminalShortcutSpecialKey> = Object.fromEntries(
TERMINAL_SHORTCUT_SPECIAL_KEYS.map((key) => [key.id, key])
)
type Props = {
visible: boolean
onClose: () => void
onKeysChanged: (keys: CustomKey[]) => void
onManageShortcuts?: () => void
}
export async function loadCustomKeys(): Promise<CustomKey[]> {
try {
const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY)
return raw ? (JSON.parse(raw) as CustomKey[]) : []
} catch {
return []
}
}
export async function saveCustomKeys(keys: CustomKey[]): Promise<void> {
await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys))
}
export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) {
const [step, setStep] = useState<Step>('choose-type')
const [shortcutKey, setShortcutKey] = useState('c')
const [shortcutModifiers, setShortcutModifiers] = useState<TerminalShortcutModifier[]>(['ctrl'])
const [macroLabel, setMacroLabel] = useState('')
const [macroText, setMacroText] = useState('')
const [macroEnter, setMacroEnter] = useState(true)
const [previousVisible, setPreviousVisible] = useState(visible)
// Why: reset before the opening commit so the drawer does not flash the last
// custom-key draft; keep close state unchanged for the slide-out animation.
if (visible !== previousVisible) {
setPreviousVisible(visible)
if (visible) {
setStep('choose-type')
setShortcutKey('c')
setShortcutModifiers(['ctrl'])
setMacroLabel('')
setMacroText('')
setMacroEnter(true)
}
}
const addKey = useCallback(
async (key: Omit<CustomKey, 'id'>) => {
const existing = await loadCustomKeys()
const newKey: CustomKey = { ...key, id: `custom-${Date.now()}` }
const updated = [...existing, newKey]
await saveCustomKeys(updated)
onKeysChanged(updated)
onClose()
},
[onClose, onKeysChanged]
)
const shortcutPreview = useMemo(
() => buildTerminalShortcutKey({ key: shortcutKey, modifiers: shortcutModifiers }),
[shortcutKey, shortcutModifiers]
)
const previewKeyLabel = useMemo(() => {
const special = SPECIAL_KEY_BY_ID[shortcutKey]
if (special) {
return special.label
}
return shortcutKey.length === 1 ? shortcutKey.toUpperCase() : shortcutKey
}, [shortcutKey])
const orderedActiveModifiers = useMemo(
() => SHORTCUT_MODIFIERS.filter((m) => shortcutModifiers.includes(m.id)),
[shortcutModifiers]
)
const toggleShortcutModifier = useCallback((modifier: TerminalShortcutModifier) => {
setShortcutModifiers((current) =>
current.includes(modifier)
? current.filter((item) => item !== modifier)
: [...current, modifier]
)
}, [])
const handleShortcutKeyInput = useCallback((value: string) => {
if (value === '') {
// Why: allow the field to go empty so backspace works; the Save button
// stays disabled until a valid key is entered.
setShortcutKey('')
return
}
const next = normalizeShortcutKeyInput(value)
if (next) {
setShortcutKey(next)
}
}, [])
const handleSpecialKeyPick = useCallback((id: string) => {
setShortcutKey(id)
setStep('shortcut-combo')
}, [])
const handleShortcutSave = useCallback(() => {
const built = buildTerminalShortcutKey({ key: shortcutKey, modifiers: shortcutModifiers })
if (!built) {
return
}
void addKey({ label: built.label, bytes: built.bytes, enter: false })
}, [addKey, shortcutKey, shortcutModifiers])
const handleMacroSave = useCallback(() => {
const label = macroLabel.trim() || macroText.trim().slice(0, 12)
const text = macroText
if (!label || !text) {
return
}
const bytes = macroEnter ? `${text}\r` : text
void addKey({ label, bytes, enter: false })
}, [addKey, macroLabel, macroText, macroEnter])
const showBack = step !== 'choose-type'
const onBack = useCallback(() => {
if (step === 'special-keys') {
setStep('shortcut-combo')
} else {
setStep('choose-type')
}
}, [step])
return (
<BottomDrawer visible={visible} onClose={onClose}>
<View style={styles.header}>
{showBack ? (
<Pressable
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
onPress={onBack}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ChevronLeft size={18} color={colors.textSecondary} />
</Pressable>
) : (
<View style={styles.backSpacer} />
)}
<Text style={styles.title}>
{step === 'choose-type' && 'Add Shortcut'}
{step === 'shortcut-combo' && 'Shortcut Combo'}
{step === 'special-keys' && 'Pick a key'}
{step === 'text-macro' && 'Text Macro'}
</Text>
<View style={styles.backSpacer} />
</View>
{step === 'choose-type' && (
<View style={styles.group}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setStep('shortcut-combo')}
>
<Text style={styles.rowLabel}>Shortcut Combo</Text>
<Text style={styles.rowHint}>Build Ctrl, Alt, and Shift key chords</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setStep('text-macro')}
>
<Text style={styles.rowLabel}>Text Macro</Text>
<Text style={styles.rowHint}>Send custom text command</Text>
</Pressable>
{onManageShortcuts ? (
<>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onManageShortcuts}
>
<Text style={styles.rowLabel}>Manage Shortcuts</Text>
<Text style={styles.rowHint}>Show, hide, or reorder shortcut keys</Text>
</Pressable>
</>
) : null}
</View>
)}
{step === 'shortcut-combo' && (
<View style={styles.shortcutForm}>
<View style={styles.preview}>
{orderedActiveModifiers.map((modifier, index) => (
<View key={modifier.id} style={styles.previewKeycapRow}>
{index > 0 ? <Text style={styles.previewPlus}>+</Text> : null}
<View style={[styles.keycap, styles.keycapModifier]}>
<Text style={styles.keycapModifierText}>{modifier.label}</Text>
</View>
</View>
))}
{orderedActiveModifiers.length > 0 ? <Text style={styles.previewPlus}>+</Text> : null}
<View style={[styles.keycap, !shortcutPreview && styles.keycapWarn]}>
<Text style={[styles.keycapText, !shortcutPreview && styles.keycapTextWarn]}>
{previewKeyLabel}
</Text>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionLabel}>Modifiers</Text>
<View style={styles.mods}>
{SHORTCUT_MODIFIERS.map((modifier) => {
const selected = shortcutModifiers.includes(modifier.id)
return (
<Pressable
key={modifier.id}
style={({ pressed }) => [
styles.chip,
selected && styles.chipSelected,
pressed && !selected && styles.chipPressed
]}
onPress={() => toggleShortcutModifier(modifier.id)}
accessibilityRole="button"
accessibilityState={{ selected }}
>
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
{modifier.label}
</Text>
{modifier.glyph ? (
<Text style={[styles.chipGlyph, selected && styles.chipGlyphSelected]}>
{modifier.glyph}
</Text>
) : null}
</Pressable>
)
})}
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionLabel}>Key</Text>
<TextInput
style={styles.keyInput}
value={shortcutKey.length === 1 ? shortcutKey.toUpperCase() : ''}
onChangeText={handleShortcutKeyInput}
placeholder={SPECIAL_KEY_BY_ID[shortcutKey]?.label ?? 'C'}
placeholderTextColor={colors.textMuted}
autoCapitalize="characters"
autoCorrect={false}
maxLength={1}
/>
<Pressable
style={({ pressed }) => [styles.moreLink, pressed && styles.moreLinkPressed]}
onPress={() => setStep('special-keys')}
>
<Text style={styles.moreLinkText}>More keys Tab, arrows, F1F12</Text>
</Pressable>
</View>
<Pressable
style={[styles.saveButton, !shortcutPreview && styles.saveButtonDisabled]}
disabled={!shortcutPreview}
onPress={handleShortcutSave}
>
<Text
style={[styles.saveButtonText, !shortcutPreview && styles.saveButtonTextDisabled]}
>
Add
</Text>
</Pressable>
</View>
)}
{step === 'special-keys' && (
<View style={styles.specialKeysForm}>
{SPECIAL_KEY_GROUPS.map((group) => (
<View key={group.title} style={styles.specialGroup}>
<Text style={styles.specialGroupTitle}>{group.title}</Text>
<View style={styles.keyGrid}>
{group.ids.map((id) => {
const key = SPECIAL_KEY_BY_ID[id]
if (!key) {
return null
}
const selected = shortcutKey === id
const flexBasis = `${100 / group.columns}%` as const
return (
<View key={id} style={[styles.keyCellWrap, { flexBasis }]}>
<Pressable
style={({ pressed }) => [
styles.keyCell,
selected && styles.keyCellSelected,
pressed && !selected && styles.keyCellPressed
]}
onPress={() => handleSpecialKeyPick(id)}
accessibilityLabel={key.accessibilityLabel}
accessibilityState={{ selected }}
>
<Text style={[styles.keyCellText, selected && styles.keyCellTextSelected]}>
{key.label}
</Text>
</Pressable>
</View>
)
})}
</View>
</View>
))}
</View>
)}
{step === 'text-macro' && (
<View style={styles.group}>
<View style={styles.macroForm}>
<Text style={styles.fieldLabel}>Label</Text>
<TextInput
style={styles.fieldInput}
value={macroLabel}
onChangeText={setMacroLabel}
placeholder="e.g. Build"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
<Text style={styles.fieldLabel}>Command</Text>
<TextInput
style={styles.fieldInput}
value={macroText}
onChangeText={setMacroText}
placeholder="e.g. pnpm build"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>Press Enter</Text>
<Switch
value={macroEnter}
onValueChange={setMacroEnter}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
<Pressable
style={[styles.saveButton, !macroText.trim() && styles.saveButtonDisabled]}
disabled={!macroText.trim()}
onPress={handleMacroSave}
>
<Text
style={[styles.saveButtonText, !macroText.trim() && styles.saveButtonTextDisabled]}
>
Add Shortcut
</Text>
</Pressable>
</View>
</View>
)}
</BottomDrawer>
)
}