/ keybindings / resolver.ts
resolver.ts
  1  import type { Key } from '../ink.js'
  2  import { getKeyName, matchesBinding } from './match.js'
  3  import { chordToString } from './parser.js'
  4  import type {
  5    KeybindingContextName,
  6    ParsedBinding,
  7    ParsedKeystroke,
  8  } from './types.js'
  9  
 10  export type ResolveResult =
 11    | { type: 'match'; action: string }
 12    | { type: 'none' }
 13    | { type: 'unbound' }
 14  
 15  export type ChordResolveResult =
 16    | { type: 'match'; action: string }
 17    | { type: 'none' }
 18    | { type: 'unbound' }
 19    | { type: 'chord_started'; pending: ParsedKeystroke[] }
 20    | { type: 'chord_cancelled' }
 21  
 22  /**
 23   * Resolve a key input to an action.
 24   * Pure function - no state, no side effects, just matching logic.
 25   *
 26   * @param input - The character input from Ink
 27   * @param key - The Key object from Ink with modifier flags
 28   * @param activeContexts - Array of currently active contexts (e.g., ['Chat', 'Global'])
 29   * @param bindings - All parsed bindings to search through
 30   * @returns The resolution result
 31   */
 32  export function resolveKey(
 33    input: string,
 34    key: Key,
 35    activeContexts: KeybindingContextName[],
 36    bindings: ParsedBinding[],
 37  ): ResolveResult {
 38    // Find matching bindings (last one wins for user overrides)
 39    let match: ParsedBinding | undefined
 40    const ctxSet = new Set(activeContexts)
 41  
 42    for (const binding of bindings) {
 43      // Phase 1: Only single-keystroke bindings
 44      if (binding.chord.length !== 1) continue
 45      if (!ctxSet.has(binding.context)) continue
 46  
 47      if (matchesBinding(input, key, binding)) {
 48        match = binding
 49      }
 50    }
 51  
 52    if (!match) {
 53      return { type: 'none' }
 54    }
 55  
 56    if (match.action === null) {
 57      return { type: 'unbound' }
 58    }
 59  
 60    return { type: 'match', action: match.action }
 61  }
 62  
 63  /**
 64   * Get display text for an action from bindings (e.g., "ctrl+t" for "app:toggleTodos").
 65   * Searches in reverse order so user overrides take precedence.
 66   */
 67  export function getBindingDisplayText(
 68    action: string,
 69    context: KeybindingContextName,
 70    bindings: ParsedBinding[],
 71  ): string | undefined {
 72    // Find the last binding for this action in this context
 73    const binding = bindings.findLast(
 74      b => b.action === action && b.context === context,
 75    )
 76    return binding ? chordToString(binding.chord) : undefined
 77  }
 78  
 79  /**
 80   * Build a ParsedKeystroke from Ink's input/key.
 81   */
 82  function buildKeystroke(input: string, key: Key): ParsedKeystroke | null {
 83    const keyName = getKeyName(input, key)
 84    if (!keyName) return null
 85  
 86    // QUIRK: Ink sets key.meta=true when escape is pressed (see input-event.ts).
 87    // This is legacy terminal behavior - we should NOT record this as a modifier
 88    // for the escape key itself, otherwise chord matching will fail.
 89    const effectiveMeta = key.escape ? false : key.meta
 90  
 91    return {
 92      key: keyName,
 93      ctrl: key.ctrl,
 94      alt: effectiveMeta,
 95      shift: key.shift,
 96      meta: effectiveMeta,
 97      super: key.super,
 98    }
 99  }
100  
101  /**
102   * Compare two ParsedKeystrokes for equality. Collapses alt/meta into
103   * one logical modifier — legacy terminals can't distinguish them (see
104   * match.ts modifiersMatch), so "alt+k" and "meta+k" are the same key.
105   * Super (cmd/win) is distinct — only arrives via kitty keyboard protocol.
106   */
107  export function keystrokesEqual(
108    a: ParsedKeystroke,
109    b: ParsedKeystroke,
110  ): boolean {
111    return (
112      a.key === b.key &&
113      a.ctrl === b.ctrl &&
114      a.shift === b.shift &&
115      (a.alt || a.meta) === (b.alt || b.meta) &&
116      a.super === b.super
117    )
118  }
119  
120  /**
121   * Check if a chord prefix matches the beginning of a binding's chord.
122   */
123  function chordPrefixMatches(
124    prefix: ParsedKeystroke[],
125    binding: ParsedBinding,
126  ): boolean {
127    if (prefix.length >= binding.chord.length) return false
128    for (let i = 0; i < prefix.length; i++) {
129      const prefixKey = prefix[i]
130      const bindingKey = binding.chord[i]
131      if (!prefixKey || !bindingKey) return false
132      if (!keystrokesEqual(prefixKey, bindingKey)) return false
133    }
134    return true
135  }
136  
137  /**
138   * Check if a full chord matches a binding's chord.
139   */
140  function chordExactlyMatches(
141    chord: ParsedKeystroke[],
142    binding: ParsedBinding,
143  ): boolean {
144    if (chord.length !== binding.chord.length) return false
145    for (let i = 0; i < chord.length; i++) {
146      const chordKey = chord[i]
147      const bindingKey = binding.chord[i]
148      if (!chordKey || !bindingKey) return false
149      if (!keystrokesEqual(chordKey, bindingKey)) return false
150    }
151    return true
152  }
153  
154  /**
155   * Resolve a key with chord state support.
156   *
157   * This function handles multi-keystroke chord bindings like "ctrl+k ctrl+s".
158   *
159   * @param input - The character input from Ink
160   * @param key - The Key object from Ink with modifier flags
161   * @param activeContexts - Array of currently active contexts
162   * @param bindings - All parsed bindings
163   * @param pending - Current chord state (null if not in a chord)
164   * @returns Resolution result with chord state
165   */
166  export function resolveKeyWithChordState(
167    input: string,
168    key: Key,
169    activeContexts: KeybindingContextName[],
170    bindings: ParsedBinding[],
171    pending: ParsedKeystroke[] | null,
172  ): ChordResolveResult {
173    // Cancel chord on escape
174    if (key.escape && pending !== null) {
175      return { type: 'chord_cancelled' }
176    }
177  
178    // Build current keystroke
179    const currentKeystroke = buildKeystroke(input, key)
180    if (!currentKeystroke) {
181      if (pending !== null) {
182        return { type: 'chord_cancelled' }
183      }
184      return { type: 'none' }
185    }
186  
187    // Build the full chord sequence to test
188    const testChord = pending
189      ? [...pending, currentKeystroke]
190      : [currentKeystroke]
191  
192    // Filter bindings by active contexts (Set lookup: O(n) instead of O(n·m))
193    const ctxSet = new Set(activeContexts)
194    const contextBindings = bindings.filter(b => ctxSet.has(b.context))
195  
196    // Check if this could be a prefix for longer chords. Group by chord
197    // string so a later null-override shadows the default it unbinds —
198    // otherwise null-unbinding `ctrl+x ctrl+k` still makes `ctrl+x` enter
199    // chord-wait and the single-key binding on the prefix never fires.
200    const chordWinners = new Map<string, string | null>()
201    for (const binding of contextBindings) {
202      if (
203        binding.chord.length > testChord.length &&
204        chordPrefixMatches(testChord, binding)
205      ) {
206        chordWinners.set(chordToString(binding.chord), binding.action)
207      }
208    }
209    let hasLongerChords = false
210    for (const action of chordWinners.values()) {
211      if (action !== null) {
212        hasLongerChords = true
213        break
214      }
215    }
216  
217    // If this keystroke could start a longer chord, prefer that
218    // (even if there's an exact single-key match)
219    if (hasLongerChords) {
220      return { type: 'chord_started', pending: testChord }
221    }
222  
223    // Check for exact matches (last one wins)
224    let exactMatch: ParsedBinding | undefined
225    for (const binding of contextBindings) {
226      if (chordExactlyMatches(testChord, binding)) {
227        exactMatch = binding
228      }
229    }
230  
231    if (exactMatch) {
232      if (exactMatch.action === null) {
233        return { type: 'unbound' }
234      }
235      return { type: 'match', action: exactMatch.action }
236    }
237  
238    // No match and no potential longer chords
239    if (pending !== null) {
240      return { type: 'chord_cancelled' }
241    }
242  
243    return { type: 'none' }
244  }