source dump of claude code
at main 244 lines 7.1 kB view raw
1import type { Key } from '../ink.js' 2import { getKeyName, matchesBinding } from './match.js' 3import { chordToString } from './parser.js' 4import type { 5 KeybindingContextName, 6 ParsedBinding, 7 ParsedKeystroke, 8} from './types.js' 9 10export type ResolveResult = 11 | { type: 'match'; action: string } 12 | { type: 'none' } 13 | { type: 'unbound' } 14 15export type ChordResolveResult = 16 | { type: 'match'; action: string } 17 | { type: 'none' } 18 | { type: 'unbound' } 19 | { type: 'chord_started'; pending: ParsedKeystroke[] } 20 | { type: 'chord_cancelled' } 21 22/** 23 * Resolve a key input to an action. 24 * Pure function - no state, no side effects, just matching logic. 25 * 26 * @param input - The character input from Ink 27 * @param key - The Key object from Ink with modifier flags 28 * @param activeContexts - Array of currently active contexts (e.g., ['Chat', 'Global']) 29 * @param bindings - All parsed bindings to search through 30 * @returns The resolution result 31 */ 32export function resolveKey( 33 input: string, 34 key: Key, 35 activeContexts: KeybindingContextName[], 36 bindings: ParsedBinding[], 37): ResolveResult { 38 // Find matching bindings (last one wins for user overrides) 39 let match: ParsedBinding | undefined 40 const ctxSet = new Set(activeContexts) 41 42 for (const binding of bindings) { 43 // Phase 1: Only single-keystroke bindings 44 if (binding.chord.length !== 1) continue 45 if (!ctxSet.has(binding.context)) continue 46 47 if (matchesBinding(input, key, binding)) { 48 match = binding 49 } 50 } 51 52 if (!match) { 53 return { type: 'none' } 54 } 55 56 if (match.action === null) { 57 return { type: 'unbound' } 58 } 59 60 return { type: 'match', action: match.action } 61} 62 63/** 64 * Get display text for an action from bindings (e.g., "ctrl+t" for "app:toggleTodos"). 65 * Searches in reverse order so user overrides take precedence. 66 */ 67export function getBindingDisplayText( 68 action: string, 69 context: KeybindingContextName, 70 bindings: ParsedBinding[], 71): string | undefined { 72 // Find the last binding for this action in this context 73 const binding = bindings.findLast( 74 b => b.action === action && b.context === context, 75 ) 76 return binding ? chordToString(binding.chord) : undefined 77} 78 79/** 80 * Build a ParsedKeystroke from Ink's input/key. 81 */ 82function buildKeystroke(input: string, key: Key): ParsedKeystroke | null { 83 const keyName = getKeyName(input, key) 84 if (!keyName) return null 85 86 // QUIRK: Ink sets key.meta=true when escape is pressed (see input-event.ts). 87 // This is legacy terminal behavior - we should NOT record this as a modifier 88 // for the escape key itself, otherwise chord matching will fail. 89 const effectiveMeta = key.escape ? false : key.meta 90 91 return { 92 key: keyName, 93 ctrl: key.ctrl, 94 alt: effectiveMeta, 95 shift: key.shift, 96 meta: effectiveMeta, 97 super: key.super, 98 } 99} 100 101/** 102 * Compare two ParsedKeystrokes for equality. Collapses alt/meta into 103 * one logical modifier — legacy terminals can't distinguish them (see 104 * match.ts modifiersMatch), so "alt+k" and "meta+k" are the same key. 105 * Super (cmd/win) is distinct — only arrives via kitty keyboard protocol. 106 */ 107export function keystrokesEqual( 108 a: ParsedKeystroke, 109 b: ParsedKeystroke, 110): boolean { 111 return ( 112 a.key === b.key && 113 a.ctrl === b.ctrl && 114 a.shift === b.shift && 115 (a.alt || a.meta) === (b.alt || b.meta) && 116 a.super === b.super 117 ) 118} 119 120/** 121 * Check if a chord prefix matches the beginning of a binding's chord. 122 */ 123function chordPrefixMatches( 124 prefix: ParsedKeystroke[], 125 binding: ParsedBinding, 126): boolean { 127 if (prefix.length >= binding.chord.length) return false 128 for (let i = 0; i < prefix.length; i++) { 129 const prefixKey = prefix[i] 130 const bindingKey = binding.chord[i] 131 if (!prefixKey || !bindingKey) return false 132 if (!keystrokesEqual(prefixKey, bindingKey)) return false 133 } 134 return true 135} 136 137/** 138 * Check if a full chord matches a binding's chord. 139 */ 140function chordExactlyMatches( 141 chord: ParsedKeystroke[], 142 binding: ParsedBinding, 143): boolean { 144 if (chord.length !== binding.chord.length) return false 145 for (let i = 0; i < chord.length; i++) { 146 const chordKey = chord[i] 147 const bindingKey = binding.chord[i] 148 if (!chordKey || !bindingKey) return false 149 if (!keystrokesEqual(chordKey, bindingKey)) return false 150 } 151 return true 152} 153 154/** 155 * Resolve a key with chord state support. 156 * 157 * This function handles multi-keystroke chord bindings like "ctrl+k ctrl+s". 158 * 159 * @param input - The character input from Ink 160 * @param key - The Key object from Ink with modifier flags 161 * @param activeContexts - Array of currently active contexts 162 * @param bindings - All parsed bindings 163 * @param pending - Current chord state (null if not in a chord) 164 * @returns Resolution result with chord state 165 */ 166export function resolveKeyWithChordState( 167 input: string, 168 key: Key, 169 activeContexts: KeybindingContextName[], 170 bindings: ParsedBinding[], 171 pending: ParsedKeystroke[] | null, 172): ChordResolveResult { 173 // Cancel chord on escape 174 if (key.escape && pending !== null) { 175 return { type: 'chord_cancelled' } 176 } 177 178 // Build current keystroke 179 const currentKeystroke = buildKeystroke(input, key) 180 if (!currentKeystroke) { 181 if (pending !== null) { 182 return { type: 'chord_cancelled' } 183 } 184 return { type: 'none' } 185 } 186 187 // Build the full chord sequence to test 188 const testChord = pending 189 ? [...pending, currentKeystroke] 190 : [currentKeystroke] 191 192 // Filter bindings by active contexts (Set lookup: O(n) instead of O(n·m)) 193 const ctxSet = new Set(activeContexts) 194 const contextBindings = bindings.filter(b => ctxSet.has(b.context)) 195 196 // Check if this could be a prefix for longer chords. Group by chord 197 // string so a later null-override shadows the default it unbinds — 198 // otherwise null-unbinding `ctrl+x ctrl+k` still makes `ctrl+x` enter 199 // chord-wait and the single-key binding on the prefix never fires. 200 const chordWinners = new Map<string, string | null>() 201 for (const binding of contextBindings) { 202 if ( 203 binding.chord.length > testChord.length && 204 chordPrefixMatches(testChord, binding) 205 ) { 206 chordWinners.set(chordToString(binding.chord), binding.action) 207 } 208 } 209 let hasLongerChords = false 210 for (const action of chordWinners.values()) { 211 if (action !== null) { 212 hasLongerChords = true 213 break 214 } 215 } 216 217 // If this keystroke could start a longer chord, prefer that 218 // (even if there's an exact single-key match) 219 if (hasLongerChords) { 220 return { type: 'chord_started', pending: testChord } 221 } 222 223 // Check for exact matches (last one wins) 224 let exactMatch: ParsedBinding | undefined 225 for (const binding of contextBindings) { 226 if (chordExactlyMatches(testChord, binding)) { 227 exactMatch = binding 228 } 229 } 230 231 if (exactMatch) { 232 if (exactMatch.action === null) { 233 return { type: 'unbound' } 234 } 235 return { type: 'match', action: exactMatch.action } 236 } 237 238 // No match and no potential longer chords 239 if (pending !== null) { 240 return { type: 'chord_cancelled' } 241 } 242 243 return { type: 'none' } 244}