Add mobile custom shortcut combos (#2387)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-21 22:39:25 -07:00
committed by GitHub
co-authored by Orca
parent e5f15a98c5
commit cb8596af6e
4 changed files with 812 additions and 69 deletions
@@ -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]
)
+389 -67
View File
@@ -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<string, TerminalShortcutSpecialKey> = 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<CustomKey[]> {
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<void> {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(keys))
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 }: 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)
@@ -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 (
<BottomDrawer visible={visible} onClose={onClose}>
@@ -102,7 +178,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
{showBack ? (
<Pressable
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
onPress={() => setStep('choose-type')}
onPress={onBack}
accessibilityLabel="Back"
>
<ChevronLeft size={18} color={colors.textSecondary} />
@@ -112,8 +188,8 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
)}
<Text style={styles.title}>
{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'}
</Text>
<View style={styles.backSpacer} />
@@ -123,18 +199,10 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
<View style={styles.group}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setStep('pick-ctrl')}
onPress={() => setStep('shortcut-combo')}
>
<Text style={styles.rowLabel}>Ctrl + Key</Text>
<Text style={styles.rowHint}>Control character shortcuts</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setStep('pick-alt')}
>
<Text style={styles.rowLabel}>Alt + Key</Text>
<Text style={styles.rowHint}>Alt/Option key combos</Text>
<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
@@ -147,21 +215,123 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
</View>
)}
{(step === 'pick-ctrl' || step === 'pick-alt') && (
<View style={styles.group}>
<ScrollView style={styles.keyGridScroll} contentContainerStyle={styles.keyGrid}>
{ALPHA_KEYS.map((letter) => (
<Pressable
key={letter}
style={({ pressed }) => [styles.keyCell, pressed && styles.keyCellPressed]}
onPress={() =>
step === 'pick-ctrl' ? handleCtrlKey(letter) : handleAltKey(letter)
}
>
<Text style={styles.keyCellText}>{letter}</Text>
</Pressable>
{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>
))}
</ScrollView>
{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>
)}
@@ -202,7 +372,11 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
disabled={!macroText.trim()}
onPress={handleMacroSave}
>
<Text style={styles.saveButtonText}>Add Shortcut</Text>
<Text
style={[styles.saveButtonText, !macroText.trim() && styles.saveButtonTextDisabled]}
>
Add Shortcut
</Text>
</Pressable>
</View>
</View>
@@ -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
}
})
@@ -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()
})
})
@@ -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<TerminalShortcutModifier, string> = {
ctrl: 'Ctrl',
alt: 'Alt',
shift: 'Shift'
}
const MODIFIER_ORDER: TerminalShortcutModifier[] = ['ctrl', 'alt', 'shift']
const SHIFTED_PRINTABLE: Record<string, string> = {
'`': '~',
'1': '!',
'2': '@',
'3': '#',
'4': '$',
'5': '%',
'6': '^',
'7': '&',
'8': '*',
'9': '(',
'0': ')',
'-': '_',
'=': '+',
'[': '{',
']': '}',
'\\': '|',
';': ':',
"'": '"',
',': '<',
'.': '>',
'/': '?'
}
const CTRL_PRINTABLE_BYTES: Record<string, string> = {
' ': '\x00',
'@': '\x00',
'`': '\x00',
'[': '\x1b',
'{': '\x1b',
'\\': '\x1c',
'|': '\x1c',
']': '\x1d',
'}': '\x1d',
'^': '\x1e',
'~': '\x1e',
_: '\x1f',
'?': '\x7f'
}
const SPECIAL_KEY_LABELS: Record<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, number> = {
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)
}