source dump of claude code
at main 196 lines 6.9 kB view raw
1import { useCallback, useEffect } from 'react' 2import type { InputEvent } from '../ink/events/input-event.js' 3import { type Key, useInput } from '../ink.js' 4import { useOptionalKeybindingContext } from './KeybindingContext.js' 5import type { KeybindingContextName } from './types.js' 6 7type Options = { 8 /** Which context this binding belongs to (default: 'Global') */ 9 context?: KeybindingContextName 10 /** Only handle when active (like useInput's isActive) */ 11 isActive?: boolean 12} 13 14/** 15 * Ink-native hook for handling a keybinding. 16 * 17 * The handler stays in the component (React way). 18 * The binding (keystroke → action) comes from config. 19 * 20 * Supports chord sequences (e.g., "ctrl+k ctrl+s"). When a chord is started, 21 * the hook will manage the pending state automatically. 22 * 23 * Uses stopImmediatePropagation() to prevent other handlers from firing 24 * once this binding is handled. 25 * 26 * @example 27 * ```tsx 28 * useKeybinding('app:toggleTodos', () => { 29 * setShowTodos(prev => !prev) 30 * }, { context: 'Global' }) 31 * ``` 32 */ 33export function useKeybinding( 34 action: string, 35 handler: () => void | false | Promise<void>, 36 options: Options = {}, 37): void { 38 const { context = 'Global', isActive = true } = options 39 const keybindingContext = useOptionalKeybindingContext() 40 41 // Register handler with the context for ChordInterceptor to invoke 42 useEffect(() => { 43 if (!keybindingContext || !isActive) return 44 return keybindingContext.registerHandler({ action, context, handler }) 45 }, [action, context, handler, keybindingContext, isActive]) 46 47 const handleInput = useCallback( 48 (input: string, key: Key, event: InputEvent) => { 49 // If no keybinding context available, skip resolution 50 if (!keybindingContext) return 51 52 // Build context list: registered active contexts + this context + Global 53 // More specific contexts (registered ones) take precedence over Global 54 const contextsToCheck: KeybindingContextName[] = [ 55 ...keybindingContext.activeContexts, 56 context, 57 'Global', 58 ] 59 // Deduplicate while preserving order (first occurrence wins for priority) 60 const uniqueContexts = [...new Set(contextsToCheck)] 61 62 const result = keybindingContext.resolve(input, key, uniqueContexts) 63 64 switch (result.type) { 65 case 'match': 66 // Chord completed (if any) - clear pending state 67 keybindingContext.setPendingChord(null) 68 if (result.action === action) { 69 if (handler() !== false) { 70 event.stopImmediatePropagation() 71 } 72 } 73 break 74 case 'chord_started': 75 // User started a chord sequence - update pending state 76 keybindingContext.setPendingChord(result.pending) 77 event.stopImmediatePropagation() 78 break 79 case 'chord_cancelled': 80 // Chord was cancelled (escape or invalid key) 81 keybindingContext.setPendingChord(null) 82 break 83 case 'unbound': 84 // Explicitly unbound - clear any pending chord 85 keybindingContext.setPendingChord(null) 86 event.stopImmediatePropagation() 87 break 88 case 'none': 89 // No match - let other handlers try 90 break 91 } 92 }, 93 [action, context, handler, keybindingContext], 94 ) 95 96 useInput(handleInput, { isActive }) 97} 98 99/** 100 * Handle multiple keybindings in one hook (reduces useInput calls). 101 * 102 * Supports chord sequences. When a chord is started, the hook will 103 * manage the pending state automatically. 104 * 105 * @example 106 * ```tsx 107 * useKeybindings({ 108 * 'chat:submit': () => handleSubmit(), 109 * 'chat:cancel': () => handleCancel(), 110 * }, { context: 'Chat' }) 111 * ``` 112 */ 113export function useKeybindings( 114 // Handler returning `false` means "not consumed" — the event propagates 115 // to later useInput/useKeybindings handlers. Useful for fall-through: 116 // e.g. ScrollKeybindingHandler's scroll:line* returns false when the 117 // ScrollBox content fits (scroll is a no-op), letting a child component's 118 // handler take the wheel event for list navigation instead. Promise<void> 119 // is allowed for fire-and-forget async handlers (the `!== false` check 120 // only skips propagation for a sync `false`, not a pending Promise). 121 handlers: Record<string, () => void | false | Promise<void>>, 122 options: Options = {}, 123): void { 124 const { context = 'Global', isActive = true } = options 125 const keybindingContext = useOptionalKeybindingContext() 126 127 // Register all handlers with the context for ChordInterceptor to invoke 128 useEffect(() => { 129 if (!keybindingContext || !isActive) return 130 131 const unregisterFns: Array<() => void> = [] 132 for (const [action, handler] of Object.entries(handlers)) { 133 unregisterFns.push( 134 keybindingContext.registerHandler({ action, context, handler }), 135 ) 136 } 137 138 return () => { 139 for (const unregister of unregisterFns) { 140 unregister() 141 } 142 } 143 }, [context, handlers, keybindingContext, isActive]) 144 145 const handleInput = useCallback( 146 (input: string, key: Key, event: InputEvent) => { 147 // If no keybinding context available, skip resolution 148 if (!keybindingContext) return 149 150 // Build context list: registered active contexts + this context + Global 151 // More specific contexts (registered ones) take precedence over Global 152 const contextsToCheck: KeybindingContextName[] = [ 153 ...keybindingContext.activeContexts, 154 context, 155 'Global', 156 ] 157 // Deduplicate while preserving order (first occurrence wins for priority) 158 const uniqueContexts = [...new Set(contextsToCheck)] 159 160 const result = keybindingContext.resolve(input, key, uniqueContexts) 161 162 switch (result.type) { 163 case 'match': 164 // Chord completed (if any) - clear pending state 165 keybindingContext.setPendingChord(null) 166 if (result.action in handlers) { 167 const handler = handlers[result.action] 168 if (handler && handler() !== false) { 169 event.stopImmediatePropagation() 170 } 171 } 172 break 173 case 'chord_started': 174 // User started a chord sequence - update pending state 175 keybindingContext.setPendingChord(result.pending) 176 event.stopImmediatePropagation() 177 break 178 case 'chord_cancelled': 179 // Chord was cancelled (escape or invalid key) 180 keybindingContext.setPendingChord(null) 181 break 182 case 'unbound': 183 // Explicitly unbound - clear any pending chord 184 keybindingContext.setPendingChord(null) 185 event.stopImmediatePropagation() 186 break 187 case 'none': 188 // No match - let other handlers try 189 break 190 } 191 }, 192 [context, handlers, keybindingContext], 193 ) 194 195 useInput(handleInput, { isActive }) 196}