diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 5d92367ed2a..01a9b609624 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -65,6 +65,7 @@ import { ConfirmModal } from '../../../../src/components/ConfirmModal' import { CustomKeyModal, loadCustomKeys, + saveCustomKeys, type CustomKey } from '../../../../src/components/CustomKeyModal' import { @@ -1684,7 +1685,7 @@ export default function SessionScreen() { async (key: CustomKey) => { const updated = customKeys.filter((k) => k.id !== key.id) setCustomKeys(updated) - await AsyncStorage.setItem('orca:custom-accessory-keys', JSON.stringify(updated)) + await saveCustomKeys(updated) }, [customKeys] ) diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx index ba264548dde..e364a963a53 100644 --- a/mobile/src/components/CustomKeyModal.tsx +++ b/mobile/src/components/CustomKeyModal.tsx @@ -1,11 +1,18 @@ -import { useState, useEffect, useCallback } from 'react' -import { View, Text, Pressable, TextInput, StyleSheet, ScrollView, Switch } from 'react-native' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native' import { ChevronLeft } from 'lucide-react-native' import AsyncStorage from '@react-native-async-storage/async-storage' import { colors, spacing, radii, typography } from '../theme/mobile-theme' import { BottomDrawer } from './BottomDrawer' +import { + buildTerminalShortcutKey, + normalizeShortcutKeyInput, + TERMINAL_SHORTCUT_SPECIAL_KEYS, + type TerminalShortcutModifier, + type TerminalShortcutSpecialKey +} from '../terminal/terminal-accessory-keys' -const STORAGE_KEY = 'orca:custom-accessory-keys' +export const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys' export type CustomKey = { id: string @@ -14,17 +21,41 @@ export type CustomKey = { enter: boolean } -type Step = 'choose-type' | 'pick-ctrl' | 'pick-alt' | 'text-macro' +type Step = 'choose-type' | 'shortcut-combo' | 'special-keys' | 'text-macro' -const ALPHA_KEYS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') +// 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' } +] -function ctrlBytes(letter: string): string { - return String.fromCharCode(letter.toUpperCase().charCodeAt(0) - 64) -} +// 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 + } +] -function altBytes(letter: string): string { - return `\x1b${letter.toLowerCase()}` -} +const SPECIAL_KEY_BY_ID: Record = Object.fromEntries( + TERMINAL_SHORTCUT_SPECIAL_KEYS.map((key) => [key.id, key]) +) type Props = { visible: boolean @@ -34,19 +65,21 @@ type Props = { export async function loadCustomKeys(): Promise { try { - const raw = await AsyncStorage.getItem(STORAGE_KEY) + const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY) return raw ? (JSON.parse(raw) as CustomKey[]) : [] } catch { return [] } } -async function saveCustomKeys(keys: CustomKey[]): Promise { - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keys)) +export async function saveCustomKeys(keys: CustomKey[]): Promise { + await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys)) } export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { const [step, setStep] = useState('choose-type') + const [shortcutKey, setShortcutKey] = useState('c') + const [shortcutModifiers, setShortcutModifiers] = useState(['ctrl']) const [macroLabel, setMacroLabel] = useState('') const [macroText, setMacroText] = useState('') const [macroEnter, setMacroEnter] = useState(true) @@ -54,6 +87,8 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { useEffect(() => { if (visible) { setStep('choose-type') + setShortcutKey('c') + setShortcutModifiers(['ctrl']) setMacroLabel('') setMacroText('') setMacroEnter(true) @@ -72,20 +107,54 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { [onClose, onKeysChanged] ) - const handleCtrlKey = useCallback( - (letter: string) => { - void addKey({ label: `Ctrl+${letter}`, bytes: ctrlBytes(letter), enter: false }) - }, - [addKey] + const shortcutPreview = useMemo( + () => buildTerminalShortcutKey({ key: shortcutKey, modifiers: shortcutModifiers }), + [shortcutKey, shortcutModifiers] ) - const handleAltKey = useCallback( - (letter: string) => { - void addKey({ label: `Alt+${letter}`, bytes: altBytes(letter), enter: false }) - }, - [addKey] + 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 @@ -95,6 +164,13 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { }, [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 ( @@ -102,7 +178,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { {showBack ? ( [styles.backButton, pressed && styles.backButtonPressed]} - onPress={() => setStep('choose-type')} + onPress={onBack} accessibilityLabel="Back" > @@ -112,8 +188,8 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { )} {step === 'choose-type' && 'Add Shortcut'} - {step === 'pick-ctrl' && 'Ctrl + Key'} - {step === 'pick-alt' && 'Alt + Key'} + {step === 'shortcut-combo' && 'Shortcut Combo'} + {step === 'special-keys' && 'Pick a key'} {step === 'text-macro' && 'Text Macro'} @@ -123,18 +199,10 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { [styles.row, pressed && styles.rowPressed]} - onPress={() => setStep('pick-ctrl')} + onPress={() => setStep('shortcut-combo')} > - Ctrl + Key - Control character shortcuts - - - [styles.row, pressed && styles.rowPressed]} - onPress={() => setStep('pick-alt')} - > - Alt + Key - Alt/Option key combos + Shortcut Combo + Build Ctrl, Alt, and Shift key chords )} - {(step === 'pick-ctrl' || step === 'pick-alt') && ( - - - {ALPHA_KEYS.map((letter) => ( - [styles.keyCell, pressed && styles.keyCellPressed]} - onPress={() => - step === 'pick-ctrl' ? handleCtrlKey(letter) : handleAltKey(letter) - } - > - {letter} - + {step === 'shortcut-combo' && ( + + + {orderedActiveModifiers.map((modifier, index) => ( + + {index > 0 ? + : null} + + {modifier.label} + + ))} - + {orderedActiveModifiers.length > 0 ? + : null} + + + {previewKeyLabel} + + + + + + Modifiers + + {SHORTCUT_MODIFIERS.map((modifier) => { + const selected = shortcutModifiers.includes(modifier.id) + return ( + [ + styles.chip, + selected && styles.chipSelected, + pressed && !selected && styles.chipPressed + ]} + onPress={() => toggleShortcutModifier(modifier.id)} + accessibilityRole="button" + accessibilityState={{ selected }} + > + + {modifier.label} + + {modifier.glyph ? ( + + {modifier.glyph} + + ) : null} + + ) + })} + + + + + Key + + [styles.moreLink, pressed && styles.moreLinkPressed]} + onPress={() => setStep('special-keys')} + > + More keys — Tab, arrows, F1–F12… + + + + + + Add + + + + )} + + {step === 'special-keys' && ( + + {SPECIAL_KEY_GROUPS.map((group) => ( + + {group.title} + + {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 ( + + [ + styles.keyCell, + selected && styles.keyCellSelected, + pressed && !selected && styles.keyCellPressed + ]} + onPress={() => handleSpecialKeyPick(id)} + accessibilityLabel={key.accessibilityLabel} + accessibilityState={{ selected }} + > + + {key.label} + + + + ) + })} + + + ))} )} @@ -202,7 +372,11 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) { disabled={!macroText.trim()} onPress={handleMacroSave} > - Add Shortcut + + Add Shortcut + @@ -264,33 +438,177 @@ const styles = StyleSheet.create({ fontSize: 12, color: colors.textMuted }, - keyGridScroll: { - maxHeight: 240 + shortcutForm: { + paddingTop: spacing.sm + }, + preview: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingVertical: spacing.lg + spacing.xs, + flexWrap: 'wrap' + }, + previewKeycapRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + previewPlus: { + color: colors.textMuted, + fontSize: 16 + }, + keycap: { + minWidth: 48, + height: 48, + paddingHorizontal: spacing.md, + borderRadius: 10, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + alignItems: 'center', + justifyContent: 'center' + }, + keycapModifier: { + minWidth: 0 + }, + keycapWarn: { + borderColor: colors.statusAmber + }, + keycapText: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: 17, + fontWeight: '600' + }, + keycapTextWarn: { + color: colors.statusAmber + }, + keycapModifierText: { + color: colors.textSecondary, + fontFamily: typography.monoFamily, + fontSize: 14, + fontWeight: '600' + }, + section: { + marginTop: spacing.md + }, + sectionLabel: { + fontSize: 11, + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.8, + marginBottom: spacing.sm, + paddingLeft: 2 + }, + mods: { + flexDirection: 'row', + gap: spacing.sm + }, + chip: { + flex: 1, + height: 40, + borderRadius: 8, + backgroundColor: colors.bgPanel, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 4 + }, + chipSelected: { + backgroundColor: colors.textPrimary + }, + chipPressed: { + backgroundColor: colors.bgRaised + }, + chipText: { + color: colors.textSecondary, + fontSize: 14, + fontWeight: '500' + }, + chipTextSelected: { + color: colors.bgBase + }, + chipGlyph: { + color: colors.textMuted, + fontSize: 13, + fontFamily: typography.monoFamily + }, + chipGlyphSelected: { + color: 'rgba(10,10,10,0.5)' + }, + keyInput: { + width: '100%', + height: 56, + borderRadius: 10, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.borderSubtle, + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: 22, + fontWeight: '600', + textAlign: 'center' + }, + moreLink: { + paddingVertical: spacing.sm, + alignItems: 'center' + }, + moreLinkPressed: { + opacity: 0.6 + }, + moreLinkText: { + color: colors.textSecondary, + fontSize: 13, + textDecorationLine: 'underline' + }, + specialKeysForm: { + paddingTop: spacing.xs, + paddingBottom: spacing.md, + gap: spacing.md + }, + specialGroup: { + gap: spacing.xs + }, + specialGroupTitle: { + fontSize: 11, + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.8, + paddingLeft: 2, + marginBottom: spacing.xs }, keyGrid: { flexDirection: 'row', flexWrap: 'wrap', - gap: spacing.xs, - justifyContent: 'center', - padding: spacing.md + marginHorizontal: -spacing.xs / 2 + }, + keyCellWrap: { + paddingHorizontal: spacing.xs / 2, + paddingVertical: spacing.xs / 2 }, keyCell: { - width: 42, - height: 38, - borderRadius: radii.button, - backgroundColor: colors.bgBase, + height: 40, + borderRadius: 8, + backgroundColor: colors.bgPanel, alignItems: 'center', justifyContent: 'center' }, keyCellPressed: { backgroundColor: colors.bgRaised }, + keyCellSelected: { + backgroundColor: colors.textPrimary + }, keyCellText: { - fontSize: 15, + fontSize: 13, fontWeight: '600', color: colors.textPrimary, fontFamily: typography.monoFamily }, + keyCellTextSelected: { + color: colors.bgBase + }, macroForm: { padding: spacing.md, gap: spacing.sm @@ -322,17 +640,21 @@ const styles = StyleSheet.create({ color: colors.textPrimary }, saveButton: { + marginTop: spacing.md, backgroundColor: colors.textPrimary, - paddingVertical: spacing.sm + 2, - borderRadius: radii.button, + paddingVertical: spacing.md, + borderRadius: 10, alignItems: 'center' }, saveButtonDisabled: { - opacity: 0.5 + backgroundColor: colors.bgRaised }, saveButtonText: { color: colors.bgBase, - fontSize: typography.bodySize, + fontSize: 15, fontWeight: '600' + }, + saveButtonTextDisabled: { + color: colors.textMuted } }) diff --git a/mobile/src/terminal/terminal-accessory-keys.test.ts b/mobile/src/terminal/terminal-accessory-keys.test.ts index 85a20122e69..650f45dc4e1 100644 --- a/mobile/src/terminal/terminal-accessory-keys.test.ts +++ b/mobile/src/terminal/terminal-accessory-keys.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { TERMINAL_ACCESSORY_KEYS } from './terminal-accessory-keys' +import { buildTerminalShortcutKey, TERMINAL_ACCESSORY_KEYS } from './terminal-accessory-keys' describe('TERMINAL_ACCESSORY_KEYS', () => { it('sends reverse-tab with a non-repeatable Shift+Tab key', () => { @@ -20,4 +20,57 @@ describe('TERMINAL_ACCESSORY_KEYS', () => { expect(key.repeatable === true).toBe(repeatableLabels.has(key.label)) } }) + + it('builds Ctrl, Alt, and Shift printable shortcut bytes', () => { + expect(buildTerminalShortcutKey({ key: 'c', modifiers: ['ctrl'] })).toEqual({ + label: 'Ctrl+C', + bytes: '\x03', + accessibilityLabel: 'Ctrl C' + }) + expect(buildTerminalShortcutKey({ key: 'k', modifiers: ['ctrl', 'alt'] })).toEqual({ + label: 'Ctrl+Alt+K', + bytes: '\x1b\x0b', + accessibilityLabel: 'Ctrl Alt K' + }) + expect(buildTerminalShortcutKey({ key: '1', modifiers: ['alt', 'shift'] })).toEqual({ + label: 'Alt+Shift+1', + bytes: '\x1b!', + accessibilityLabel: 'Alt Shift 1' + }) + }) + + it('builds modified special-key terminal sequences', () => { + expect(buildTerminalShortcutKey({ key: 'tab', modifiers: ['shift'] })).toEqual({ + label: 'Shift+Tab', + bytes: '\x1b[Z', + accessibilityLabel: 'Shift Tab' + }) + expect(buildTerminalShortcutKey({ key: 'arrowRight', modifiers: ['ctrl', 'shift'] })).toEqual({ + label: 'Ctrl+Shift+→', + bytes: '\x1b[1;6C', + accessibilityLabel: 'Ctrl Shift →' + }) + expect(buildTerminalShortcutKey({ key: 'delete', modifiers: ['alt'] })).toEqual({ + label: 'Alt+Del', + bytes: '\x1b[3;3~', + accessibilityLabel: 'Alt Del' + }) + }) + + it('builds function-key terminal sequences', () => { + expect(buildTerminalShortcutKey({ key: 'f1', modifiers: [] })).toEqual({ + label: 'F1', + bytes: '\x1bOP', + accessibilityLabel: 'F1' + }) + expect(buildTerminalShortcutKey({ key: 'f5', modifiers: ['shift'] })).toEqual({ + label: 'Shift+F5', + bytes: '\x1b[15;2~', + accessibilityLabel: 'Shift F5' + }) + }) + + it('rejects control combinations that terminals cannot encode as control bytes', () => { + expect(buildTerminalShortcutKey({ key: '1', modifiers: ['ctrl'] })).toBeNull() + }) }) diff --git a/mobile/src/terminal/terminal-accessory-keys.ts b/mobile/src/terminal/terminal-accessory-keys.ts index e65b0b27fe0..82ef9d83fc9 100644 --- a/mobile/src/terminal/terminal-accessory-keys.ts +++ b/mobile/src/terminal/terminal-accessory-keys.ts @@ -5,6 +5,199 @@ export type TerminalAccessoryKey = { repeatable?: boolean } +export type TerminalShortcutModifier = 'ctrl' | 'alt' | 'shift' + +export type TerminalShortcutBinding = { + key: string + modifiers: TerminalShortcutModifier[] +} + +export type TerminalShortcutBuildResult = { + label: string + bytes: string + accessibilityLabel: string +} + +export type TerminalShortcutSpecialKey = { + id: string + label: string + accessibilityLabel: string +} + +const ESC = '\x1b' + +const MODIFIER_LABELS: Record = { + ctrl: 'Ctrl', + alt: 'Alt', + shift: 'Shift' +} + +const MODIFIER_ORDER: TerminalShortcutModifier[] = ['ctrl', 'alt', 'shift'] + +const SHIFTED_PRINTABLE: Record = { + '`': '~', + '1': '!', + '2': '@', + '3': '#', + '4': '$', + '5': '%', + '6': '^', + '7': '&', + '8': '*', + '9': '(', + '0': ')', + '-': '_', + '=': '+', + '[': '{', + ']': '}', + '\\': '|', + ';': ':', + "'": '"', + ',': '<', + '.': '>', + '/': '?' +} + +const CTRL_PRINTABLE_BYTES: Record = { + ' ': '\x00', + '@': '\x00', + '`': '\x00', + '[': '\x1b', + '{': '\x1b', + '\\': '\x1c', + '|': '\x1c', + ']': '\x1d', + '}': '\x1d', + '^': '\x1e', + '~': '\x1e', + _: '\x1f', + '?': '\x7f' +} + +const SPECIAL_KEY_LABELS: Record = { + escape: 'Esc', + tab: 'Tab', + enter: 'Enter', + backspace: '⌫', + delete: 'Del', + insert: 'Ins', + arrowUp: '↑', + arrowDown: '↓', + arrowLeft: '←', + arrowRight: '→', + home: 'Home', + end: 'End', + pageUp: 'PgUp', + pageDown: 'PgDn', + space: 'Space', + f1: 'F1', + f2: 'F2', + f3: 'F3', + f4: 'F4', + f5: 'F5', + f6: 'F6', + f7: 'F7', + f8: 'F8', + f9: 'F9', + f10: 'F10', + f11: 'F11', + f12: 'F12' +} + +const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record = { + escape: 'Escape', + tab: 'Tab', + enter: 'Enter', + backspace: 'Backspace', + delete: 'Forward delete', + insert: 'Insert', + arrowUp: 'Arrow up', + arrowDown: 'Arrow down', + arrowLeft: 'Arrow left', + arrowRight: 'Arrow right', + home: 'Home', + end: 'End', + pageUp: 'Page up', + pageDown: 'Page down', + space: 'Space', + f1: 'F1', + f2: 'F2', + f3: 'F3', + f4: 'F4', + f5: 'F5', + f6: 'F6', + f7: 'F7', + f8: 'F8', + f9: 'F9', + f10: 'F10', + f11: 'F11', + f12: 'F12' +} + +const CSI_FINAL_SPECIAL_KEYS: Record = { + arrowUp: 'A', + arrowDown: 'B', + arrowRight: 'C', + arrowLeft: 'D', + home: 'H', + end: 'F', + f1: 'P', + f2: 'Q', + f3: 'R', + f4: 'S' +} + +const SS3_BASE_SPECIAL_KEYS = new Set(['f1', 'f2', 'f3', 'f4']) + +const CSI_TILDE_SPECIAL_KEYS: Record = { + insert: 2, + delete: 3, + pageUp: 5, + pageDown: 6, + f5: 15, + f6: 17, + f7: 18, + f8: 19, + f9: 20, + f10: 21, + f11: 23, + f12: 24 +} + +export const TERMINAL_SHORTCUT_SPECIAL_KEYS: TerminalShortcutSpecialKey[] = [ + 'escape', + 'tab', + 'enter', + 'backspace', + 'delete', + 'insert', + 'arrowUp', + 'arrowDown', + 'arrowLeft', + 'arrowRight', + 'home', + 'end', + 'pageUp', + 'pageDown', + 'space', + 'f1', + 'f2', + 'f3', + 'f4', + 'f5', + 'f6', + 'f7', + 'f8', + 'f9', + 'f10', + 'f11', + 'f12' +].map((id) => ({ + id, + label: SPECIAL_KEY_LABELS[id]!, + accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]! +})) + export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [ { label: 'Esc', bytes: '\x1b' }, { label: 'Tab', bytes: '\t' }, @@ -26,3 +219,177 @@ export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [ { label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' }, { label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' } ] + +export function buildTerminalShortcutKey( + binding: TerminalShortcutBinding +): TerminalShortcutBuildResult | null { + const key = normalizeShortcutKey(binding.key) + if (!key) { + return null + } + const modifiers = normalizeModifiers(binding.modifiers) + const bytes = buildShortcutBytes(key, modifiers) + if (bytes == null) { + return null + } + const label = formatShortcutLabel(key, modifiers) + return { + label, + bytes, + accessibilityLabel: label.replaceAll('+', ' ') + } +} + +export function normalizeShortcutKeyInput(value: string): string | null { + const chars = Array.from(value) + const firstVisible = chars.find((char) => char !== '\n' && char !== '\r' && char !== '\t') + if (!firstVisible) { + return null + } + return normalizeShortcutKey(firstVisible) +} + +function buildShortcutBytes(key: string, modifiers: TerminalShortcutModifier[]): string | null { + if (key === 'space') { + return buildPrintableShortcutBytes(' ', modifiers) + } + const csiFinal = CSI_FINAL_SPECIAL_KEYS[key] + if (csiFinal) { + // Why: xterm encodes unmodified F1-F4 as SS3 (ESC O P/S). Once a + // modifier is present it switches to the CSI 1;N form like arrows. + if (SS3_BASE_SPECIAL_KEYS.has(key) && csiModifierParameter(modifiers) === 1) { + return `${ESC}O${csiFinal}` + } + return buildCsiFinalShortcut(csiFinal, modifiers) + } + const csiTilde = CSI_TILDE_SPECIAL_KEYS[key] + if (csiTilde) { + return buildCsiTildeShortcut(csiTilde, modifiers) + } + if (key === 'tab') { + if ( + hasModifier(modifiers, 'shift') && + !hasModifier(modifiers, 'ctrl') && + !hasModifier(modifiers, 'alt') + ) { + return `${ESC}[Z` + } + const bytes = '\t' + return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes + } + if (key === 'escape') { + const bytes = ESC + return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes + } + if (key === 'enter') { + const bytes = '\r' + return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes + } + if (key === 'backspace') { + const bytes = hasModifier(modifiers, 'ctrl') ? '\b' : '\x7f' + return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes + } + if (isPrintableShortcutKey(key)) { + return buildPrintableShortcutBytes(key, modifiers) + } + return null +} + +function buildPrintableShortcutBytes( + key: string, + modifiers: TerminalShortcutModifier[] +): string | null { + const shifted = hasModifier(modifiers, 'shift') ? applyShift(key) : key + let bytes = shifted + if (hasModifier(modifiers, 'ctrl')) { + const ctrlBytes = controlBytesForPrintable(shifted) + if (ctrlBytes == null) { + return null + } + bytes = ctrlBytes + } + return hasModifier(modifiers, 'alt') ? `${ESC}${bytes}` : bytes +} + +function buildCsiFinalShortcut(final: string, modifiers: TerminalShortcutModifier[]): string { + const parameter = csiModifierParameter(modifiers) + return parameter === 1 ? `${ESC}[${final}` : `${ESC}[1;${parameter}${final}` +} + +function buildCsiTildeShortcut(code: number, modifiers: TerminalShortcutModifier[]): string { + const parameter = csiModifierParameter(modifiers) + return parameter === 1 ? `${ESC}[${code}~` : `${ESC}[${code};${parameter}~` +} + +function csiModifierParameter(modifiers: TerminalShortcutModifier[]): number { + let parameter = 1 + if (hasModifier(modifiers, 'shift')) { + parameter += 1 + } + if (hasModifier(modifiers, 'alt')) { + parameter += 2 + } + if (hasModifier(modifiers, 'ctrl')) { + parameter += 4 + } + return parameter +} + +function controlBytesForPrintable(key: string): string | null { + const lower = key.toLowerCase() + if (lower >= 'a' && lower <= 'z') { + return String.fromCharCode(lower.charCodeAt(0) - 96) + } + return CTRL_PRINTABLE_BYTES[key] ?? null +} + +function applyShift(key: string): string { + if (key >= 'a' && key <= 'z') { + return key.toUpperCase() + } + if (key >= 'A' && key <= 'Z') { + return key + } + return SHIFTED_PRINTABLE[key] ?? key +} + +function normalizeModifiers(modifiers: TerminalShortcutModifier[]): TerminalShortcutModifier[] { + const selected = new Set(modifiers) + return MODIFIER_ORDER.filter((modifier) => selected.has(modifier)) +} + +function normalizeShortcutKey(key: string): string | null { + if (SPECIAL_KEY_LABELS[key]) { + return key + } + if (key.length === 1 && isPrintableShortcutKey(key)) { + return key >= 'A' && key <= 'Z' ? key.toLowerCase() : key + } + return null +} + +function isPrintableShortcutKey(key: string): boolean { + return key.length === 1 && key >= ' ' && key <= '~' +} + +function formatShortcutLabel(key: string, modifiers: TerminalShortcutModifier[]): string { + const modifierLabels = modifiers.map((modifier) => MODIFIER_LABELS[modifier]) + return [...modifierLabels, displayKeyLabel(key)].join('+') +} + +function displayKeyLabel(key: string): string { + if (SPECIAL_KEY_LABELS[key]) { + return SPECIAL_KEY_LABELS[key] + } + if (key === ' ') { + return 'Space' + } + return key.length === 1 && key >= 'a' && key <= 'z' ? key.toUpperCase() : key +} + +function hasModifier( + modifiers: TerminalShortcutModifier[], + modifier: TerminalShortcutModifier +): boolean { + return modifiers.includes(modifier) +}