diff --git a/packages/@ant/ink/src/index.ts b/packages/@ant/ink/src/index.ts index ac92daabc..bc6388e31 100644 --- a/packages/@ant/ink/src/index.ts +++ b/packages/@ant/ink/src/index.ts @@ -16,6 +16,48 @@ export type { RenderOptions, Instance, Root } from './core/root.js' // InkCore class 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 // ============================================================ diff --git a/packages/@ant/ink/src/keybindings/KeybindingContext.tsx b/packages/@ant/ink/src/keybindings/KeybindingContext.tsx new file mode 100644 index 000000000..8cd56a408 --- /dev/null +++ b/packages/@ant/ink/src/keybindings/KeybindingContext.tsx @@ -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 + + /** 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(null) + +type ProviderProps = { + bindings: ParsedBinding[] + /** Ref for immediate access to pending chord (avoids React state delay) */ + pendingChordRef: RefObject + /** State value for re-renders (UI updates) */ + pendingChord: ParsedKeystroke[] | null + setPendingChord: (pending: ParsedKeystroke[] | null) => void + activeContexts: Set + registerActiveContext: (context: KeybindingContextName) => void + unregisterActiveContext: (context: KeybindingContextName) => void + /** Ref to handler registry (used by ChordInterceptor) */ + handlerRegistryRef: RefObject>> + children: React.ReactNode +} + +export function KeybindingProvider({ + bindings, + pendingChordRef, + pendingChord, + setPendingChord, + activeContexts, + registerActiveContext, + unregisterActiveContext, + handlerRegistryRef, + children, +}: ProviderProps): React.ReactNode { + const value = useMemo(() => { + 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 ( + + {children} + + ) +} + +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]) +} diff --git a/packages/@ant/ink/src/keybindings/match.ts b/packages/@ant/ink/src/keybindings/match.ts new file mode 100644 index 000000000..55e787b9f --- /dev/null +++ b/packages/@ant/ink/src/keybindings/match.ts @@ -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 + +/** + * 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) +} diff --git a/packages/@ant/ink/src/keybindings/parser.ts b/packages/@ant/ink/src/keybindings/parser.ts new file mode 100644 index 000000000..ead1a1a8f --- /dev/null +++ b/packages/@ant/ink/src/keybindings/parser.ts @@ -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 +} diff --git a/packages/@ant/ink/src/keybindings/resolver.ts b/packages/@ant/ink/src/keybindings/resolver.ts new file mode 100644 index 000000000..5c930d68b --- /dev/null +++ b/packages/@ant/ink/src/keybindings/resolver.ts @@ -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() + 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' } +} diff --git a/packages/@ant/ink/src/keybindings/types.ts b/packages/@ant/ink/src/keybindings/types.ts new file mode 100644 index 000000000..b72090cbb --- /dev/null +++ b/packages/@ant/ink/src/keybindings/types.ts @@ -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 +} +export type Chord = ParsedKeystroke[] +export type KeybindingAction = string diff --git a/packages/@ant/ink/src/keybindings/useKeybinding.ts b/packages/@ant/ink/src/keybindings/useKeybinding.ts new file mode 100644 index 000000000..ea822300d --- /dev/null +++ b/packages/@ant/ink/src/keybindings/useKeybinding.ts @@ -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, + options: Options = {}, +): void { + const { context = 'Global', isActive = true } = options + const keybindingContext = useOptionalKeybindingContext() + + // Register handler with the context for ChordInterceptor to invoke + useEffect(() => { + if (!keybindingContext || !isActive) return + return keybindingContext.registerHandler({ action, context, handler }) + }, [action, context, handler, keybindingContext, isActive]) + + const handleInput = useCallback( + (input: string, key: Key, event: InputEvent) => { + // If no keybinding context available, skip resolution + if (!keybindingContext) return + + // Build context list: registered active contexts + this context + Global + // More specific contexts (registered ones) take precedence over Global + const contextsToCheck: KeybindingContextName[] = [ + ...keybindingContext.activeContexts, + context, + 'Global', + ] + // Deduplicate while preserving order (first occurrence wins for priority) + const uniqueContexts = [...new Set(contextsToCheck)] + + const result = keybindingContext.resolve(input, key, uniqueContexts) + + switch (result.type) { + case 'match': + // Chord completed (if any) - clear pending state + keybindingContext.setPendingChord(null) + if (result.action === action) { + if (handler() !== false) { + event.stopImmediatePropagation() + } + } + break + case 'chord_started': + // User started a chord sequence - update pending state + keybindingContext.setPendingChord(result.pending) + event.stopImmediatePropagation() + break + case 'chord_cancelled': + // Chord was cancelled (escape or invalid key) + keybindingContext.setPendingChord(null) + break + case 'unbound': + // Explicitly unbound - clear any pending chord + keybindingContext.setPendingChord(null) + event.stopImmediatePropagation() + break + case 'none': + // No match - let other handlers try + break + } + }, + [action, context, handler, keybindingContext], + ) + + useInput(handleInput, { isActive }) +} + +/** + * Handle multiple keybindings in one hook (reduces useInput calls). + * + * Supports chord sequences. When a chord is started, the hook will + * manage the pending state automatically. + * + * @example + * ```tsx + * useKeybindings({ + * 'chat:submit': () => handleSubmit(), + * 'chat:cancel': () => handleCancel(), + * }, { context: 'Chat' }) + * ``` + */ +export function useKeybindings( + // Handler returning `false` means "not consumed" — the event propagates + // to later useInput/useKeybindings handlers. Useful for fall-through: + // e.g. ScrollKeybindingHandler's scroll:line* returns false when the + // ScrollBox content fits (scroll is a no-op), letting a child component's + // handler take the wheel event for list navigation instead. Promise + // is allowed for fire-and-forget async handlers (the `!== false` check + // only skips propagation for a sync `false`, not a pending Promise). + handlers: Record void | false | Promise>, + options: Options = {}, +): void { + const { context = 'Global', isActive = true } = options + const keybindingContext = useOptionalKeybindingContext() + + // Register all handlers with the context for ChordInterceptor to invoke + useEffect(() => { + if (!keybindingContext || !isActive) return + + const unregisterFns: Array<() => void> = [] + for (const [action, handler] of Object.entries(handlers)) { + unregisterFns.push( + keybindingContext.registerHandler({ action, context, handler }), + ) + } + + return () => { + for (const unregister of unregisterFns) { + unregister() + } + } + }, [context, handlers, keybindingContext, isActive]) + + const handleInput = useCallback( + (input: string, key: Key, event: InputEvent) => { + // If no keybinding context available, skip resolution + if (!keybindingContext) return + + // Build context list: registered active contexts + this context + Global + // More specific contexts (registered ones) take precedence over Global + const contextsToCheck: KeybindingContextName[] = [ + ...keybindingContext.activeContexts, + context, + 'Global', + ] + // Deduplicate while preserving order (first occurrence wins for priority) + const uniqueContexts = [...new Set(contextsToCheck)] + + const result = keybindingContext.resolve(input, key, uniqueContexts) + + switch (result.type) { + case 'match': + // Chord completed (if any) - clear pending state + keybindingContext.setPendingChord(null) + if (result.action in handlers) { + const handler = handlers[result.action] + if (handler && handler() !== false) { + event.stopImmediatePropagation() + } + } + break + case 'chord_started': + // User started a chord sequence - update pending state + keybindingContext.setPendingChord(result.pending) + event.stopImmediatePropagation() + break + case 'chord_cancelled': + // Chord was cancelled (escape or invalid key) + keybindingContext.setPendingChord(null) + break + case 'unbound': + // Explicitly unbound - clear any pending chord + keybindingContext.setPendingChord(null) + event.stopImmediatePropagation() + break + case 'none': + // No match - let other handlers try + break + } + }, + [context, handlers, keybindingContext], + ) + + useInput(handleInput, { isActive }) +} diff --git a/packages/@ant/ink/src/theme/Dialog.tsx b/packages/@ant/ink/src/theme/Dialog.tsx index 3b60b3587..caf6166a0 100644 --- a/packages/@ant/ink/src/theme/Dialog.tsx +++ b/packages/@ant/ink/src/theme/Dialog.tsx @@ -4,7 +4,7 @@ import { useExitOnCtrlCDWithKeybindings, } from '../hooks/useExitOnCtrlCD.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 { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' import { Byline } from './Byline.js' diff --git a/packages/@ant/ink/src/theme/Tabs.tsx b/packages/@ant/ink/src/theme/Tabs.tsx index cec38cf7e..df49f4619 100644 --- a/packages/@ant/ink/src/theme/Tabs.tsx +++ b/packages/@ant/ink/src/theme/Tabs.tsx @@ -14,7 +14,7 @@ import ScrollBox from '../components/ScrollBox.js' import type { KeyboardEvent } from '../core/events/keyboard-event.js' import { stringWidth } from '../core/stringWidth.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' type TabsProps = { diff --git a/packages/@ant/ink/src/theme/keybindings.ts b/packages/@ant/ink/src/theme/keybindings.ts deleted file mode 100644 index a2ce31c50..000000000 --- a/packages/@ant/ink/src/theme/keybindings.ts +++ /dev/null @@ -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, - 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 void | false | Promise>, - 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 }) -} diff --git a/src/keybindings/KeybindingContext.tsx b/src/keybindings/KeybindingContext.tsx index edfedad54..10cbb8507 100644 --- a/src/keybindings/KeybindingContext.tsx +++ b/src/keybindings/KeybindingContext.tsx @@ -1,225 +1,7 @@ -import React, { - createContext, - type RefObject, - useContext, - useLayoutEffect, - useMemo, -} from 'react' -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 - - /** 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(null) - -type ProviderProps = { - bindings: ParsedBinding[] - /** Ref for immediate access to pending chord (avoids React state delay) */ - pendingChordRef: RefObject - /** State value for re-renders (UI updates) */ - pendingChord: ParsedKeystroke[] | null - setPendingChord: (pending: ParsedKeystroke[] | null) => void - activeContexts: Set - registerActiveContext: (context: KeybindingContextName) => void - unregisterActiveContext: (context: KeybindingContextName) => void - /** Ref to handler registry (used by ChordInterceptor) */ - handlerRegistryRef: RefObject>> - children: React.ReactNode -} - -export function KeybindingProvider({ - bindings, - pendingChordRef, - pendingChord, - setPendingChord, - activeContexts, - registerActiveContext, - unregisterActiveContext, - handlerRegistryRef, - children, -}: ProviderProps): React.ReactNode { - const value = useMemo(() => { - 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 ( - - {children} - - ) -} - -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]) -} +// Re-export from @anthropic/ink keybindings module +export { + KeybindingProvider, + useKeybindingContext, + useOptionalKeybindingContext, + useRegisterKeybindingContext, +} from '@anthropic/ink' diff --git a/src/keybindings/match.ts b/src/keybindings/match.ts index c915f328e..ab4b976ef 100644 --- a/src/keybindings/match.ts +++ b/src/keybindings/match.ts @@ -1,120 +1,2 @@ -import type { Key } from '@anthropic/ink' -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 - -/** - * 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) -} +// Re-export from @anthropic/ink keybindings module +export { getKeyName, matchesKeystroke, matchesBinding } from '@anthropic/ink' diff --git a/src/keybindings/parser.ts b/src/keybindings/parser.ts index ead1a1a8f..e5aa67996 100644 --- a/src/keybindings/parser.ts +++ b/src/keybindings/parser.ts @@ -1,203 +1,10 @@ -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 -} +// Re-export from @anthropic/ink keybindings module +export { + parseKeystroke, + parseChord, + keystrokeToString, + chordToString, + keystrokeToDisplayString, + chordToDisplayString, + parseBindings, +} from '@anthropic/ink' diff --git a/src/keybindings/resolver.ts b/src/keybindings/resolver.ts index f58babeb6..0e168e6c7 100644 --- a/src/keybindings/resolver.ts +++ b/src/keybindings/resolver.ts @@ -1,244 +1,9 @@ -import type { Key } from '@anthropic/ink' -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() - 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' } -} +// Re-export from @anthropic/ink keybindings module +export { + resolveKey, + resolveKeyWithChordState, + getBindingDisplayText, + keystrokesEqual, + type ResolveResult, + type ChordResolveResult, +} from '@anthropic/ink' diff --git a/src/keybindings/types.ts b/src/keybindings/types.ts index e3284422f..0420c8588 100644 --- a/src/keybindings/types.ts +++ b/src/keybindings/types.ts @@ -1,7 +1,9 @@ -// Auto-generated stub — replace with real implementation -export type ParsedBinding = any; -export type ParsedKeystroke = any; -export type KeybindingContextName = any; -export type KeybindingBlock = any; -export type Chord = any; -export type KeybindingAction = any; +// Re-export types from @anthropic/ink keybindings module +export type { + ParsedBinding, + ParsedKeystroke, + KeybindingContextName, + KeybindingBlock, + Chord, + KeybindingAction, +} from '@anthropic/ink' diff --git a/src/keybindings/useKeybinding.ts b/src/keybindings/useKeybinding.ts index 1068852be..3a53a8f8d 100644 --- a/src/keybindings/useKeybinding.ts +++ b/src/keybindings/useKeybinding.ts @@ -1,196 +1,2 @@ -import { useCallback, useEffect } from 'react' -import type { InputEvent } 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, - options: Options = {}, -): void { - const { context = 'Global', isActive = true } = options - const keybindingContext = useOptionalKeybindingContext() - - // Register handler with the context for ChordInterceptor to invoke - useEffect(() => { - if (!keybindingContext || !isActive) return - return keybindingContext.registerHandler({ action, context, handler }) - }, [action, context, handler, keybindingContext, isActive]) - - const handleInput = useCallback( - (input: string, key: Key, event: InputEvent) => { - // If no keybinding context available, skip resolution - if (!keybindingContext) return - - // Build context list: registered active contexts + this context + Global - // More specific contexts (registered ones) take precedence over Global - const contextsToCheck: KeybindingContextName[] = [ - ...keybindingContext.activeContexts, - context, - 'Global', - ] - // Deduplicate while preserving order (first occurrence wins for priority) - const uniqueContexts = [...new Set(contextsToCheck)] - - const result = keybindingContext.resolve(input, key, uniqueContexts) - - switch (result.type) { - case 'match': - // Chord completed (if any) - clear pending state - keybindingContext.setPendingChord(null) - if (result.action === action) { - if (handler() !== false) { - event.stopImmediatePropagation() - } - } - break - case 'chord_started': - // User started a chord sequence - update pending state - keybindingContext.setPendingChord(result.pending) - event.stopImmediatePropagation() - break - case 'chord_cancelled': - // Chord was cancelled (escape or invalid key) - keybindingContext.setPendingChord(null) - break - case 'unbound': - // Explicitly unbound - clear any pending chord - keybindingContext.setPendingChord(null) - event.stopImmediatePropagation() - break - case 'none': - // No match - let other handlers try - break - } - }, - [action, context, handler, keybindingContext], - ) - - useInput(handleInput, { isActive }) -} - -/** - * Handle multiple keybindings in one hook (reduces useInput calls). - * - * Supports chord sequences. When a chord is started, the hook will - * manage the pending state automatically. - * - * @example - * ```tsx - * useKeybindings({ - * 'chat:submit': () => handleSubmit(), - * 'chat:cancel': () => handleCancel(), - * }, { context: 'Chat' }) - * ``` - */ -export function useKeybindings( - // Handler returning `false` means "not consumed" — the event propagates - // to later useInput/useKeybindings handlers. Useful for fall-through: - // e.g. ScrollKeybindingHandler's scroll:line* returns false when the - // ScrollBox content fits (scroll is a no-op), letting a child component's - // handler take the wheel event for list navigation instead. Promise - // is allowed for fire-and-forget async handlers (the `!== false` check - // only skips propagation for a sync `false`, not a pending Promise). - handlers: Record void | false | Promise>, - options: Options = {}, -): void { - const { context = 'Global', isActive = true } = options - const keybindingContext = useOptionalKeybindingContext() - - // Register all handlers with the context for ChordInterceptor to invoke - useEffect(() => { - if (!keybindingContext || !isActive) return - - const unregisterFns: Array<() => void> = [] - for (const [action, handler] of Object.entries(handlers)) { - unregisterFns.push( - keybindingContext.registerHandler({ action, context, handler }), - ) - } - - return () => { - for (const unregister of unregisterFns) { - unregister() - } - } - }, [context, handlers, keybindingContext, isActive]) - - const handleInput = useCallback( - (input: string, key: Key, event: InputEvent) => { - // If no keybinding context available, skip resolution - if (!keybindingContext) return - - // Build context list: registered active contexts + this context + Global - // More specific contexts (registered ones) take precedence over Global - const contextsToCheck: KeybindingContextName[] = [ - ...keybindingContext.activeContexts, - context, - 'Global', - ] - // Deduplicate while preserving order (first occurrence wins for priority) - const uniqueContexts = [...new Set(contextsToCheck)] - - const result = keybindingContext.resolve(input, key, uniqueContexts) - - switch (result.type) { - case 'match': - // Chord completed (if any) - clear pending state - keybindingContext.setPendingChord(null) - if (result.action in handlers) { - const handler = handlers[result.action] - if (handler && handler() !== false) { - event.stopImmediatePropagation() - } - } - break - case 'chord_started': - // User started a chord sequence - update pending state - keybindingContext.setPendingChord(result.pending) - event.stopImmediatePropagation() - break - case 'chord_cancelled': - // Chord was cancelled (escape or invalid key) - keybindingContext.setPendingChord(null) - break - case 'unbound': - // Explicitly unbound - clear any pending chord - keybindingContext.setPendingChord(null) - event.stopImmediatePropagation() - break - case 'none': - // No match - let other handlers try - break - } - }, - [context, handlers, keybindingContext], - ) - - useInput(handleInput, { isActive }) -} +// Re-export from @anthropic/ink keybindings module +export { useKeybinding, useKeybindings } from '@anthropic/ink' diff --git a/src/main.tsx b/src/main.tsx index 465c28914..68f58eaa3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -41,7 +41,7 @@ import { getRemoteSessionUrl } from './constants/product.js' import { getSystemContext, getUserContext } from './context.js' import { init, initializeTelemetryAfterTrust } from './entrypoints/init.js' import { addToHistory } from './history.js' -import type { Root } from './ink.js' +import type { Root } from '@anthropic/ink' import { launchRepl } from './replLauncher.js' import { hasGrowthBookEnvOverride, @@ -173,7 +173,7 @@ import { launchTeleportRepoMismatchDialog, launchTeleportResumeWrapper, } from './dialogLaunchers.js' -import { SHOW_CURSOR } from './ink/termio/dec.js' +import { SHOW_CURSOR } from '@anthropic/ink' import { exitWithError, exitWithMessage, @@ -3370,8 +3370,8 @@ async function run(): Promise { installAsciicastRecorder(); } - const { createRoot } = await import('./ink.js') - root = await createRoot(ctx.renderOptions) + const { createRoot } = await import('@anthropic/ink') + root = await createRoot(ctx.renderOptions) // Log startup time now, before any blocking dialog renders. Logging // from REPL's first render (the old location) included however long @@ -6374,7 +6374,7 @@ async function run(): Promise { .action(async () => { const [{ setupTokenHandler }, { createRoot }] = await Promise.all([ import('./cli/handlers/util.js'), - import('./ink.js'), + import('@anthropic/ink'), ]) const root = await createRoot(getBaseRenderOptions(false)) await setupTokenHandler(root) @@ -6491,7 +6491,7 @@ async function run(): Promise { .action(async () => { const [{ doctorHandler }, { createRoot }] = await Promise.all([ import('./cli/handlers/util.js'), - import('./ink.js'), + import('@anthropic/ink'), ]) const root = await createRoot(getBaseRenderOptions(false)) await doctorHandler(root)