source dump of claude code
at main 498 lines 14 kB view raw
1import { plural } from '../utils/stringUtils.js' 2import { chordToString, parseChord, parseKeystroke } from './parser.js' 3import { 4 getReservedShortcuts, 5 normalizeKeyForComparison, 6} from './reservedShortcuts.js' 7import type { 8 KeybindingBlock, 9 KeybindingContextName, 10 ParsedBinding, 11} from './types.js' 12 13/** 14 * Types of validation issues that can occur with keybindings. 15 */ 16export type KeybindingWarningType = 17 | 'parse_error' 18 | 'duplicate' 19 | 'reserved' 20 | 'invalid_context' 21 | 'invalid_action' 22 23/** 24 * A warning or error about a keybinding configuration issue. 25 */ 26export type KeybindingWarning = { 27 type: KeybindingWarningType 28 severity: 'error' | 'warning' 29 message: string 30 key?: string 31 context?: string 32 action?: string 33 suggestion?: string 34} 35 36/** 37 * Type guard to check if an object is a valid KeybindingBlock. 38 */ 39function isKeybindingBlock(obj: unknown): obj is KeybindingBlock { 40 if (typeof obj !== 'object' || obj === null) return false 41 const b = obj as Record<string, unknown> 42 return ( 43 typeof b.context === 'string' && 44 typeof b.bindings === 'object' && 45 b.bindings !== null 46 ) 47} 48 49/** 50 * Type guard to check if an array contains only valid KeybindingBlocks. 51 */ 52function isKeybindingBlockArray(arr: unknown): arr is KeybindingBlock[] { 53 return Array.isArray(arr) && arr.every(isKeybindingBlock) 54} 55 56/** 57 * Valid context names for keybindings. 58 * Must match KeybindingContextName in types.ts 59 */ 60const VALID_CONTEXTS: KeybindingContextName[] = [ 61 'Global', 62 'Chat', 63 'Autocomplete', 64 'Confirmation', 65 'Help', 66 'Transcript', 67 'HistorySearch', 68 'Task', 69 'ThemePicker', 70 'Settings', 71 'Tabs', 72 'Attachments', 73 'Footer', 74 'MessageSelector', 75 'DiffDialog', 76 'ModelPicker', 77 'Select', 78 'Plugin', 79] 80 81/** 82 * Type guard to check if a string is a valid context name. 83 */ 84function isValidContext(value: string): value is KeybindingContextName { 85 return (VALID_CONTEXTS as readonly string[]).includes(value) 86} 87 88/** 89 * Validate a single keystroke string and return any parse errors. 90 */ 91function validateKeystroke(keystroke: string): KeybindingWarning | null { 92 const parts = keystroke.toLowerCase().split('+') 93 94 for (const part of parts) { 95 const trimmed = part.trim() 96 if (!trimmed) { 97 return { 98 type: 'parse_error', 99 severity: 'error', 100 message: `Empty key part in "${keystroke}"`, 101 key: keystroke, 102 suggestion: 'Remove extra "+" characters', 103 } 104 } 105 } 106 107 // Try to parse and see if it fails 108 const parsed = parseKeystroke(keystroke) 109 if ( 110 !parsed.key && 111 !parsed.ctrl && 112 !parsed.alt && 113 !parsed.shift && 114 !parsed.meta 115 ) { 116 return { 117 type: 'parse_error', 118 severity: 'error', 119 message: `Could not parse keystroke "${keystroke}"`, 120 key: keystroke, 121 } 122 } 123 124 return null 125} 126 127/** 128 * Validate a keybinding block from user config. 129 */ 130function validateBlock( 131 block: unknown, 132 blockIndex: number, 133): KeybindingWarning[] { 134 const warnings: KeybindingWarning[] = [] 135 136 if (typeof block !== 'object' || block === null) { 137 warnings.push({ 138 type: 'parse_error', 139 severity: 'error', 140 message: `Keybinding block ${blockIndex + 1} is not an object`, 141 }) 142 return warnings 143 } 144 145 const b = block as Record<string, unknown> 146 147 // Validate context - extract to narrowed variable for type safety 148 const rawContext = b.context 149 let contextName: string | undefined 150 if (typeof rawContext !== 'string') { 151 warnings.push({ 152 type: 'parse_error', 153 severity: 'error', 154 message: `Keybinding block ${blockIndex + 1} missing "context" field`, 155 }) 156 } else if (!isValidContext(rawContext)) { 157 warnings.push({ 158 type: 'invalid_context', 159 severity: 'error', 160 message: `Unknown context "${rawContext}"`, 161 context: rawContext, 162 suggestion: `Valid contexts: ${VALID_CONTEXTS.join(', ')}`, 163 }) 164 } else { 165 contextName = rawContext 166 } 167 168 // Validate bindings 169 if (typeof b.bindings !== 'object' || b.bindings === null) { 170 warnings.push({ 171 type: 'parse_error', 172 severity: 'error', 173 message: `Keybinding block ${blockIndex + 1} missing "bindings" field`, 174 }) 175 return warnings 176 } 177 178 const bindings = b.bindings as Record<string, unknown> 179 for (const [key, action] of Object.entries(bindings)) { 180 // Validate key syntax 181 const keyError = validateKeystroke(key) 182 if (keyError) { 183 keyError.context = contextName 184 warnings.push(keyError) 185 } 186 187 // Validate action 188 if (action !== null && typeof action !== 'string') { 189 warnings.push({ 190 type: 'invalid_action', 191 severity: 'error', 192 message: `Invalid action for "${key}": must be a string or null`, 193 key, 194 context: contextName, 195 }) 196 } else if (typeof action === 'string' && action.startsWith('command:')) { 197 // Validate command binding format 198 if (!/^command:[a-zA-Z0-9:\-_]+$/.test(action)) { 199 warnings.push({ 200 type: 'invalid_action', 201 severity: 'warning', 202 message: `Invalid command binding "${action}" for "${key}": command name may only contain alphanumeric characters, colons, hyphens, and underscores`, 203 key, 204 context: contextName, 205 action, 206 }) 207 } 208 // Command bindings must be in Chat context 209 if (contextName && contextName !== 'Chat') { 210 warnings.push({ 211 type: 'invalid_action', 212 severity: 'warning', 213 message: `Command binding "${action}" must be in "Chat" context, not "${contextName}"`, 214 key, 215 context: contextName, 216 action, 217 suggestion: 'Move this binding to a block with "context": "Chat"', 218 }) 219 } 220 } else if (action === 'voice:pushToTalk') { 221 // Hold detection needs OS auto-repeat. Bare letters print into the 222 // input during warmup and the activation strip is best-effort — 223 // space (default) or a modifier combo like meta+k avoid that. 224 const ks = parseChord(key)[0] 225 if ( 226 ks && 227 !ks.ctrl && 228 !ks.alt && 229 !ks.shift && 230 !ks.meta && 231 !ks.super && 232 /^[a-z]$/.test(ks.key) 233 ) { 234 warnings.push({ 235 type: 'invalid_action', 236 severity: 'warning', 237 message: `Binding "${key}" to voice:pushToTalk prints into the input during warmup; use space or a modifier combo like meta+k`, 238 key, 239 context: contextName, 240 action, 241 }) 242 } 243 } 244 } 245 246 return warnings 247} 248 249/** 250 * Detect duplicate keys within the same bindings block in a JSON string. 251 * JSON.parse silently uses the last value for duplicate keys, 252 * so we need to check the raw string to warn users. 253 * 254 * Only warns about duplicates within the same context's bindings object. 255 * Duplicates across different contexts are allowed (e.g., "enter" in Chat 256 * and "enter" in Confirmation). 257 */ 258export function checkDuplicateKeysInJson( 259 jsonString: string, 260): KeybindingWarning[] { 261 const warnings: KeybindingWarning[] = [] 262 263 // Find each "bindings" block and check for duplicates within it 264 // Pattern: "bindings" : { ... } 265 const bindingsBlockPattern = 266 /"bindings"\s*:\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g 267 268 let blockMatch 269 while ((blockMatch = bindingsBlockPattern.exec(jsonString)) !== null) { 270 const blockContent = blockMatch[1] 271 if (!blockContent) continue 272 273 // Find the context for this block by looking backwards 274 const textBeforeBlock = jsonString.slice(0, blockMatch.index) 275 const contextMatch = textBeforeBlock.match( 276 /"context"\s*:\s*"([^"]+)"[^{]*$/, 277 ) 278 const context = contextMatch?.[1] ?? 'unknown' 279 280 // Find all keys within this bindings block 281 const keyPattern = /"([^"]+)"\s*:/g 282 const keysByName = new Map<string, number>() 283 284 let keyMatch 285 while ((keyMatch = keyPattern.exec(blockContent)) !== null) { 286 const key = keyMatch[1] 287 if (!key) continue 288 289 const count = (keysByName.get(key) ?? 0) + 1 290 keysByName.set(key, count) 291 292 if (count === 2) { 293 // Only warn on the second occurrence 294 warnings.push({ 295 type: 'duplicate', 296 severity: 'warning', 297 message: `Duplicate key "${key}" in ${context} bindings`, 298 key, 299 context, 300 suggestion: `This key appears multiple times in the same context. JSON uses the last value, earlier values are ignored.`, 301 }) 302 } 303 } 304 } 305 306 return warnings 307} 308 309/** 310 * Validate user keybinding config and return all warnings. 311 */ 312export function validateUserConfig(userBlocks: unknown): KeybindingWarning[] { 313 const warnings: KeybindingWarning[] = [] 314 315 if (!Array.isArray(userBlocks)) { 316 warnings.push({ 317 type: 'parse_error', 318 severity: 'error', 319 message: 'keybindings.json must contain an array', 320 suggestion: 'Wrap your bindings in [ ]', 321 }) 322 return warnings 323 } 324 325 for (let i = 0; i < userBlocks.length; i++) { 326 warnings.push(...validateBlock(userBlocks[i], i)) 327 } 328 329 return warnings 330} 331 332/** 333 * Check for duplicate bindings within the same context. 334 * Only checks user bindings (not default + user merged). 335 */ 336export function checkDuplicates( 337 blocks: KeybindingBlock[], 338): KeybindingWarning[] { 339 const warnings: KeybindingWarning[] = [] 340 const seenByContext = new Map<string, Map<string, string>>() 341 342 for (const block of blocks) { 343 const contextMap = 344 seenByContext.get(block.context) ?? new Map<string, string>() 345 seenByContext.set(block.context, contextMap) 346 347 for (const [key, action] of Object.entries(block.bindings)) { 348 const normalizedKey = normalizeKeyForComparison(key) 349 const existingAction = contextMap.get(normalizedKey) 350 351 if (existingAction && existingAction !== action) { 352 warnings.push({ 353 type: 'duplicate', 354 severity: 'warning', 355 message: `Duplicate binding "${key}" in ${block.context} context`, 356 key, 357 context: block.context, 358 action: action ?? 'null (unbind)', 359 suggestion: `Previously bound to "${existingAction}". Only the last binding will be used.`, 360 }) 361 } 362 363 contextMap.set(normalizedKey, action ?? 'null') 364 } 365 } 366 367 return warnings 368} 369 370/** 371 * Check for reserved shortcuts that may not work. 372 */ 373export function checkReservedShortcuts( 374 bindings: ParsedBinding[], 375): KeybindingWarning[] { 376 const warnings: KeybindingWarning[] = [] 377 const reserved = getReservedShortcuts() 378 379 for (const binding of bindings) { 380 const keyDisplay = chordToString(binding.chord) 381 const normalizedKey = normalizeKeyForComparison(keyDisplay) 382 383 // Check against reserved shortcuts 384 for (const res of reserved) { 385 if (normalizeKeyForComparison(res.key) === normalizedKey) { 386 warnings.push({ 387 type: 'reserved', 388 severity: res.severity, 389 message: `"${keyDisplay}" may not work: ${res.reason}`, 390 key: keyDisplay, 391 context: binding.context, 392 action: binding.action ?? undefined, 393 }) 394 } 395 } 396 } 397 398 return warnings 399} 400 401/** 402 * Parse user blocks into bindings for validation. 403 * This is separate from the main parser to avoid importing it. 404 */ 405function getUserBindingsForValidation( 406 userBlocks: KeybindingBlock[], 407): ParsedBinding[] { 408 const bindings: ParsedBinding[] = [] 409 for (const block of userBlocks) { 410 for (const [key, action] of Object.entries(block.bindings)) { 411 const chord = key.split(' ').map(k => parseKeystroke(k)) 412 bindings.push({ 413 chord, 414 action, 415 context: block.context, 416 }) 417 } 418 } 419 return bindings 420} 421 422/** 423 * Run all validations and return combined warnings. 424 */ 425export function validateBindings( 426 userBlocks: unknown, 427 _parsedBindings: ParsedBinding[], 428): KeybindingWarning[] { 429 const warnings: KeybindingWarning[] = [] 430 431 // Validate user config structure 432 warnings.push(...validateUserConfig(userBlocks)) 433 434 // Check for duplicates in user config 435 if (isKeybindingBlockArray(userBlocks)) { 436 warnings.push(...checkDuplicates(userBlocks)) 437 438 // Check for reserved/conflicting shortcuts - only check USER bindings 439 const userBindings = getUserBindingsForValidation(userBlocks) 440 warnings.push(...checkReservedShortcuts(userBindings)) 441 } 442 443 // Deduplicate warnings (same key+context+type) 444 const seen = new Set<string>() 445 return warnings.filter(w => { 446 const key = `${w.type}:${w.key}:${w.context}` 447 if (seen.has(key)) return false 448 seen.add(key) 449 return true 450 }) 451} 452 453/** 454 * Format a warning for display to the user. 455 */ 456export function formatWarning(warning: KeybindingWarning): string { 457 const icon = warning.severity === 'error' ? '✗' : '⚠' 458 let msg = `${icon} Keybinding ${warning.severity}: ${warning.message}` 459 460 if (warning.suggestion) { 461 msg += `\n ${warning.suggestion}` 462 } 463 464 return msg 465} 466 467/** 468 * Format multiple warnings for display. 469 */ 470export function formatWarnings(warnings: KeybindingWarning[]): string { 471 if (warnings.length === 0) return '' 472 473 const errors = warnings.filter(w => w.severity === 'error') 474 const warns = warnings.filter(w => w.severity === 'warning') 475 476 const lines: string[] = [] 477 478 if (errors.length > 0) { 479 lines.push( 480 `Found ${errors.length} keybinding ${plural(errors.length, 'error')}:`, 481 ) 482 for (const e of errors) { 483 lines.push(formatWarning(e)) 484 } 485 } 486 487 if (warns.length > 0) { 488 if (lines.length > 0) lines.push('') 489 lines.push( 490 `Found ${warns.length} keybinding ${plural(warns.length, 'warning')}:`, 491 ) 492 for (const w of warns) { 493 lines.push(formatWarning(w)) 494 } 495 } 496 497 return lines.join('\n') 498}