mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 将 keybinding 纳入 ink 管辖
This commit is contained in:
@@ -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<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])
|
||||
}
|
||||
// Re-export from @anthropic/ink keybindings module
|
||||
export {
|
||||
KeybindingProvider,
|
||||
useKeybindingContext,
|
||||
useOptionalKeybindingContext,
|
||||
useRegisterKeybindingContext,
|
||||
} from '@anthropic/ink'
|
||||
|
||||
@@ -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<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)
|
||||
}
|
||||
// Re-export from @anthropic/ink keybindings module
|
||||
export { getKeyName, matchesKeystroke, matchesBinding } from '@anthropic/ink'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<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' }
|
||||
}
|
||||
// Re-export from @anthropic/ink keybindings module
|
||||
export {
|
||||
resolveKey,
|
||||
resolveKeyWithChordState,
|
||||
getBindingDisplayText,
|
||||
keystrokesEqual,
|
||||
type ResolveResult,
|
||||
type ChordResolveResult,
|
||||
} from '@anthropic/ink'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<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 })
|
||||
}
|
||||
// Re-export from @anthropic/ink keybindings module
|
||||
export { useKeybinding, useKeybindings } from '@anthropic/ink'
|
||||
|
||||
12
src/main.tsx
12
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<CommanderCommand> {
|
||||
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<CommanderCommand> {
|
||||
.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<CommanderCommand> {
|
||||
.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)
|
||||
|
||||
Reference in New Issue
Block a user