@@ -18,6 +18,11 @@ const KEY_MAP: Record<string, string> = {
1818} ;
1919
2020const MODIFIERS = new Set ( [ "ctrl" , "alt" , "shift" ] ) ;
21+ const MODIFIER_SEPARATORS = / [ + _ - ] / ;
22+ const NAMED_KEYS = Object . keys ( KEY_MAP ) . sort ( ) . join ( ", " ) ;
23+ const KEY_SPEC_HELP =
24+ `Use ctrl+u, ctrl-u, ctrl_u, or C-u; supported modifiers are ctrl, alt, and shift; ` +
25+ `supported keys are a-z, ${ NAMED_KEYS } .` ;
2126
2227/** Keycodes for CSI u encoding (Kitty keyboard protocol). */
2328const CSI_U_KEYCODES : Record < string , number > = {
@@ -40,16 +45,57 @@ function modifierParam(mods: Set<string>): number {
4045 ) ;
4146}
4247
43- /** Parse a key spec like `ctrl+c`, `return`, `alt+x` into bytes. */
48+ function normalizeModifier ( mod : string , index : number , spec : string ) : string {
49+ // Readline/tmux-style C-u is the established compact spelling for ctrl+u.
50+ // Keep the one-letter alias scoped to a leading C- so C+u and other
51+ // abbreviated modifier alphabets do not acquire surprise meaning.
52+ if ( mod === "c" && index === 0 && / ^ c - / i. test ( spec ) ) return "ctrl" ;
53+ return mod ;
54+ }
55+
56+ function isSupportedBase ( base : string ) : boolean {
57+ return KEY_MAP [ base ] !== undefined || ( base . length === 1 && base >= "a" && base <= "z" ) ;
58+ }
59+
60+ /** Parse a key spec like `ctrl+c`, `ctrl-c`, `C-c`, `return`, or `alt+x` into bytes. */
4461export function resolveKey ( spec : string ) : string {
45- const parts = spec . toLowerCase ( ) . split ( "+" ) ;
62+ const normalized = spec . toLowerCase ( ) ;
63+ const hasSeparator = MODIFIER_SEPARATORS . test ( normalized ) ;
64+ const rawParts = hasSeparator ? normalized . split ( MODIFIER_SEPARATORS ) : [ normalized ] ;
65+ const rawBase = rawParts . at ( - 1 ) ?? "" ;
66+ const rawMods = rawParts . slice ( 0 , - 1 ) . map ( ( mod , index ) =>
67+ normalizeModifier ( mod , index , spec ) ,
68+ ) ;
69+
70+ // A separator-bearing name could be both a named key and a modifier chord.
71+ // Refuse that collision instead of silently changing meaning if the key map
72+ // ever grows such a name.
73+ const isValidChord =
74+ rawBase !== "" &&
75+ rawMods . length > 0 &&
76+ rawMods . every ( ( mod ) => mod !== "" && MODIFIERS . has ( mod ) ) &&
77+ isSupportedBase ( rawBase ) ;
78+ if ( hasSeparator && KEY_MAP [ normalized ] !== undefined && isValidChord ) {
79+ throw new Error (
80+ `Ambiguous key spec "${ spec } ": it is both a named key and a modifier chord. ${ KEY_SPEC_HELP } ` ,
81+ ) ;
82+ }
83+ if ( KEY_MAP [ normalized ] !== undefined && ! isValidChord ) return KEY_MAP [ normalized ] ;
84+
85+ const parts = rawParts ;
4686 const base = parts . pop ( ) ! ;
47- const mods = new Set ( parts ) ;
87+ if ( base === "" || parts . some ( ( part ) => part === "" ) ) {
88+ throw new Error ( `Incomplete key spec "${ spec } ". ${ KEY_SPEC_HELP } ` ) ;
89+ }
90+
91+ const mods = new Set ( parts . map ( ( mod , index ) => normalizeModifier ( mod , index , spec ) ) ) ;
4892
4993 // Validate modifiers
5094 for ( const mod of mods ) {
5195 if ( ! MODIFIERS . has ( mod ) ) {
52- throw new Error ( `Unknown modifier: "${ mod } " in key spec "${ spec } "` ) ;
96+ throw new Error (
97+ `Unknown modifier: "${ mod } " in key spec "${ spec } ". ${ KEY_SPEC_HELP } ` ,
98+ ) ;
5399 }
54100 }
55101
@@ -58,7 +104,10 @@ export function resolveKey(spec: string): string {
58104 const mapped = KEY_MAP [ base ] ;
59105
60106 if ( mapped === undefined && ! isLetter ) {
61- throw new Error ( `Unknown key: "${ base } " in key spec "${ spec } "` ) ;
107+ throw new Error (
108+ `Unknown key: "${ base } " in key spec "${ spec } ". ` +
109+ KEY_SPEC_HELP ,
110+ ) ;
62111 }
63112
64113 // Single letter keys
0 commit comments