π File detail
keybindings/useKeybinding.ts
π― Use case
This file lives under βkeybindings/β, which covers keyboard shortcuts and binding tables. On the API surface it exposes useKeybinding and useKeybindings β mainly functions, hooks, or classes. Dependencies touch React UI. It composes internal code from ink, KeybindingContext, and types (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
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'
π€ Exports (heuristic)
useKeybindinguseKeybindings
π External import roots
Package roots from from "β¦" (relative paths omitted).
react
π₯οΈ Source preview
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<void>,
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<void>
// is allowed for fire-and-forget async handlers (the `!== false` check
// only skips propagation for a sync `false`, not a pending Promise).
handlers: Record<string, () => void | false | Promise<void>>,
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 })
}