mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 16:25:51 +00:00
feat: 将 keybinding 纳入 ink 管辖
This commit is contained in:
@@ -16,6 +16,48 @@ export type { RenderOptions, Instance, Root } from './core/root.js'
|
|||||||
// InkCore class
|
// InkCore class
|
||||||
export { default as Ink } from './core/ink.js'
|
export { default as Ink } from './core/ink.js'
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Keybindings
|
||||||
|
// ============================================================
|
||||||
|
export { useKeybinding, useKeybindings } from './keybindings/useKeybinding.js'
|
||||||
|
export {
|
||||||
|
KeybindingProvider,
|
||||||
|
useKeybindingContext,
|
||||||
|
useOptionalKeybindingContext,
|
||||||
|
useRegisterKeybindingContext,
|
||||||
|
} from './keybindings/KeybindingContext.js'
|
||||||
|
export {
|
||||||
|
resolveKey,
|
||||||
|
resolveKeyWithChordState,
|
||||||
|
getBindingDisplayText,
|
||||||
|
keystrokesEqual,
|
||||||
|
type ResolveResult,
|
||||||
|
type ChordResolveResult,
|
||||||
|
} from './keybindings/resolver.js'
|
||||||
|
export {
|
||||||
|
parseKeystroke,
|
||||||
|
parseChord,
|
||||||
|
keystrokeToString,
|
||||||
|
chordToString,
|
||||||
|
keystrokeToDisplayString,
|
||||||
|
chordToDisplayString,
|
||||||
|
parseBindings,
|
||||||
|
} from './keybindings/parser.js'
|
||||||
|
export {
|
||||||
|
getKeyName,
|
||||||
|
matchesKeystroke,
|
||||||
|
matchesBinding,
|
||||||
|
} from './keybindings/match.js'
|
||||||
|
export type {
|
||||||
|
ParsedBinding,
|
||||||
|
ParsedKeystroke,
|
||||||
|
KeybindingContextName,
|
||||||
|
KeybindingBlock,
|
||||||
|
Chord,
|
||||||
|
KeybindingAction,
|
||||||
|
} from './keybindings/types.js'
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Core types
|
// Core types
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
225
packages/@ant/ink/src/keybindings/KeybindingContext.tsx
Normal file
225
packages/@ant/ink/src/keybindings/KeybindingContext.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
type RefObject,
|
||||||
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
} from 'react'
|
||||||
|
import type { Key } from '../core/events/input-event.js'
|
||||||
|
import {
|
||||||
|
type ChordResolveResult,
|
||||||
|
getBindingDisplayText,
|
||||||
|
resolveKeyWithChordState,
|
||||||
|
} from './resolver.js'
|
||||||
|
import type {
|
||||||
|
KeybindingContextName,
|
||||||
|
ParsedBinding,
|
||||||
|
ParsedKeystroke,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
/** Handler registration for action callbacks */
|
||||||
|
type HandlerRegistration = {
|
||||||
|
action: string
|
||||||
|
context: KeybindingContextName
|
||||||
|
handler: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeybindingContextValue = {
|
||||||
|
/** Resolve a key input to an action name (with chord support) */
|
||||||
|
resolve: (
|
||||||
|
input: string,
|
||||||
|
key: Key,
|
||||||
|
activeContexts: KeybindingContextName[],
|
||||||
|
) => ChordResolveResult
|
||||||
|
|
||||||
|
/** Update the pending chord state */
|
||||||
|
setPendingChord: (pending: ParsedKeystroke[] | null) => void
|
||||||
|
|
||||||
|
/** Get display text for an action (e.g., "ctrl+t") */
|
||||||
|
getDisplayText: (
|
||||||
|
action: string,
|
||||||
|
context: KeybindingContextName,
|
||||||
|
) => string | undefined
|
||||||
|
|
||||||
|
/** All parsed bindings (for help display) */
|
||||||
|
bindings: ParsedBinding[]
|
||||||
|
|
||||||
|
/** Current pending chord keystrokes (null if not in a chord) */
|
||||||
|
pendingChord: ParsedKeystroke[] | null
|
||||||
|
|
||||||
|
/** Currently active keybinding contexts (for priority resolution) */
|
||||||
|
activeContexts: Set<KeybindingContextName>
|
||||||
|
|
||||||
|
/** Register a context as active (call on mount) */
|
||||||
|
registerActiveContext: (context: KeybindingContextName) => void
|
||||||
|
|
||||||
|
/** Unregister a context (call on unmount) */
|
||||||
|
unregisterActiveContext: (context: KeybindingContextName) => void
|
||||||
|
|
||||||
|
/** Register a handler for an action (used by useKeybinding) */
|
||||||
|
registerHandler: (registration: HandlerRegistration) => () => void
|
||||||
|
|
||||||
|
/** Invoke all handlers for an action (used by ChordInterceptor) */
|
||||||
|
invokeAction: (action: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const KeybindingContext = createContext<KeybindingContextValue | null>(null)
|
||||||
|
|
||||||
|
type ProviderProps = {
|
||||||
|
bindings: ParsedBinding[]
|
||||||
|
/** Ref for immediate access to pending chord (avoids React state delay) */
|
||||||
|
pendingChordRef: RefObject<ParsedKeystroke[] | null>
|
||||||
|
/** State value for re-renders (UI updates) */
|
||||||
|
pendingChord: ParsedKeystroke[] | null
|
||||||
|
setPendingChord: (pending: ParsedKeystroke[] | null) => void
|
||||||
|
activeContexts: Set<KeybindingContextName>
|
||||||
|
registerActiveContext: (context: KeybindingContextName) => void
|
||||||
|
unregisterActiveContext: (context: KeybindingContextName) => void
|
||||||
|
/** Ref to handler registry (used by ChordInterceptor) */
|
||||||
|
handlerRegistryRef: RefObject<Map<string, Set<HandlerRegistration>>>
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeybindingProvider({
|
||||||
|
bindings,
|
||||||
|
pendingChordRef,
|
||||||
|
pendingChord,
|
||||||
|
setPendingChord,
|
||||||
|
activeContexts,
|
||||||
|
registerActiveContext,
|
||||||
|
unregisterActiveContext,
|
||||||
|
handlerRegistryRef,
|
||||||
|
children,
|
||||||
|
}: ProviderProps): React.ReactNode {
|
||||||
|
const value = useMemo<KeybindingContextValue>(() => {
|
||||||
|
const getDisplay = (action: string, context: KeybindingContextName) =>
|
||||||
|
getBindingDisplayText(action, context, bindings)
|
||||||
|
|
||||||
|
// Register a handler for an action
|
||||||
|
const registerHandler = (registration: HandlerRegistration) => {
|
||||||
|
const registry = handlerRegistryRef.current
|
||||||
|
if (!registry) return () => {}
|
||||||
|
|
||||||
|
if (!registry.has(registration.action)) {
|
||||||
|
registry.set(registration.action, new Set())
|
||||||
|
}
|
||||||
|
registry.get(registration.action)!.add(registration)
|
||||||
|
|
||||||
|
// Return unregister function
|
||||||
|
return () => {
|
||||||
|
const handlers = registry.get(registration.action)
|
||||||
|
if (handlers) {
|
||||||
|
handlers.delete(registration)
|
||||||
|
if (handlers.size === 0) {
|
||||||
|
registry.delete(registration.action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke all handlers for an action
|
||||||
|
const invokeAction = (action: string): boolean => {
|
||||||
|
const registry = handlerRegistryRef.current
|
||||||
|
if (!registry) return false
|
||||||
|
|
||||||
|
const handlers = registry.get(action)
|
||||||
|
if (!handlers || handlers.size === 0) return false
|
||||||
|
|
||||||
|
// Find handlers whose context is active
|
||||||
|
for (const registration of handlers) {
|
||||||
|
if (activeContexts.has(registration.context)) {
|
||||||
|
registration.handler()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Use ref for immediate access to pending chord, avoiding React state delay
|
||||||
|
// This is critical for chord sequences where the second key might be pressed
|
||||||
|
// before React re-renders with the updated pendingChord state
|
||||||
|
resolve: (input, key, contexts) =>
|
||||||
|
resolveKeyWithChordState(
|
||||||
|
input,
|
||||||
|
key,
|
||||||
|
contexts,
|
||||||
|
bindings,
|
||||||
|
pendingChordRef.current,
|
||||||
|
),
|
||||||
|
setPendingChord,
|
||||||
|
getDisplayText: getDisplay,
|
||||||
|
bindings,
|
||||||
|
pendingChord,
|
||||||
|
activeContexts,
|
||||||
|
registerActiveContext,
|
||||||
|
unregisterActiveContext,
|
||||||
|
registerHandler,
|
||||||
|
invokeAction,
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
bindings,
|
||||||
|
pendingChordRef,
|
||||||
|
pendingChord,
|
||||||
|
setPendingChord,
|
||||||
|
activeContexts,
|
||||||
|
registerActiveContext,
|
||||||
|
unregisterActiveContext,
|
||||||
|
handlerRegistryRef,
|
||||||
|
])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeybindingContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</KeybindingContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKeybindingContext(): KeybindingContextValue {
|
||||||
|
const ctx = useContext(KeybindingContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
'useKeybindingContext must be used within KeybindingProvider',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional hook that returns undefined outside of KeybindingProvider.
|
||||||
|
* Useful for components that may render before provider is available.
|
||||||
|
*/
|
||||||
|
export function useOptionalKeybindingContext(): KeybindingContextValue | null {
|
||||||
|
return useContext(KeybindingContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to register a keybinding context as active while the component is mounted.
|
||||||
|
*
|
||||||
|
* When a context is registered, its keybindings take precedence over Global bindings.
|
||||||
|
* This allows context-specific bindings (like ThemePicker's ctrl+t) to override
|
||||||
|
* global bindings (like the todo toggle) when the context is active.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function ThemePicker() {
|
||||||
|
* useRegisterKeybindingContext('ThemePicker')
|
||||||
|
* // Now ThemePicker's ctrl+t binding takes precedence over Global
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useRegisterKeybindingContext(
|
||||||
|
context: KeybindingContextName,
|
||||||
|
isActive: boolean = true,
|
||||||
|
): void {
|
||||||
|
const keybindingContext = useOptionalKeybindingContext()
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!keybindingContext || !isActive) return
|
||||||
|
|
||||||
|
keybindingContext.registerActiveContext(context)
|
||||||
|
return () => {
|
||||||
|
keybindingContext.unregisterActiveContext(context)
|
||||||
|
}
|
||||||
|
}, [context, keybindingContext, isActive])
|
||||||
|
}
|
||||||
120
packages/@ant/ink/src/keybindings/match.ts
Normal file
120
packages/@ant/ink/src/keybindings/match.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import type { Key } from '../core/events/input-event.js'
|
||||||
|
import type { ParsedBinding, ParsedKeystroke } from './types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modifier keys from Ink's Key type that we care about for matching.
|
||||||
|
* Note: `fn` from Key is intentionally excluded as it's rarely used and
|
||||||
|
* not commonly configurable in terminal applications.
|
||||||
|
*/
|
||||||
|
type InkModifiers = Pick<Key, 'ctrl' | 'shift' | 'meta' | 'super'>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract modifiers from an Ink Key object.
|
||||||
|
* This function ensures we're explicitly extracting the modifiers we care about.
|
||||||
|
*/
|
||||||
|
function getInkModifiers(key: Key): InkModifiers {
|
||||||
|
return {
|
||||||
|
ctrl: key.ctrl,
|
||||||
|
shift: key.shift,
|
||||||
|
meta: key.meta,
|
||||||
|
super: key.super,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the normalized key name from Ink's Key + input.
|
||||||
|
* Maps Ink's boolean flags (key.escape, key.return, etc.) to string names
|
||||||
|
* that match our ParsedKeystroke.key format.
|
||||||
|
*/
|
||||||
|
export function getKeyName(input: string, key: Key): string | null {
|
||||||
|
if (key.escape) return 'escape'
|
||||||
|
if (key.return) return 'enter'
|
||||||
|
if (key.tab) return 'tab'
|
||||||
|
if (key.backspace) return 'backspace'
|
||||||
|
if (key.delete) return 'delete'
|
||||||
|
if (key.upArrow) return 'up'
|
||||||
|
if (key.downArrow) return 'down'
|
||||||
|
if (key.leftArrow) return 'left'
|
||||||
|
if (key.rightArrow) return 'right'
|
||||||
|
if (key.pageUp) return 'pageup'
|
||||||
|
if (key.pageDown) return 'pagedown'
|
||||||
|
if (key.wheelUp) return 'wheelup'
|
||||||
|
if (key.wheelDown) return 'wheeldown'
|
||||||
|
if (key.home) return 'home'
|
||||||
|
if (key.end) return 'end'
|
||||||
|
if (input.length === 1) return input.toLowerCase()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if all modifiers match between Ink Key and ParsedKeystroke.
|
||||||
|
*
|
||||||
|
* Alt and Meta: Ink historically set `key.meta` for Alt/Option. A `meta`
|
||||||
|
* modifier in config is treated as an alias for `alt` — both match when
|
||||||
|
* `key.meta` is true.
|
||||||
|
*
|
||||||
|
* Super (Cmd/Win): distinct from alt/meta. Only arrives via the kitty
|
||||||
|
* keyboard protocol on supporting terminals. A `cmd`/`super` binding will
|
||||||
|
* simply never fire on terminals that don't send it.
|
||||||
|
*/
|
||||||
|
function modifiersMatch(
|
||||||
|
inkMods: InkModifiers,
|
||||||
|
target: ParsedKeystroke,
|
||||||
|
): boolean {
|
||||||
|
// Check ctrl modifier
|
||||||
|
if (inkMods.ctrl !== target.ctrl) return false
|
||||||
|
|
||||||
|
// Check shift modifier
|
||||||
|
if (inkMods.shift !== target.shift) return false
|
||||||
|
|
||||||
|
// Alt and meta both map to key.meta in Ink (terminal limitation)
|
||||||
|
// So we check if EITHER alt OR meta is required in target
|
||||||
|
const targetNeedsMeta = target.alt || target.meta
|
||||||
|
if (inkMods.meta !== targetNeedsMeta) return false
|
||||||
|
|
||||||
|
// Super (cmd/win) is a distinct modifier from alt/meta
|
||||||
|
if (inkMods.super !== target.super) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a ParsedKeystroke matches the given Ink input + Key.
|
||||||
|
*
|
||||||
|
* The display text will show platform-appropriate names (opt on macOS, alt elsewhere).
|
||||||
|
*/
|
||||||
|
export function matchesKeystroke(
|
||||||
|
input: string,
|
||||||
|
key: Key,
|
||||||
|
target: ParsedKeystroke,
|
||||||
|
): boolean {
|
||||||
|
const keyName = getKeyName(input, key)
|
||||||
|
if (keyName !== target.key) return false
|
||||||
|
|
||||||
|
const inkMods = getInkModifiers(key)
|
||||||
|
|
||||||
|
// QUIRK: Ink sets key.meta=true when escape is pressed (see input-event.ts).
|
||||||
|
// This is a legacy behavior from how escape sequences work in terminals.
|
||||||
|
// We need to ignore the meta modifier when matching the escape key itself,
|
||||||
|
// otherwise bindings like "escape" (without modifiers) would never match.
|
||||||
|
if (key.escape) {
|
||||||
|
return modifiersMatch({ ...inkMods, meta: false }, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
return modifiersMatch(inkMods, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Ink's Key + input matches a parsed binding's first keystroke.
|
||||||
|
* For single-keystroke bindings only (Phase 1).
|
||||||
|
*/
|
||||||
|
export function matchesBinding(
|
||||||
|
input: string,
|
||||||
|
key: Key,
|
||||||
|
binding: ParsedBinding,
|
||||||
|
): boolean {
|
||||||
|
if (binding.chord.length !== 1) return false
|
||||||
|
const keystroke = binding.chord[0]
|
||||||
|
if (!keystroke) return false
|
||||||
|
return matchesKeystroke(input, key, keystroke)
|
||||||
|
}
|
||||||
203
packages/@ant/ink/src/keybindings/parser.ts
Normal file
203
packages/@ant/ink/src/keybindings/parser.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import type {
|
||||||
|
Chord,
|
||||||
|
KeybindingBlock,
|
||||||
|
ParsedBinding,
|
||||||
|
ParsedKeystroke,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a keystroke string like "ctrl+shift+k" into a ParsedKeystroke.
|
||||||
|
* Supports various modifier aliases (ctrl/control, alt/opt/option/meta,
|
||||||
|
* cmd/command/super/win).
|
||||||
|
*/
|
||||||
|
export function parseKeystroke(input: string): ParsedKeystroke {
|
||||||
|
const parts = input.split('+')
|
||||||
|
const keystroke: ParsedKeystroke = {
|
||||||
|
key: '',
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
shift: false,
|
||||||
|
meta: false,
|
||||||
|
super: false,
|
||||||
|
}
|
||||||
|
for (const part of parts) {
|
||||||
|
const lower = part.toLowerCase()
|
||||||
|
switch (lower) {
|
||||||
|
case 'ctrl':
|
||||||
|
case 'control':
|
||||||
|
keystroke.ctrl = true
|
||||||
|
break
|
||||||
|
case 'alt':
|
||||||
|
case 'opt':
|
||||||
|
case 'option':
|
||||||
|
keystroke.alt = true
|
||||||
|
break
|
||||||
|
case 'shift':
|
||||||
|
keystroke.shift = true
|
||||||
|
break
|
||||||
|
case 'meta':
|
||||||
|
keystroke.meta = true
|
||||||
|
break
|
||||||
|
case 'cmd':
|
||||||
|
case 'command':
|
||||||
|
case 'super':
|
||||||
|
case 'win':
|
||||||
|
keystroke.super = true
|
||||||
|
break
|
||||||
|
case 'esc':
|
||||||
|
keystroke.key = 'escape'
|
||||||
|
break
|
||||||
|
case 'return':
|
||||||
|
keystroke.key = 'enter'
|
||||||
|
break
|
||||||
|
case 'space':
|
||||||
|
keystroke.key = ' '
|
||||||
|
break
|
||||||
|
case '↑':
|
||||||
|
keystroke.key = 'up'
|
||||||
|
break
|
||||||
|
case '↓':
|
||||||
|
keystroke.key = 'down'
|
||||||
|
break
|
||||||
|
case '←':
|
||||||
|
keystroke.key = 'left'
|
||||||
|
break
|
||||||
|
case '→':
|
||||||
|
keystroke.key = 'right'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
keystroke.key = lower
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keystroke
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a chord string like "ctrl+k ctrl+s" into an array of ParsedKeystrokes.
|
||||||
|
*/
|
||||||
|
export function parseChord(input: string): Chord {
|
||||||
|
// A lone space character IS the space key binding, not a separator
|
||||||
|
if (input === ' ') return [parseKeystroke('space')]
|
||||||
|
return input.trim().split(/\s+/).map(parseKeystroke)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a ParsedKeystroke to its canonical string representation for display.
|
||||||
|
*/
|
||||||
|
export function keystrokeToString(ks: ParsedKeystroke): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (ks.ctrl) parts.push('ctrl')
|
||||||
|
if (ks.alt) parts.push('alt')
|
||||||
|
if (ks.shift) parts.push('shift')
|
||||||
|
if (ks.meta) parts.push('meta')
|
||||||
|
if (ks.super) parts.push('cmd')
|
||||||
|
// Use readable names for display
|
||||||
|
const displayKey = keyToDisplayName(ks.key)
|
||||||
|
parts.push(displayKey)
|
||||||
|
return parts.join('+')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map internal key names to human-readable display names.
|
||||||
|
*/
|
||||||
|
function keyToDisplayName(key: string): string {
|
||||||
|
switch (key) {
|
||||||
|
case 'escape':
|
||||||
|
return 'Esc'
|
||||||
|
case ' ':
|
||||||
|
return 'Space'
|
||||||
|
case 'tab':
|
||||||
|
return 'tab'
|
||||||
|
case 'enter':
|
||||||
|
return 'Enter'
|
||||||
|
case 'backspace':
|
||||||
|
return 'Backspace'
|
||||||
|
case 'delete':
|
||||||
|
return 'Delete'
|
||||||
|
case 'up':
|
||||||
|
return '↑'
|
||||||
|
case 'down':
|
||||||
|
return '↓'
|
||||||
|
case 'left':
|
||||||
|
return '←'
|
||||||
|
case 'right':
|
||||||
|
return '→'
|
||||||
|
case 'pageup':
|
||||||
|
return 'PageUp'
|
||||||
|
case 'pagedown':
|
||||||
|
return 'PageDown'
|
||||||
|
case 'home':
|
||||||
|
return 'Home'
|
||||||
|
case 'end':
|
||||||
|
return 'End'
|
||||||
|
default:
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Chord to its canonical string representation for display.
|
||||||
|
*/
|
||||||
|
export function chordToString(chord: Chord): string {
|
||||||
|
return chord.map(keystrokeToString).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display platform type - a subset of Platform that we care about for display.
|
||||||
|
* WSL and unknown are treated as linux for display purposes.
|
||||||
|
*/
|
||||||
|
type DisplayPlatform = 'macos' | 'windows' | 'linux' | 'wsl' | 'unknown'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a ParsedKeystroke to a platform-appropriate display string.
|
||||||
|
* Uses "opt" for alt on macOS, "alt" elsewhere.
|
||||||
|
*/
|
||||||
|
export function keystrokeToDisplayString(
|
||||||
|
ks: ParsedKeystroke,
|
||||||
|
platform: DisplayPlatform = 'linux',
|
||||||
|
): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (ks.ctrl) parts.push('ctrl')
|
||||||
|
// Alt/meta are equivalent in terminals, show platform-appropriate name
|
||||||
|
if (ks.alt || ks.meta) {
|
||||||
|
// Only macOS uses "opt", all other platforms use "alt"
|
||||||
|
parts.push(platform === 'macos' ? 'opt' : 'alt')
|
||||||
|
}
|
||||||
|
if (ks.shift) parts.push('shift')
|
||||||
|
if (ks.super) {
|
||||||
|
parts.push(platform === 'macos' ? 'cmd' : 'super')
|
||||||
|
}
|
||||||
|
// Use readable names for display
|
||||||
|
const displayKey = keyToDisplayName(ks.key)
|
||||||
|
parts.push(displayKey)
|
||||||
|
return parts.join('+')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Chord to a platform-appropriate display string.
|
||||||
|
*/
|
||||||
|
export function chordToDisplayString(
|
||||||
|
chord: Chord,
|
||||||
|
platform: DisplayPlatform = 'linux',
|
||||||
|
): string {
|
||||||
|
return chord.map(ks => keystrokeToDisplayString(ks, platform)).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse keybinding blocks (from JSON config) into a flat list of ParsedBindings.
|
||||||
|
*/
|
||||||
|
export function parseBindings(blocks: KeybindingBlock[]): ParsedBinding[] {
|
||||||
|
const bindings: ParsedBinding[] = []
|
||||||
|
for (const block of blocks) {
|
||||||
|
for (const [key, action] of Object.entries(block.bindings)) {
|
||||||
|
bindings.push({
|
||||||
|
chord: parseChord(key),
|
||||||
|
action,
|
||||||
|
context: block.context,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bindings
|
||||||
|
}
|
||||||
244
packages/@ant/ink/src/keybindings/resolver.ts
Normal file
244
packages/@ant/ink/src/keybindings/resolver.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import type { Key } from '../core/events/input-event.js'
|
||||||
|
import { getKeyName, matchesBinding } from './match.js'
|
||||||
|
import { chordToString } from './parser.js'
|
||||||
|
import type {
|
||||||
|
KeybindingContextName,
|
||||||
|
ParsedBinding,
|
||||||
|
ParsedKeystroke,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
export type ResolveResult =
|
||||||
|
| { type: 'match'; action: string }
|
||||||
|
| { type: 'none' }
|
||||||
|
| { type: 'unbound' }
|
||||||
|
|
||||||
|
export type ChordResolveResult =
|
||||||
|
| { type: 'match'; action: string }
|
||||||
|
| { type: 'none' }
|
||||||
|
| { type: 'unbound' }
|
||||||
|
| { type: 'chord_started'; pending: ParsedKeystroke[] }
|
||||||
|
| { type: 'chord_cancelled' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a key input to an action.
|
||||||
|
* Pure function - no state, no side effects, just matching logic.
|
||||||
|
*
|
||||||
|
* @param input - The character input from Ink
|
||||||
|
* @param key - The Key object from Ink with modifier flags
|
||||||
|
* @param activeContexts - Array of currently active contexts (e.g., ['Chat', 'Global'])
|
||||||
|
* @param bindings - All parsed bindings to search through
|
||||||
|
* @returns The resolution result
|
||||||
|
*/
|
||||||
|
export function resolveKey(
|
||||||
|
input: string,
|
||||||
|
key: Key,
|
||||||
|
activeContexts: KeybindingContextName[],
|
||||||
|
bindings: ParsedBinding[],
|
||||||
|
): ResolveResult {
|
||||||
|
// Find matching bindings (last one wins for user overrides)
|
||||||
|
let match: ParsedBinding | undefined
|
||||||
|
const ctxSet = new Set(activeContexts)
|
||||||
|
|
||||||
|
for (const binding of bindings) {
|
||||||
|
// Phase 1: Only single-keystroke bindings
|
||||||
|
if (binding.chord.length !== 1) continue
|
||||||
|
if (!ctxSet.has(binding.context)) continue
|
||||||
|
|
||||||
|
if (matchesBinding(input, key, binding)) {
|
||||||
|
match = binding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return { type: 'none' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.action === null) {
|
||||||
|
return { type: 'unbound' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'match', action: match.action }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display text for an action from bindings (e.g., "ctrl+t" for "app:toggleTodos").
|
||||||
|
* Searches in reverse order so user overrides take precedence.
|
||||||
|
*/
|
||||||
|
export function getBindingDisplayText(
|
||||||
|
action: string,
|
||||||
|
context: KeybindingContextName,
|
||||||
|
bindings: ParsedBinding[],
|
||||||
|
): string | undefined {
|
||||||
|
// Find the last binding for this action in this context
|
||||||
|
const binding = bindings.findLast(
|
||||||
|
b => b.action === action && b.context === context,
|
||||||
|
)
|
||||||
|
return binding ? chordToString(binding.chord) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a ParsedKeystroke from Ink's input/key.
|
||||||
|
*/
|
||||||
|
function buildKeystroke(input: string, key: Key): ParsedKeystroke | null {
|
||||||
|
const keyName = getKeyName(input, key)
|
||||||
|
if (!keyName) return null
|
||||||
|
|
||||||
|
// QUIRK: Ink sets key.meta=true when escape is pressed (see input-event.ts).
|
||||||
|
// This is legacy terminal behavior - we should NOT record this as a modifier
|
||||||
|
// for the escape key itself, otherwise chord matching will fail.
|
||||||
|
const effectiveMeta = key.escape ? false : key.meta
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: keyName,
|
||||||
|
ctrl: key.ctrl,
|
||||||
|
alt: effectiveMeta,
|
||||||
|
shift: key.shift,
|
||||||
|
meta: effectiveMeta,
|
||||||
|
super: key.super,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two ParsedKeystrokes for equality. Collapses alt/meta into
|
||||||
|
* one logical modifier — legacy terminals can't distinguish them (see
|
||||||
|
* match.ts modifiersMatch), so "alt+k" and "meta+k" are the same key.
|
||||||
|
* Super (cmd/win) is distinct — only arrives via kitty keyboard protocol.
|
||||||
|
*/
|
||||||
|
export function keystrokesEqual(
|
||||||
|
a: ParsedKeystroke,
|
||||||
|
b: ParsedKeystroke,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
a.key === b.key &&
|
||||||
|
a.ctrl === b.ctrl &&
|
||||||
|
a.shift === b.shift &&
|
||||||
|
(a.alt || a.meta) === (b.alt || b.meta) &&
|
||||||
|
a.super === b.super
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a chord prefix matches the beginning of a binding's chord.
|
||||||
|
*/
|
||||||
|
function chordPrefixMatches(
|
||||||
|
prefix: ParsedKeystroke[],
|
||||||
|
binding: ParsedBinding,
|
||||||
|
): boolean {
|
||||||
|
if (prefix.length >= binding.chord.length) return false
|
||||||
|
for (let i = 0; i < prefix.length; i++) {
|
||||||
|
const prefixKey = prefix[i]
|
||||||
|
const bindingKey = binding.chord[i]
|
||||||
|
if (!prefixKey || !bindingKey) return false
|
||||||
|
if (!keystrokesEqual(prefixKey, bindingKey)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a full chord matches a binding's chord.
|
||||||
|
*/
|
||||||
|
function chordExactlyMatches(
|
||||||
|
chord: ParsedKeystroke[],
|
||||||
|
binding: ParsedBinding,
|
||||||
|
): boolean {
|
||||||
|
if (chord.length !== binding.chord.length) return false
|
||||||
|
for (let i = 0; i < chord.length; i++) {
|
||||||
|
const chordKey = chord[i]
|
||||||
|
const bindingKey = binding.chord[i]
|
||||||
|
if (!chordKey || !bindingKey) return false
|
||||||
|
if (!keystrokesEqual(chordKey, bindingKey)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a key with chord state support.
|
||||||
|
*
|
||||||
|
* This function handles multi-keystroke chord bindings like "ctrl+k ctrl+s".
|
||||||
|
*
|
||||||
|
* @param input - The character input from Ink
|
||||||
|
* @param key - The Key object from Ink with modifier flags
|
||||||
|
* @param activeContexts - Array of currently active contexts
|
||||||
|
* @param bindings - All parsed bindings
|
||||||
|
* @param pending - Current chord state (null if not in a chord)
|
||||||
|
* @returns Resolution result with chord state
|
||||||
|
*/
|
||||||
|
export function resolveKeyWithChordState(
|
||||||
|
input: string,
|
||||||
|
key: Key,
|
||||||
|
activeContexts: KeybindingContextName[],
|
||||||
|
bindings: ParsedBinding[],
|
||||||
|
pending: ParsedKeystroke[] | null,
|
||||||
|
): ChordResolveResult {
|
||||||
|
// Cancel chord on escape
|
||||||
|
if (key.escape && pending !== null) {
|
||||||
|
return { type: 'chord_cancelled' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build current keystroke
|
||||||
|
const currentKeystroke = buildKeystroke(input, key)
|
||||||
|
if (!currentKeystroke) {
|
||||||
|
if (pending !== null) {
|
||||||
|
return { type: 'chord_cancelled' }
|
||||||
|
}
|
||||||
|
return { type: 'none' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the full chord sequence to test
|
||||||
|
const testChord = pending
|
||||||
|
? [...pending, currentKeystroke]
|
||||||
|
: [currentKeystroke]
|
||||||
|
|
||||||
|
// Filter bindings by active contexts (Set lookup: O(n) instead of O(n·m))
|
||||||
|
const ctxSet = new Set(activeContexts)
|
||||||
|
const contextBindings = bindings.filter(b => ctxSet.has(b.context))
|
||||||
|
|
||||||
|
// Check if this could be a prefix for longer chords. Group by chord
|
||||||
|
// string so a later null-override shadows the default it unbinds —
|
||||||
|
// otherwise null-unbinding `ctrl+x ctrl+k` still makes `ctrl+x` enter
|
||||||
|
// chord-wait and the single-key binding on the prefix never fires.
|
||||||
|
const chordWinners = new Map<string, string | null>()
|
||||||
|
for (const binding of contextBindings) {
|
||||||
|
if (
|
||||||
|
binding.chord.length > testChord.length &&
|
||||||
|
chordPrefixMatches(testChord, binding)
|
||||||
|
) {
|
||||||
|
chordWinners.set(chordToString(binding.chord), binding.action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let hasLongerChords = false
|
||||||
|
for (const action of chordWinners.values()) {
|
||||||
|
if (action !== null) {
|
||||||
|
hasLongerChords = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this keystroke could start a longer chord, prefer that
|
||||||
|
// (even if there's an exact single-key match)
|
||||||
|
if (hasLongerChords) {
|
||||||
|
return { type: 'chord_started', pending: testChord }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for exact matches (last one wins)
|
||||||
|
let exactMatch: ParsedBinding | undefined
|
||||||
|
for (const binding of contextBindings) {
|
||||||
|
if (chordExactlyMatches(testChord, binding)) {
|
||||||
|
exactMatch = binding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exactMatch) {
|
||||||
|
if (exactMatch.action === null) {
|
||||||
|
return { type: 'unbound' }
|
||||||
|
}
|
||||||
|
return { type: 'match', action: exactMatch.action }
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match and no potential longer chords
|
||||||
|
if (pending !== null) {
|
||||||
|
return { type: 'chord_cancelled' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'none' }
|
||||||
|
}
|
||||||
23
packages/@ant/ink/src/keybindings/types.ts
Normal file
23
packages/@ant/ink/src/keybindings/types.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Keybinding type definitions
|
||||||
|
export type ParsedBinding = {
|
||||||
|
chord: ParsedKeystroke[]
|
||||||
|
action: string | null
|
||||||
|
context: KeybindingContextName
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ParsedKeystroke = {
|
||||||
|
key: string
|
||||||
|
ctrl: boolean
|
||||||
|
alt: boolean
|
||||||
|
shift: boolean
|
||||||
|
meta: boolean
|
||||||
|
super: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KeybindingContextName = string
|
||||||
|
export type KeybindingBlock = {
|
||||||
|
context: KeybindingContextName
|
||||||
|
bindings: Record<string, string | null>
|
||||||
|
}
|
||||||
|
export type Chord = ParsedKeystroke[]
|
||||||
|
export type KeybindingAction = string
|
||||||
197
packages/@ant/ink/src/keybindings/useKeybinding.ts
Normal file
197
packages/@ant/ink/src/keybindings/useKeybinding.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { useCallback, useEffect } from 'react'
|
||||||
|
import type { InputEvent } from '../core/events/input-event.js'
|
||||||
|
import { type Key } from '../core/events/input-event.js'
|
||||||
|
import useInput from '../hooks/use-input.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 })
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
useExitOnCtrlCDWithKeybindings,
|
useExitOnCtrlCDWithKeybindings,
|
||||||
} from '../hooks/useExitOnCtrlCD.js'
|
} from '../hooks/useExitOnCtrlCD.js'
|
||||||
import { Box, Text } from '../index.js'
|
import { Box, Text } from '../index.js'
|
||||||
import { useKeybinding } from './keybindings.js'
|
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||||
import type { Theme } from './theme-types.js'
|
import type { Theme } from './theme-types.js'
|
||||||
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'
|
import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'
|
||||||
import { Byline } from './Byline.js'
|
import { Byline } from './Byline.js'
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import ScrollBox from '../components/ScrollBox.js'
|
|||||||
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
|
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
|
||||||
import { stringWidth } from '../core/stringWidth.js'
|
import { stringWidth } from '../core/stringWidth.js'
|
||||||
import { Box, Text } from '../index.js'
|
import { Box, Text } from '../index.js'
|
||||||
import { useKeybindings } from './keybindings.js'
|
import { useKeybindings } from '../keybindings/useKeybinding.js'
|
||||||
import type { Theme } from './theme-types.js'
|
import type { Theme } from './theme-types.js'
|
||||||
|
|
||||||
type TabsProps = {
|
type TabsProps = {
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
/**
|
|
||||||
* Minimal stub of the keybinding system for the standalone @anthropic/ink package.
|
|
||||||
*
|
|
||||||
* The full keybinding system (src/keybindings/) depends on KeybindingContext,
|
|
||||||
* KeybindingRegistry, and chord handling. This stub provides the same hook
|
|
||||||
* interfaces (useKeybinding / useKeybindings) but routes directly through
|
|
||||||
* useInput, matching common key sequences to action names.
|
|
||||||
*
|
|
||||||
* Only the keybindings used by theme components are mapped:
|
|
||||||
* - confirm:no → Escape
|
|
||||||
* - tabs:next → Tab / Right arrow
|
|
||||||
* - tabs:previous → Shift+Tab / Left arrow
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useCallback } from 'react'
|
|
||||||
import useInput from '../hooks/use-input.js'
|
|
||||||
import type { Key } from '../core/events/input-event.js'
|
|
||||||
|
|
||||||
type Options = {
|
|
||||||
context?: string
|
|
||||||
isActive?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Maps action names to key matching logic. */
|
|
||||||
const ACTION_MATCHERS: Record<
|
|
||||||
string,
|
|
||||||
(input: string, key: Key) => boolean
|
|
||||||
> = {
|
|
||||||
'confirm:no': (_input, key) => key.escape === true,
|
|
||||||
'tabs:next': (input, key) =>
|
|
||||||
(key.tab && !key.shift) || (key.rightArrow && !key.shift),
|
|
||||||
'tabs:previous': (_input, key) =>
|
|
||||||
(key.tab && key.shift) || (key.leftArrow && !key.shift),
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a single keybinding action handler.
|
|
||||||
*/
|
|
||||||
export function useKeybinding(
|
|
||||||
action: string,
|
|
||||||
handler: () => void | false | Promise<void>,
|
|
||||||
options: Options = {},
|
|
||||||
): void {
|
|
||||||
const { isActive = true } = options
|
|
||||||
|
|
||||||
const handleInput = useCallback(
|
|
||||||
(input: string, key: Key) => {
|
|
||||||
if (!isActive) return
|
|
||||||
const matcher = ACTION_MATCHERS[action]
|
|
||||||
if (matcher && matcher(input, key)) {
|
|
||||||
if (handler() !== false) {
|
|
||||||
// consumed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[action, handler, isActive],
|
|
||||||
)
|
|
||||||
|
|
||||||
useInput(handleInput, { isActive })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register multiple keybinding action handlers in one hook.
|
|
||||||
*/
|
|
||||||
export function useKeybindings(
|
|
||||||
handlers: Record<string, () => void | false | Promise<void>>,
|
|
||||||
options: Options = {},
|
|
||||||
): void {
|
|
||||||
const { isActive = true } = options
|
|
||||||
|
|
||||||
const handleInput = useCallback(
|
|
||||||
(input: string, key: Key) => {
|
|
||||||
if (!isActive) return
|
|
||||||
for (const [action, handler] of Object.entries(handlers)) {
|
|
||||||
const matcher = ACTION_MATCHERS[action]
|
|
||||||
if (matcher && matcher(input, key)) {
|
|
||||||
if (handler() !== false) {
|
|
||||||
break // consumed, stop checking other handlers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[handlers, isActive],
|
|
||||||
)
|
|
||||||
|
|
||||||
useInput(handleInput, { isActive })
|
|
||||||
}
|
|
||||||
@@ -1,225 +1,7 @@
|
|||||||
import React, {
|
// Re-export from @anthropic/ink keybindings module
|
||||||
createContext,
|
export {
|
||||||
type RefObject,
|
KeybindingProvider,
|
||||||
useContext,
|
useKeybindingContext,
|
||||||
useLayoutEffect,
|
useOptionalKeybindingContext,
|
||||||
useMemo,
|
useRegisterKeybindingContext,
|
||||||
} from 'react'
|
} from '@anthropic/ink'
|
||||||
import type { Key } from '@anthropic/ink'
|
|
||||||
import {
|
|
||||||
type ChordResolveResult,
|
|
||||||
getBindingDisplayText,
|
|
||||||
resolveKeyWithChordState,
|
|
||||||
} from './resolver.js'
|
|
||||||
import type {
|
|
||||||
KeybindingContextName,
|
|
||||||
ParsedBinding,
|
|
||||||
ParsedKeystroke,
|
|
||||||
} from './types.js'
|
|
||||||
|
|
||||||
/** Handler registration for action callbacks */
|
|
||||||
type HandlerRegistration = {
|
|
||||||
action: string
|
|
||||||
context: KeybindingContextName
|
|
||||||
handler: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type KeybindingContextValue = {
|
|
||||||
/** Resolve a key input to an action name (with chord support) */
|
|
||||||
resolve: (
|
|
||||||
input: string,
|
|
||||||
key: Key,
|
|
||||||
activeContexts: KeybindingContextName[],
|
|
||||||
) => ChordResolveResult
|
|
||||||
|
|
||||||
/** Update the pending chord state */
|
|
||||||
setPendingChord: (pending: ParsedKeystroke[] | null) => void
|
|
||||||
|
|
||||||
/** Get display text for an action (e.g., "ctrl+t") */
|
|
||||||
getDisplayText: (
|
|
||||||
action: string,
|
|
||||||
context: KeybindingContextName,
|
|
||||||
) => string | undefined
|
|
||||||
|
|
||||||
/** All parsed bindings (for help display) */
|
|
||||||
bindings: ParsedBinding[]
|
|
||||||
|
|
||||||
/** Current pending chord keystrokes (null if not in a chord) */
|
|
||||||
pendingChord: ParsedKeystroke[] | null
|
|
||||||
|
|
||||||
/** Currently active keybinding contexts (for priority resolution) */
|
|
||||||
activeContexts: Set<KeybindingContextName>
|
|
||||||
|
|
||||||
/** Register a context as active (call on mount) */
|
|
||||||
registerActiveContext: (context: KeybindingContextName) => void
|
|
||||||
|
|
||||||
/** Unregister a context (call on unmount) */
|
|
||||||
unregisterActiveContext: (context: KeybindingContextName) => void
|
|
||||||
|
|
||||||
/** Register a handler for an action (used by useKeybinding) */
|
|
||||||
registerHandler: (registration: HandlerRegistration) => () => void
|
|
||||||
|
|
||||||
/** Invoke all handlers for an action (used by ChordInterceptor) */
|
|
||||||
invokeAction: (action: string) => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const KeybindingContext = createContext<KeybindingContextValue | null>(null)
|
|
||||||
|
|
||||||
type ProviderProps = {
|
|
||||||
bindings: ParsedBinding[]
|
|
||||||
/** Ref for immediate access to pending chord (avoids React state delay) */
|
|
||||||
pendingChordRef: RefObject<ParsedKeystroke[] | null>
|
|
||||||
/** State value for re-renders (UI updates) */
|
|
||||||
pendingChord: ParsedKeystroke[] | null
|
|
||||||
setPendingChord: (pending: ParsedKeystroke[] | null) => void
|
|
||||||
activeContexts: Set<KeybindingContextName>
|
|
||||||
registerActiveContext: (context: KeybindingContextName) => void
|
|
||||||
unregisterActiveContext: (context: KeybindingContextName) => void
|
|
||||||
/** Ref to handler registry (used by ChordInterceptor) */
|
|
||||||
handlerRegistryRef: RefObject<Map<string, Set<HandlerRegistration>>>
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeybindingProvider({
|
|
||||||
bindings,
|
|
||||||
pendingChordRef,
|
|
||||||
pendingChord,
|
|
||||||
setPendingChord,
|
|
||||||
activeContexts,
|
|
||||||
registerActiveContext,
|
|
||||||
unregisterActiveContext,
|
|
||||||
handlerRegistryRef,
|
|
||||||
children,
|
|
||||||
}: ProviderProps): React.ReactNode {
|
|
||||||
const value = useMemo<KeybindingContextValue>(() => {
|
|
||||||
const getDisplay = (action: string, context: KeybindingContextName) =>
|
|
||||||
getBindingDisplayText(action, context, bindings)
|
|
||||||
|
|
||||||
// Register a handler for an action
|
|
||||||
const registerHandler = (registration: HandlerRegistration) => {
|
|
||||||
const registry = handlerRegistryRef.current
|
|
||||||
if (!registry) return () => {}
|
|
||||||
|
|
||||||
if (!registry.has(registration.action)) {
|
|
||||||
registry.set(registration.action, new Set())
|
|
||||||
}
|
|
||||||
registry.get(registration.action)!.add(registration)
|
|
||||||
|
|
||||||
// Return unregister function
|
|
||||||
return () => {
|
|
||||||
const handlers = registry.get(registration.action)
|
|
||||||
if (handlers) {
|
|
||||||
handlers.delete(registration)
|
|
||||||
if (handlers.size === 0) {
|
|
||||||
registry.delete(registration.action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invoke all handlers for an action
|
|
||||||
const invokeAction = (action: string): boolean => {
|
|
||||||
const registry = handlerRegistryRef.current
|
|
||||||
if (!registry) return false
|
|
||||||
|
|
||||||
const handlers = registry.get(action)
|
|
||||||
if (!handlers || handlers.size === 0) return false
|
|
||||||
|
|
||||||
// Find handlers whose context is active
|
|
||||||
for (const registration of handlers) {
|
|
||||||
if (activeContexts.has(registration.context)) {
|
|
||||||
registration.handler()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Use ref for immediate access to pending chord, avoiding React state delay
|
|
||||||
// This is critical for chord sequences where the second key might be pressed
|
|
||||||
// before React re-renders with the updated pendingChord state
|
|
||||||
resolve: (input, key, contexts) =>
|
|
||||||
resolveKeyWithChordState(
|
|
||||||
input,
|
|
||||||
key,
|
|
||||||
contexts,
|
|
||||||
bindings,
|
|
||||||
pendingChordRef.current,
|
|
||||||
),
|
|
||||||
setPendingChord,
|
|
||||||
getDisplayText: getDisplay,
|
|
||||||
bindings,
|
|
||||||
pendingChord,
|
|
||||||
activeContexts,
|
|
||||||
registerActiveContext,
|
|
||||||
unregisterActiveContext,
|
|
||||||
registerHandler,
|
|
||||||
invokeAction,
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
bindings,
|
|
||||||
pendingChordRef,
|
|
||||||
pendingChord,
|
|
||||||
setPendingChord,
|
|
||||||
activeContexts,
|
|
||||||
registerActiveContext,
|
|
||||||
unregisterActiveContext,
|
|
||||||
handlerRegistryRef,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KeybindingContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</KeybindingContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKeybindingContext(): KeybindingContextValue {
|
|
||||||
const ctx = useContext(KeybindingContext)
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error(
|
|
||||||
'useKeybindingContext must be used within KeybindingProvider',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional hook that returns undefined outside of KeybindingProvider.
|
|
||||||
* Useful for components that may render before provider is available.
|
|
||||||
*/
|
|
||||||
export function useOptionalKeybindingContext(): KeybindingContextValue | null {
|
|
||||||
return useContext(KeybindingContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to register a keybinding context as active while the component is mounted.
|
|
||||||
*
|
|
||||||
* When a context is registered, its keybindings take precedence over Global bindings.
|
|
||||||
* This allows context-specific bindings (like ThemePicker's ctrl+t) to override
|
|
||||||
* global bindings (like the todo toggle) when the context is active.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* function ThemePicker() {
|
|
||||||
* useRegisterKeybindingContext('ThemePicker')
|
|
||||||
* // Now ThemePicker's ctrl+t binding takes precedence over Global
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useRegisterKeybindingContext(
|
|
||||||
context: KeybindingContextName,
|
|
||||||
isActive: boolean = true,
|
|
||||||
): void {
|
|
||||||
const keybindingContext = useOptionalKeybindingContext()
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!keybindingContext || !isActive) return
|
|
||||||
|
|
||||||
keybindingContext.registerActiveContext(context)
|
|
||||||
return () => {
|
|
||||||
keybindingContext.unregisterActiveContext(context)
|
|
||||||
}
|
|
||||||
}, [context, keybindingContext, isActive])
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,120 +1,2 @@
|
|||||||
import type { Key } from '@anthropic/ink'
|
// Re-export from @anthropic/ink keybindings module
|
||||||
import type { ParsedBinding, ParsedKeystroke } from './types.js'
|
export { getKeyName, matchesKeystroke, matchesBinding } from '@anthropic/ink'
|
||||||
|
|
||||||
/**
|
|
||||||
* Modifier keys from Ink's Key type that we care about for matching.
|
|
||||||
* Note: `fn` from Key is intentionally excluded as it's rarely used and
|
|
||||||
* not commonly configurable in terminal applications.
|
|
||||||
*/
|
|
||||||
type InkModifiers = Pick<Key, 'ctrl' | 'shift' | 'meta' | 'super'>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract modifiers from an Ink Key object.
|
|
||||||
* This function ensures we're explicitly extracting the modifiers we care about.
|
|
||||||
*/
|
|
||||||
function getInkModifiers(key: Key): InkModifiers {
|
|
||||||
return {
|
|
||||||
ctrl: key.ctrl,
|
|
||||||
shift: key.shift,
|
|
||||||
meta: key.meta,
|
|
||||||
super: key.super,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract the normalized key name from Ink's Key + input.
|
|
||||||
* Maps Ink's boolean flags (key.escape, key.return, etc.) to string names
|
|
||||||
* that match our ParsedKeystroke.key format.
|
|
||||||
*/
|
|
||||||
export function getKeyName(input: string, key: Key): string | null {
|
|
||||||
if (key.escape) return 'escape'
|
|
||||||
if (key.return) return 'enter'
|
|
||||||
if (key.tab) return 'tab'
|
|
||||||
if (key.backspace) return 'backspace'
|
|
||||||
if (key.delete) return 'delete'
|
|
||||||
if (key.upArrow) return 'up'
|
|
||||||
if (key.downArrow) return 'down'
|
|
||||||
if (key.leftArrow) return 'left'
|
|
||||||
if (key.rightArrow) return 'right'
|
|
||||||
if (key.pageUp) return 'pageup'
|
|
||||||
if (key.pageDown) return 'pagedown'
|
|
||||||
if (key.wheelUp) return 'wheelup'
|
|
||||||
if (key.wheelDown) return 'wheeldown'
|
|
||||||
if (key.home) return 'home'
|
|
||||||
if (key.end) return 'end'
|
|
||||||
if (input.length === 1) return input.toLowerCase()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if all modifiers match between Ink Key and ParsedKeystroke.
|
|
||||||
*
|
|
||||||
* Alt and Meta: Ink historically set `key.meta` for Alt/Option. A `meta`
|
|
||||||
* modifier in config is treated as an alias for `alt` — both match when
|
|
||||||
* `key.meta` is true.
|
|
||||||
*
|
|
||||||
* Super (Cmd/Win): distinct from alt/meta. Only arrives via the kitty
|
|
||||||
* keyboard protocol on supporting terminals. A `cmd`/`super` binding will
|
|
||||||
* simply never fire on terminals that don't send it.
|
|
||||||
*/
|
|
||||||
function modifiersMatch(
|
|
||||||
inkMods: InkModifiers,
|
|
||||||
target: ParsedKeystroke,
|
|
||||||
): boolean {
|
|
||||||
// Check ctrl modifier
|
|
||||||
if (inkMods.ctrl !== target.ctrl) return false
|
|
||||||
|
|
||||||
// Check shift modifier
|
|
||||||
if (inkMods.shift !== target.shift) return false
|
|
||||||
|
|
||||||
// Alt and meta both map to key.meta in Ink (terminal limitation)
|
|
||||||
// So we check if EITHER alt OR meta is required in target
|
|
||||||
const targetNeedsMeta = target.alt || target.meta
|
|
||||||
if (inkMods.meta !== targetNeedsMeta) return false
|
|
||||||
|
|
||||||
// Super (cmd/win) is a distinct modifier from alt/meta
|
|
||||||
if (inkMods.super !== target.super) return false
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a ParsedKeystroke matches the given Ink input + Key.
|
|
||||||
*
|
|
||||||
* The display text will show platform-appropriate names (opt on macOS, alt elsewhere).
|
|
||||||
*/
|
|
||||||
export function matchesKeystroke(
|
|
||||||
input: string,
|
|
||||||
key: Key,
|
|
||||||
target: ParsedKeystroke,
|
|
||||||
): boolean {
|
|
||||||
const keyName = getKeyName(input, key)
|
|
||||||
if (keyName !== target.key) return false
|
|
||||||
|
|
||||||
const inkMods = getInkModifiers(key)
|
|
||||||
|
|
||||||
// QUIRK: Ink sets key.meta=true when escape is pressed (see input-event.ts).
|
|
||||||
// This is a legacy behavior from how escape sequences work in terminals.
|
|
||||||
// We need to ignore the meta modifier when matching the escape key itself,
|
|
||||||
// otherwise bindings like "escape" (without modifiers) would never match.
|
|
||||||
if (key.escape) {
|
|
||||||
return modifiersMatch({ ...inkMods, meta: false }, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
return modifiersMatch(inkMods, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if Ink's Key + input matches a parsed binding's first keystroke.
|
|
||||||
* For single-keystroke bindings only (Phase 1).
|
|
||||||
*/
|
|
||||||
export function matchesBinding(
|
|
||||||
input: string,
|
|
||||||
key: Key,
|
|
||||||
binding: ParsedBinding,
|
|
||||||
): boolean {
|
|
||||||
if (binding.chord.length !== 1) return false
|
|
||||||
const keystroke = binding.chord[0]
|
|
||||||
if (!keystroke) return false
|
|
||||||
return matchesKeystroke(input, key, keystroke)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,203 +1,10 @@
|
|||||||
import type {
|
// Re-export from @anthropic/ink keybindings module
|
||||||
Chord,
|
export {
|
||||||
KeybindingBlock,
|
parseKeystroke,
|
||||||
ParsedBinding,
|
parseChord,
|
||||||
ParsedKeystroke,
|
keystrokeToString,
|
||||||
} from './types.js'
|
chordToString,
|
||||||
|
keystrokeToDisplayString,
|
||||||
/**
|
chordToDisplayString,
|
||||||
* Parse a keystroke string like "ctrl+shift+k" into a ParsedKeystroke.
|
parseBindings,
|
||||||
* Supports various modifier aliases (ctrl/control, alt/opt/option/meta,
|
} from '@anthropic/ink'
|
||||||
* cmd/command/super/win).
|
|
||||||
*/
|
|
||||||
export function parseKeystroke(input: string): ParsedKeystroke {
|
|
||||||
const parts = input.split('+')
|
|
||||||
const keystroke: ParsedKeystroke = {
|
|
||||||
key: '',
|
|
||||||
ctrl: false,
|
|
||||||
alt: false,
|
|
||||||
shift: false,
|
|
||||||
meta: false,
|
|
||||||
super: false,
|
|
||||||
}
|
|
||||||
for (const part of parts) {
|
|
||||||
const lower = part.toLowerCase()
|
|
||||||
switch (lower) {
|
|
||||||
case 'ctrl':
|
|
||||||
case 'control':
|
|
||||||
keystroke.ctrl = true
|
|
||||||
break
|
|
||||||
case 'alt':
|
|
||||||
case 'opt':
|
|
||||||
case 'option':
|
|
||||||
keystroke.alt = true
|
|
||||||
break
|
|
||||||
case 'shift':
|
|
||||||
keystroke.shift = true
|
|
||||||
break
|
|
||||||
case 'meta':
|
|
||||||
keystroke.meta = true
|
|
||||||
break
|
|
||||||
case 'cmd':
|
|
||||||
case 'command':
|
|
||||||
case 'super':
|
|
||||||
case 'win':
|
|
||||||
keystroke.super = true
|
|
||||||
break
|
|
||||||
case 'esc':
|
|
||||||
keystroke.key = 'escape'
|
|
||||||
break
|
|
||||||
case 'return':
|
|
||||||
keystroke.key = 'enter'
|
|
||||||
break
|
|
||||||
case 'space':
|
|
||||||
keystroke.key = ' '
|
|
||||||
break
|
|
||||||
case '↑':
|
|
||||||
keystroke.key = 'up'
|
|
||||||
break
|
|
||||||
case '↓':
|
|
||||||
keystroke.key = 'down'
|
|
||||||
break
|
|
||||||
case '←':
|
|
||||||
keystroke.key = 'left'
|
|
||||||
break
|
|
||||||
case '→':
|
|
||||||
keystroke.key = 'right'
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
keystroke.key = lower
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return keystroke
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a chord string like "ctrl+k ctrl+s" into an array of ParsedKeystrokes.
|
|
||||||
*/
|
|
||||||
export function parseChord(input: string): Chord {
|
|
||||||
// A lone space character IS the space key binding, not a separator
|
|
||||||
if (input === ' ') return [parseKeystroke('space')]
|
|
||||||
return input.trim().split(/\s+/).map(parseKeystroke)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a ParsedKeystroke to its canonical string representation for display.
|
|
||||||
*/
|
|
||||||
export function keystrokeToString(ks: ParsedKeystroke): string {
|
|
||||||
const parts: string[] = []
|
|
||||||
if (ks.ctrl) parts.push('ctrl')
|
|
||||||
if (ks.alt) parts.push('alt')
|
|
||||||
if (ks.shift) parts.push('shift')
|
|
||||||
if (ks.meta) parts.push('meta')
|
|
||||||
if (ks.super) parts.push('cmd')
|
|
||||||
// Use readable names for display
|
|
||||||
const displayKey = keyToDisplayName(ks.key)
|
|
||||||
parts.push(displayKey)
|
|
||||||
return parts.join('+')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map internal key names to human-readable display names.
|
|
||||||
*/
|
|
||||||
function keyToDisplayName(key: string): string {
|
|
||||||
switch (key) {
|
|
||||||
case 'escape':
|
|
||||||
return 'Esc'
|
|
||||||
case ' ':
|
|
||||||
return 'Space'
|
|
||||||
case 'tab':
|
|
||||||
return 'tab'
|
|
||||||
case 'enter':
|
|
||||||
return 'Enter'
|
|
||||||
case 'backspace':
|
|
||||||
return 'Backspace'
|
|
||||||
case 'delete':
|
|
||||||
return 'Delete'
|
|
||||||
case 'up':
|
|
||||||
return '↑'
|
|
||||||
case 'down':
|
|
||||||
return '↓'
|
|
||||||
case 'left':
|
|
||||||
return '←'
|
|
||||||
case 'right':
|
|
||||||
return '→'
|
|
||||||
case 'pageup':
|
|
||||||
return 'PageUp'
|
|
||||||
case 'pagedown':
|
|
||||||
return 'PageDown'
|
|
||||||
case 'home':
|
|
||||||
return 'Home'
|
|
||||||
case 'end':
|
|
||||||
return 'End'
|
|
||||||
default:
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a Chord to its canonical string representation for display.
|
|
||||||
*/
|
|
||||||
export function chordToString(chord: Chord): string {
|
|
||||||
return chord.map(keystrokeToString).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display platform type - a subset of Platform that we care about for display.
|
|
||||||
* WSL and unknown are treated as linux for display purposes.
|
|
||||||
*/
|
|
||||||
type DisplayPlatform = 'macos' | 'windows' | 'linux' | 'wsl' | 'unknown'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a ParsedKeystroke to a platform-appropriate display string.
|
|
||||||
* Uses "opt" for alt on macOS, "alt" elsewhere.
|
|
||||||
*/
|
|
||||||
export function keystrokeToDisplayString(
|
|
||||||
ks: ParsedKeystroke,
|
|
||||||
platform: DisplayPlatform = 'linux',
|
|
||||||
): string {
|
|
||||||
const parts: string[] = []
|
|
||||||
if (ks.ctrl) parts.push('ctrl')
|
|
||||||
// Alt/meta are equivalent in terminals, show platform-appropriate name
|
|
||||||
if (ks.alt || ks.meta) {
|
|
||||||
// Only macOS uses "opt", all other platforms use "alt"
|
|
||||||
parts.push(platform === 'macos' ? 'opt' : 'alt')
|
|
||||||
}
|
|
||||||
if (ks.shift) parts.push('shift')
|
|
||||||
if (ks.super) {
|
|
||||||
parts.push(platform === 'macos' ? 'cmd' : 'super')
|
|
||||||
}
|
|
||||||
// Use readable names for display
|
|
||||||
const displayKey = keyToDisplayName(ks.key)
|
|
||||||
parts.push(displayKey)
|
|
||||||
return parts.join('+')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a Chord to a platform-appropriate display string.
|
|
||||||
*/
|
|
||||||
export function chordToDisplayString(
|
|
||||||
chord: Chord,
|
|
||||||
platform: DisplayPlatform = 'linux',
|
|
||||||
): string {
|
|
||||||
return chord.map(ks => keystrokeToDisplayString(ks, platform)).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse keybinding blocks (from JSON config) into a flat list of ParsedBindings.
|
|
||||||
*/
|
|
||||||
export function parseBindings(blocks: KeybindingBlock[]): ParsedBinding[] {
|
|
||||||
const bindings: ParsedBinding[] = []
|
|
||||||
for (const block of blocks) {
|
|
||||||
for (const [key, action] of Object.entries(block.bindings)) {
|
|
||||||
bindings.push({
|
|
||||||
chord: parseChord(key),
|
|
||||||
action,
|
|
||||||
context: block.context,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bindings
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,244 +1,9 @@
|
|||||||
import type { Key } from '@anthropic/ink'
|
// Re-export from @anthropic/ink keybindings module
|
||||||
import { getKeyName, matchesBinding } from './match.js'
|
export {
|
||||||
import { chordToString } from './parser.js'
|
resolveKey,
|
||||||
import type {
|
resolveKeyWithChordState,
|
||||||
KeybindingContextName,
|
getBindingDisplayText,
|
||||||
ParsedBinding,
|
keystrokesEqual,
|
||||||
ParsedKeystroke,
|
type ResolveResult,
|
||||||
} from './types.js'
|
type ChordResolveResult,
|
||||||
|
} from '@anthropic/ink'
|
||||||
export type ResolveResult =
|
|
||||||
| { type: 'match'; action: string }
|
|
||||||
| { type: 'none' }
|
|
||||||
| { type: 'unbound' }
|
|
||||||
|
|
||||||
export type ChordResolveResult =
|
|
||||||
| { type: 'match'; action: string }
|
|
||||||
| { type: 'none' }
|
|
||||||
| { type: 'unbound' }
|
|
||||||
| { type: 'chord_started'; pending: ParsedKeystroke[] }
|
|
||||||
| { type: 'chord_cancelled' }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a key input to an action.
|
|
||||||
* Pure function - no state, no side effects, just matching logic.
|
|
||||||
*
|
|
||||||
* @param input - The character input from Ink
|
|
||||||
* @param key - The Key object from Ink with modifier flags
|
|
||||||
* @param activeContexts - Array of currently active contexts (e.g., ['Chat', 'Global'])
|
|
||||||
* @param bindings - All parsed bindings to search through
|
|
||||||
* @returns The resolution result
|
|
||||||
*/
|
|
||||||
export function resolveKey(
|
|
||||||
input: string,
|
|
||||||
key: Key,
|
|
||||||
activeContexts: KeybindingContextName[],
|
|
||||||
bindings: ParsedBinding[],
|
|
||||||
): ResolveResult {
|
|
||||||
// Find matching bindings (last one wins for user overrides)
|
|
||||||
let match: ParsedBinding | undefined
|
|
||||||
const ctxSet = new Set(activeContexts)
|
|
||||||
|
|
||||||
for (const binding of bindings) {
|
|
||||||
// Phase 1: Only single-keystroke bindings
|
|
||||||
if (binding.chord.length !== 1) continue
|
|
||||||
if (!ctxSet.has(binding.context)) continue
|
|
||||||
|
|
||||||
if (matchesBinding(input, key, binding)) {
|
|
||||||
match = binding
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
return { type: 'none' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (match.action === null) {
|
|
||||||
return { type: 'unbound' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: 'match', action: match.action }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get display text for an action from bindings (e.g., "ctrl+t" for "app:toggleTodos").
|
|
||||||
* Searches in reverse order so user overrides take precedence.
|
|
||||||
*/
|
|
||||||
export function getBindingDisplayText(
|
|
||||||
action: string,
|
|
||||||
context: KeybindingContextName,
|
|
||||||
bindings: ParsedBinding[],
|
|
||||||
): string | undefined {
|
|
||||||
// Find the last binding for this action in this context
|
|
||||||
const binding = bindings.findLast(
|
|
||||||
b => b.action === action && b.context === context,
|
|
||||||
)
|
|
||||||
return binding ? chordToString(binding.chord) : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a ParsedKeystroke from Ink's input/key.
|
|
||||||
*/
|
|
||||||
function buildKeystroke(input: string, key: Key): ParsedKeystroke | null {
|
|
||||||
const keyName = getKeyName(input, key)
|
|
||||||
if (!keyName) return null
|
|
||||||
|
|
||||||
// QUIRK: Ink sets key.meta=true when escape is pressed (see input-event.ts).
|
|
||||||
// This is legacy terminal behavior - we should NOT record this as a modifier
|
|
||||||
// for the escape key itself, otherwise chord matching will fail.
|
|
||||||
const effectiveMeta = key.escape ? false : key.meta
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: keyName,
|
|
||||||
ctrl: key.ctrl,
|
|
||||||
alt: effectiveMeta,
|
|
||||||
shift: key.shift,
|
|
||||||
meta: effectiveMeta,
|
|
||||||
super: key.super,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare two ParsedKeystrokes for equality. Collapses alt/meta into
|
|
||||||
* one logical modifier — legacy terminals can't distinguish them (see
|
|
||||||
* match.ts modifiersMatch), so "alt+k" and "meta+k" are the same key.
|
|
||||||
* Super (cmd/win) is distinct — only arrives via kitty keyboard protocol.
|
|
||||||
*/
|
|
||||||
export function keystrokesEqual(
|
|
||||||
a: ParsedKeystroke,
|
|
||||||
b: ParsedKeystroke,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
a.key === b.key &&
|
|
||||||
a.ctrl === b.ctrl &&
|
|
||||||
a.shift === b.shift &&
|
|
||||||
(a.alt || a.meta) === (b.alt || b.meta) &&
|
|
||||||
a.super === b.super
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a chord prefix matches the beginning of a binding's chord.
|
|
||||||
*/
|
|
||||||
function chordPrefixMatches(
|
|
||||||
prefix: ParsedKeystroke[],
|
|
||||||
binding: ParsedBinding,
|
|
||||||
): boolean {
|
|
||||||
if (prefix.length >= binding.chord.length) return false
|
|
||||||
for (let i = 0; i < prefix.length; i++) {
|
|
||||||
const prefixKey = prefix[i]
|
|
||||||
const bindingKey = binding.chord[i]
|
|
||||||
if (!prefixKey || !bindingKey) return false
|
|
||||||
if (!keystrokesEqual(prefixKey, bindingKey)) return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a full chord matches a binding's chord.
|
|
||||||
*/
|
|
||||||
function chordExactlyMatches(
|
|
||||||
chord: ParsedKeystroke[],
|
|
||||||
binding: ParsedBinding,
|
|
||||||
): boolean {
|
|
||||||
if (chord.length !== binding.chord.length) return false
|
|
||||||
for (let i = 0; i < chord.length; i++) {
|
|
||||||
const chordKey = chord[i]
|
|
||||||
const bindingKey = binding.chord[i]
|
|
||||||
if (!chordKey || !bindingKey) return false
|
|
||||||
if (!keystrokesEqual(chordKey, bindingKey)) return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a key with chord state support.
|
|
||||||
*
|
|
||||||
* This function handles multi-keystroke chord bindings like "ctrl+k ctrl+s".
|
|
||||||
*
|
|
||||||
* @param input - The character input from Ink
|
|
||||||
* @param key - The Key object from Ink with modifier flags
|
|
||||||
* @param activeContexts - Array of currently active contexts
|
|
||||||
* @param bindings - All parsed bindings
|
|
||||||
* @param pending - Current chord state (null if not in a chord)
|
|
||||||
* @returns Resolution result with chord state
|
|
||||||
*/
|
|
||||||
export function resolveKeyWithChordState(
|
|
||||||
input: string,
|
|
||||||
key: Key,
|
|
||||||
activeContexts: KeybindingContextName[],
|
|
||||||
bindings: ParsedBinding[],
|
|
||||||
pending: ParsedKeystroke[] | null,
|
|
||||||
): ChordResolveResult {
|
|
||||||
// Cancel chord on escape
|
|
||||||
if (key.escape && pending !== null) {
|
|
||||||
return { type: 'chord_cancelled' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build current keystroke
|
|
||||||
const currentKeystroke = buildKeystroke(input, key)
|
|
||||||
if (!currentKeystroke) {
|
|
||||||
if (pending !== null) {
|
|
||||||
return { type: 'chord_cancelled' }
|
|
||||||
}
|
|
||||||
return { type: 'none' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the full chord sequence to test
|
|
||||||
const testChord = pending
|
|
||||||
? [...pending, currentKeystroke]
|
|
||||||
: [currentKeystroke]
|
|
||||||
|
|
||||||
// Filter bindings by active contexts (Set lookup: O(n) instead of O(n·m))
|
|
||||||
const ctxSet = new Set(activeContexts)
|
|
||||||
const contextBindings = bindings.filter(b => ctxSet.has(b.context))
|
|
||||||
|
|
||||||
// Check if this could be a prefix for longer chords. Group by chord
|
|
||||||
// string so a later null-override shadows the default it unbinds —
|
|
||||||
// otherwise null-unbinding `ctrl+x ctrl+k` still makes `ctrl+x` enter
|
|
||||||
// chord-wait and the single-key binding on the prefix never fires.
|
|
||||||
const chordWinners = new Map<string, string | null>()
|
|
||||||
for (const binding of contextBindings) {
|
|
||||||
if (
|
|
||||||
binding.chord.length > testChord.length &&
|
|
||||||
chordPrefixMatches(testChord, binding)
|
|
||||||
) {
|
|
||||||
chordWinners.set(chordToString(binding.chord), binding.action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let hasLongerChords = false
|
|
||||||
for (const action of chordWinners.values()) {
|
|
||||||
if (action !== null) {
|
|
||||||
hasLongerChords = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this keystroke could start a longer chord, prefer that
|
|
||||||
// (even if there's an exact single-key match)
|
|
||||||
if (hasLongerChords) {
|
|
||||||
return { type: 'chord_started', pending: testChord }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for exact matches (last one wins)
|
|
||||||
let exactMatch: ParsedBinding | undefined
|
|
||||||
for (const binding of contextBindings) {
|
|
||||||
if (chordExactlyMatches(testChord, binding)) {
|
|
||||||
exactMatch = binding
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exactMatch) {
|
|
||||||
if (exactMatch.action === null) {
|
|
||||||
return { type: 'unbound' }
|
|
||||||
}
|
|
||||||
return { type: 'match', action: exactMatch.action }
|
|
||||||
}
|
|
||||||
|
|
||||||
// No match and no potential longer chords
|
|
||||||
if (pending !== null) {
|
|
||||||
return { type: 'chord_cancelled' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: 'none' }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
// Auto-generated stub — replace with real implementation
|
// Re-export types from @anthropic/ink keybindings module
|
||||||
export type ParsedBinding = any;
|
export type {
|
||||||
export type ParsedKeystroke = any;
|
ParsedBinding,
|
||||||
export type KeybindingContextName = any;
|
ParsedKeystroke,
|
||||||
export type KeybindingBlock = any;
|
KeybindingContextName,
|
||||||
export type Chord = any;
|
KeybindingBlock,
|
||||||
export type KeybindingAction = any;
|
Chord,
|
||||||
|
KeybindingAction,
|
||||||
|
} from '@anthropic/ink'
|
||||||
|
|||||||
@@ -1,196 +1,2 @@
|
|||||||
import { useCallback, useEffect } from 'react'
|
// Re-export from @anthropic/ink keybindings module
|
||||||
import type { InputEvent } from '@anthropic/ink'
|
export { useKeybinding, useKeybindings } from '@anthropic/ink'
|
||||||
import { type Key, useInput } from '@anthropic/ink'
|
|
||||||
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 })
|
|
||||||
}
|
|
||||||
|
|||||||
10
src/main.tsx
10
src/main.tsx
@@ -41,7 +41,7 @@ import { getRemoteSessionUrl } from './constants/product.js'
|
|||||||
import { getSystemContext, getUserContext } from './context.js'
|
import { getSystemContext, getUserContext } from './context.js'
|
||||||
import { init, initializeTelemetryAfterTrust } from './entrypoints/init.js'
|
import { init, initializeTelemetryAfterTrust } from './entrypoints/init.js'
|
||||||
import { addToHistory } from './history.js'
|
import { addToHistory } from './history.js'
|
||||||
import type { Root } from './ink.js'
|
import type { Root } from '@anthropic/ink'
|
||||||
import { launchRepl } from './replLauncher.js'
|
import { launchRepl } from './replLauncher.js'
|
||||||
import {
|
import {
|
||||||
hasGrowthBookEnvOverride,
|
hasGrowthBookEnvOverride,
|
||||||
@@ -173,7 +173,7 @@ import {
|
|||||||
launchTeleportRepoMismatchDialog,
|
launchTeleportRepoMismatchDialog,
|
||||||
launchTeleportResumeWrapper,
|
launchTeleportResumeWrapper,
|
||||||
} from './dialogLaunchers.js'
|
} from './dialogLaunchers.js'
|
||||||
import { SHOW_CURSOR } from './ink/termio/dec.js'
|
import { SHOW_CURSOR } from '@anthropic/ink'
|
||||||
import {
|
import {
|
||||||
exitWithError,
|
exitWithError,
|
||||||
exitWithMessage,
|
exitWithMessage,
|
||||||
@@ -3370,7 +3370,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
installAsciicastRecorder();
|
installAsciicastRecorder();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { createRoot } = await import('./ink.js')
|
const { createRoot } = await import('@anthropic/ink')
|
||||||
root = await createRoot(ctx.renderOptions)
|
root = await createRoot(ctx.renderOptions)
|
||||||
|
|
||||||
// Log startup time now, before any blocking dialog renders. Logging
|
// Log startup time now, before any blocking dialog renders. Logging
|
||||||
@@ -6374,7 +6374,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
.action(async () => {
|
.action(async () => {
|
||||||
const [{ setupTokenHandler }, { createRoot }] = await Promise.all([
|
const [{ setupTokenHandler }, { createRoot }] = await Promise.all([
|
||||||
import('./cli/handlers/util.js'),
|
import('./cli/handlers/util.js'),
|
||||||
import('./ink.js'),
|
import('@anthropic/ink'),
|
||||||
])
|
])
|
||||||
const root = await createRoot(getBaseRenderOptions(false))
|
const root = await createRoot(getBaseRenderOptions(false))
|
||||||
await setupTokenHandler(root)
|
await setupTokenHandler(root)
|
||||||
@@ -6491,7 +6491,7 @@ async function run(): Promise<CommanderCommand> {
|
|||||||
.action(async () => {
|
.action(async () => {
|
||||||
const [{ doctorHandler }, { createRoot }] = await Promise.all([
|
const [{ doctorHandler }, { createRoot }] = await Promise.all([
|
||||||
import('./cli/handlers/util.js'),
|
import('./cli/handlers/util.js'),
|
||||||
import('./ink.js'),
|
import('@anthropic/ink'),
|
||||||
])
|
])
|
||||||
const root = await createRoot(getBaseRenderOptions(false))
|
const root = await createRoot(getBaseRenderOptions(false))
|
||||||
await doctorHandler(root)
|
await doctorHandler(root)
|
||||||
|
|||||||
Reference in New Issue
Block a user