/ keybindings / useKeybinding.ts
useKeybinding.ts
  1  import { useCallback, useEffect } from 'react'
  2  import type { InputEvent } from '../ink/events/input-event.js'
  3  import { type Key, useInput } from '../ink.js'
  4  import { useOptionalKeybindingContext } from './KeybindingContext.js'
  5  import type { KeybindingContextName } from './types.js'
  6  
  7  type 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   */
 33  export 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   */
113  export 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  }