import { useCallback, useEffect } from 'react' import type { InputEvent } from '../ink/events/input-event.js' import { type Key, useInput } from '../ink.js' import { useOptionalKeybindingContext } from './KeybindingContext.js' import type { KeybindingContextName } from './types.js' type Options = { /** Which context this binding belongs to (default: 'Global') */ context?: KeybindingContextName /** Only handle when active (like useInput's isActive) */ isActive?: boolean } /** * Ink-native hook for handling a keybinding. * * The handler stays in the component (React way). * The binding (keystroke → action) comes from config. * * Supports chord sequences (e.g., "ctrl+k ctrl+s"). When a chord is started, * the hook will manage the pending state automatically. * * Uses stopImmediatePropagation() to prevent other handlers from firing * once this binding is handled. * * @example * ```tsx * useKeybinding('app:toggleTodos', () => { * setShowTodos(prev => !prev) * }, { context: 'Global' }) * ``` */ export function useKeybinding( action: string, handler: () => void | false | Promise, options: Options = {}, ): void { const { context = 'Global', isActive = true } = options const keybindingContext = useOptionalKeybindingContext() // Register handler with the context for ChordInterceptor to invoke useEffect(() => { if (!keybindingContext || !isActive) return return keybindingContext.registerHandler({ action, context, handler }) }, [action, context, handler, keybindingContext, isActive]) const handleInput = useCallback( (input: string, key: Key, event: InputEvent) => { // If no keybinding context available, skip resolution if (!keybindingContext) return // Build context list: registered active contexts + this context + Global // More specific contexts (registered ones) take precedence over Global const contextsToCheck: KeybindingContextName[] = [ ...keybindingContext.activeContexts, context, 'Global', ] // Deduplicate while preserving order (first occurrence wins for priority) const uniqueContexts = [...new Set(contextsToCheck)] const result = keybindingContext.resolve(input, key, uniqueContexts) switch (result.type) { case 'match': // Chord completed (if any) - clear pending state keybindingContext.setPendingChord(null) if (result.action === action) { if (handler() !== false) { event.stopImmediatePropagation() } } break case 'chord_started': // User started a chord sequence - update pending state keybindingContext.setPendingChord(result.pending) event.stopImmediatePropagation() break case 'chord_cancelled': // Chord was cancelled (escape or invalid key) keybindingContext.setPendingChord(null) break case 'unbound': // Explicitly unbound - clear any pending chord keybindingContext.setPendingChord(null) event.stopImmediatePropagation() break case 'none': // No match - let other handlers try break } }, [action, context, handler, keybindingContext], ) useInput(handleInput, { isActive }) } /** * Handle multiple keybindings in one hook (reduces useInput calls). * * Supports chord sequences. When a chord is started, the hook will * manage the pending state automatically. * * @example * ```tsx * useKeybindings({ * 'chat:submit': () => handleSubmit(), * 'chat:cancel': () => handleCancel(), * }, { context: 'Chat' }) * ``` */ export function useKeybindings( // Handler returning `false` means "not consumed" — the event propagates // to later useInput/useKeybindings handlers. Useful for fall-through: // e.g. ScrollKeybindingHandler's scroll:line* returns false when the // ScrollBox content fits (scroll is a no-op), letting a child component's // handler take the wheel event for list navigation instead. Promise // is allowed for fire-and-forget async handlers (the `!== false` check // only skips propagation for a sync `false`, not a pending Promise). handlers: Record void | false | Promise>, options: Options = {}, ): void { const { context = 'Global', isActive = true } = options const keybindingContext = useOptionalKeybindingContext() // Register all handlers with the context for ChordInterceptor to invoke useEffect(() => { if (!keybindingContext || !isActive) return const unregisterFns: Array<() => void> = [] for (const [action, handler] of Object.entries(handlers)) { unregisterFns.push( keybindingContext.registerHandler({ action, context, handler }), ) } return () => { for (const unregister of unregisterFns) { unregister() } } }, [context, handlers, keybindingContext, isActive]) const handleInput = useCallback( (input: string, key: Key, event: InputEvent) => { // If no keybinding context available, skip resolution if (!keybindingContext) return // Build context list: registered active contexts + this context + Global // More specific contexts (registered ones) take precedence over Global const contextsToCheck: KeybindingContextName[] = [ ...keybindingContext.activeContexts, context, 'Global', ] // Deduplicate while preserving order (first occurrence wins for priority) const uniqueContexts = [...new Set(contextsToCheck)] const result = keybindingContext.resolve(input, key, uniqueContexts) switch (result.type) { case 'match': // Chord completed (if any) - clear pending state keybindingContext.setPendingChord(null) if (result.action in handlers) { const handler = handlers[result.action] if (handler && handler() !== false) { event.stopImmediatePropagation() } } break case 'chord_started': // User started a chord sequence - update pending state keybindingContext.setPendingChord(result.pending) event.stopImmediatePropagation() break case 'chord_cancelled': // Chord was cancelled (escape or invalid key) keybindingContext.setPendingChord(null) break case 'unbound': // Explicitly unbound - clear any pending chord keybindingContext.setPendingChord(null) event.stopImmediatePropagation() break case 'none': // No match - let other handlers try break } }, [context, handlers, keybindingContext], ) useInput(handleInput, { isActive }) }