Skip to main content

fresh/input/
keybindings.rs

1use crate::config::Config;
2use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3use rust_i18n::t;
4use std::collections::HashMap;
5use std::sync::atomic::{AtomicBool, Ordering};
6
7/// Normalize a key for consistent lookup in keybinding resolution.
8///
9/// Terminals vary in how they report certain keys:
10/// - BackTab already encodes Shift+Tab, but some terminals also set SHIFT —
11///   strip the redundant SHIFT so bindings defined as "BackTab" match.
12/// - Shift+Backspace has no distinct semantics from plain Backspace — strip
13///   the redundant SHIFT so bindings defined as "Backspace" match both.
14/// - Uppercase letters may arrive as `Char('P')` + SHIFT (real Shift press
15///   with kitty keyboard protocol), `Char('P')` without SHIFT (typical
16///   terminal — the case carries the shift information, including with ALT:
17///   Alt+Shift+F arrives as `Char('F')` + ALT), or `Char('A')` with only
18///   CONTROL (CapsLock+Ctrl+A in some terminals). Lowercase the character and
19///   infer SHIFT so that a binding defined as `Shift+P` / `Alt+Shift+F`
20///   matches whether or not the terminal reports the modifier. The one
21///   exception is CONTROL: an uppercase letter with Ctrl is ambiguous between
22///   CapsLock+Ctrl+letter and Shift+Ctrl+letter, and treating it as the
23///   former preserves the long-standing intent that CapsLock+Ctrl+A still
24///   triggers the `Ctrl+A` binding — so don't infer SHIFT when CONTROL is set.
25pub(crate) fn normalize_key(code: KeyCode, modifiers: KeyModifiers) -> (KeyCode, KeyModifiers) {
26    if code == KeyCode::BackTab {
27        return (code, modifiers.difference(KeyModifiers::SHIFT));
28    }
29    if code == KeyCode::Backspace {
30        return (code, modifiers.difference(KeyModifiers::SHIFT));
31    }
32    if let KeyCode::Char(c) = code {
33        if c.is_ascii_uppercase() {
34            let new_modifiers = if modifiers.contains(KeyModifiers::CONTROL) {
35                modifiers
36            } else {
37                modifiers | KeyModifiers::SHIFT
38            };
39            return (KeyCode::Char(c.to_ascii_lowercase()), new_modifiers);
40        }
41    }
42    (code, modifiers)
43}
44
45/// Global flag to force Linux-style keybinding display (Alt/Shift instead of ⌥/⇧)
46/// This is primarily used in tests to ensure consistent output across platforms.
47static FORCE_LINUX_KEYBINDINGS: AtomicBool = AtomicBool::new(false);
48
49/// Force Linux-style keybinding display (Alt/Shift instead of ⌥/⇧)
50/// Call this in tests to ensure consistent output regardless of platform.
51pub fn set_force_linux_keybindings(force: bool) {
52    FORCE_LINUX_KEYBINDINGS.store(force, Ordering::SeqCst);
53}
54
55/// Check if we should use macOS-style symbols for Alt and Shift keybindings
56fn use_macos_symbols() -> bool {
57    if FORCE_LINUX_KEYBINDINGS.load(Ordering::SeqCst) {
58        return false;
59    }
60    cfg!(target_os = "macos")
61}
62
63/// Check if the given modifiers allow text input (character insertion).
64///
65/// Returns true for:
66/// - No modifiers
67/// - Shift only (for uppercase letters, symbols)
68/// - Ctrl+Alt on Windows (AltGr key, used for special characters on international keyboards)
69///
70/// On Windows, the AltGr key is reported as Ctrl+Alt by crossterm, which is needed for
71/// typing characters like @, [, ], {, }, etc. on German, French, and other keyboard layouts.
72/// See: https://github.com/crossterm-rs/crossterm/issues/820
73fn is_text_input_modifier(modifiers: KeyModifiers) -> bool {
74    if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT {
75        return true;
76    }
77
78    // Windows: AltGr is reported as Ctrl+Alt by crossterm.
79    // AltGr+Shift is needed for some layouts (e.g. Italian: AltGr+Shift+è = '{').
80    // See: https://github.com/sinelaw/fresh/issues/993
81    #[cfg(windows)]
82    if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT)
83        || modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT)
84    {
85        return true;
86    }
87
88    false
89}
90
91/// Format a keybinding as a user-friendly string
92/// On macOS, uses native symbols: ⌃ (Control), ⌥ (Option), ⇧ (Shift) without separators
93/// On other platforms, uses "Ctrl+Alt+Shift+" format
94pub fn format_keybinding(keycode: &KeyCode, modifiers: &KeyModifiers) -> String {
95    let mut result = String::new();
96
97    // On macOS, use native symbols: ⌃ (Control), ⌥ (Option/Alt), ⇧ (Shift), ⌘ (Command)
98    let (ctrl_label, alt_label, shift_label, super_label) = if use_macos_symbols() {
99        ("⌃", "⌥", "⇧", "⌘")
100    } else {
101        ("Ctrl", "Alt", "Shift", "Super")
102    };
103
104    let use_plus = !use_macos_symbols();
105
106    if modifiers.contains(KeyModifiers::SUPER) {
107        result.push_str(super_label);
108        if use_plus {
109            result.push('+');
110        }
111    }
112    if modifiers.contains(KeyModifiers::CONTROL) {
113        result.push_str(ctrl_label);
114        if use_plus {
115            result.push('+');
116        }
117    }
118    if modifiers.contains(KeyModifiers::ALT) {
119        result.push_str(alt_label);
120        if use_plus {
121            result.push('+');
122        }
123    }
124    if modifiers.contains(KeyModifiers::SHIFT) {
125        result.push_str(shift_label);
126        if use_plus {
127            result.push('+');
128        }
129    }
130
131    match keycode {
132        KeyCode::Enter => result.push_str("Enter"),
133        KeyCode::Backspace => result.push_str("Backspace"),
134        KeyCode::Delete => result.push_str("Del"),
135        KeyCode::Tab => result.push_str("Tab"),
136        KeyCode::Esc => result.push_str("Esc"),
137        KeyCode::Left => result.push('←'),
138        KeyCode::Right => result.push('→'),
139        KeyCode::Up => result.push('↑'),
140        KeyCode::Down => result.push('↓'),
141        KeyCode::Home => result.push_str("Home"),
142        KeyCode::End => result.push_str("End"),
143        KeyCode::PageUp => result.push_str("PgUp"),
144        KeyCode::PageDown => result.push_str("PgDn"),
145        KeyCode::Char(' ') => result.push_str("Space"),
146        KeyCode::Char(c) => result.push_str(&c.to_uppercase().to_string()),
147        KeyCode::F(n) => result.push_str(&format!("F{}", n)),
148        _ => return String::new(),
149    }
150
151    result
152}
153
154/// Returns a priority score for a keybinding key.
155/// Lower scores indicate canonical/preferred keys, higher scores indicate terminal equivalents.
156/// This helps ensure deterministic selection when multiple keybindings exist for an action.
157fn keybinding_priority_score(key: &KeyCode) -> u32 {
158    match key {
159        // Terminal equivalents get higher scores (deprioritized)
160        KeyCode::Char('@') => 100, // Equivalent of Space
161        KeyCode::Char('7') => 100, // Equivalent of /
162        KeyCode::Char('_') => 100, // Equivalent of -
163        // Ctrl+H as backspace equivalent is handled differently (only plain Ctrl+H)
164        // All other keys get default priority
165        _ => 0,
166    }
167}
168
169/// Returns terminal key equivalents for a given key combination.
170///
171/// Some key combinations are sent differently by terminals:
172/// - Ctrl+/ is often sent as Ctrl+7
173/// - Ctrl+Backspace is often sent as Ctrl+H
174/// - Ctrl+Space is often sent as Ctrl+@ (NUL)
175/// - Ctrl+[ is often sent as Escape
176///
177/// This function returns any equivalent key combinations that should be
178/// treated as aliases for the given key.
179pub fn terminal_key_equivalents(
180    key: KeyCode,
181    modifiers: KeyModifiers,
182) -> Vec<(KeyCode, KeyModifiers)> {
183    let mut equivalents = Vec::new();
184
185    // Only consider equivalents when Ctrl is pressed
186    if modifiers.contains(KeyModifiers::CONTROL) {
187        let base_modifiers = modifiers; // Keep all modifiers including Ctrl
188
189        match key {
190            // Ctrl+/ is often sent as Ctrl+7
191            KeyCode::Char('/') => {
192                equivalents.push((KeyCode::Char('7'), base_modifiers));
193            }
194            KeyCode::Char('7') => {
195                equivalents.push((KeyCode::Char('/'), base_modifiers));
196            }
197
198            // Ctrl+Backspace is often sent as Ctrl+H
199            KeyCode::Backspace => {
200                equivalents.push((KeyCode::Char('h'), base_modifiers));
201            }
202            KeyCode::Char('h') if modifiers == KeyModifiers::CONTROL => {
203                // Only add Backspace equivalent for plain Ctrl+H (not Ctrl+Shift+H etc.)
204                equivalents.push((KeyCode::Backspace, base_modifiers));
205            }
206
207            // Ctrl+Space is often sent as Ctrl+@ (NUL character, code 0)
208            KeyCode::Char(' ') => {
209                equivalents.push((KeyCode::Char('@'), base_modifiers));
210            }
211            KeyCode::Char('@') => {
212                equivalents.push((KeyCode::Char(' '), base_modifiers));
213            }
214
215            // Ctrl+- is often sent as Ctrl+_
216            KeyCode::Char('-') => {
217                equivalents.push((KeyCode::Char('_'), base_modifiers));
218            }
219            KeyCode::Char('_') => {
220                equivalents.push((KeyCode::Char('-'), base_modifiers));
221            }
222
223            _ => {}
224        }
225    }
226
227    equivalents
228}
229
230/// Context in which a keybinding is active
231#[derive(Debug, Clone, PartialEq, Eq, Hash)]
232pub enum KeyContext {
233    /// Global bindings that work in all contexts (checked first with highest priority)
234    Global,
235    /// Normal editing mode
236    Normal,
237    /// Prompt/minibuffer is active
238    Prompt,
239    /// A find/replace search prompt is active. A narrowing of `Prompt`: it
240    /// owns the match-mode toggles (case / whole word / regex / confirm-each)
241    /// and falls through to `Prompt` for every generic editing/navigation key.
242    /// Keeping these toggles out of the broad `Prompt` scope is what stops
243    /// e.g. Alt+W from flipping whole-word match mode while an unrelated
244    /// prompt (the save/discard/cancel close confirmation) is up.
245    SearchPrompt,
246    /// Popup window is visible
247    Popup,
248    /// Completion popup is visible (LSP or non-LSP). Takes precedence over `Popup`
249    /// for `PopupKind::Completion` so accept/dismiss can be bound independently
250    /// of the generic popup keys (Enter/Esc/Up/Down/PageUp/PageDown).
251    Completion,
252    /// File explorer has focus
253    FileExplorer,
254    /// The editor-global orchestrator dock has focus. Like
255    /// `FileExplorer` it is a persistent, non-modal chrome region, but
256    /// it is owned by the `Editor` (not per-window) — it shows all
257    /// sessions and survives session switches.
258    Dock,
259    /// Menu bar is active
260    Menu,
261    /// Terminal has focus
262    Terminal,
263    /// Settings modal is active
264    Settings,
265    /// Composite buffer (side-by-side diff) has focus
266    CompositeBuffer,
267    /// Buffer-local mode context (e.g. "search-replace-list")
268    Mode(String),
269}
270
271impl KeyContext {
272    /// Whether this context should allow all Normal-context bindings as fallbacks.
273    ///
274    /// CompositeBuffer is a specialized Normal view with extra context-specific
275    /// bindings layered on top. All standard navigation bindings (arrows,
276    /// PageUp/Down, etc.) should still work, so it falls through fully to
277    /// Normal rather than restricting to application-wide actions only.
278    pub fn allows_normal_fallthrough(&self) -> bool {
279        matches!(self, Self::CompositeBuffer)
280    }
281
282    /// Whether this context should allow Normal-context bindings to fall
283    /// through when the action is in the curated UI / navigation set
284    /// (`is_terminal_ui_action`): tab/buffer switching, split navigation,
285    /// palette, settings, etc. These actions don't depend on a text cursor
286    /// and naturally apply to whichever buffer is currently active, so
287    /// users expect them to work even when keyboard focus is elsewhere
288    /// (e.g. on the file explorer). Issue #1903.
289    ///
290    /// Also true for plugin `Mode(_)` contexts so that focus inside a
291    /// panel (search/replace, dashboard, git log, …) doesn't swallow
292    /// global navigation keys. `is_terminal_ui_action` is the curated
293    /// whitelist (split nav, palette, save, quit, help, …) — none of
294    /// which a sensible plugin mode would want to suppress. See §18 of
295    /// `docs/internal/search-replace-scope-replan-on-widgets.md`.
296    pub fn allows_ui_fallthrough(&self) -> bool {
297        matches!(self, Self::FileExplorer | Self::Dock | Self::Mode(_))
298    }
299
300    /// Check if a context should allow input
301    pub fn allows_text_input(&self) -> bool {
302        matches!(
303            self,
304            Self::Normal | Self::Prompt | Self::SearchPrompt | Self::FileExplorer
305        )
306    }
307
308    /// The context whose bindings this context inherits when it has no binding
309    /// of its own for a key. `SearchPrompt` is a narrowing of `Prompt`, so it
310    /// inherits all of `Prompt`'s editing/navigation/confirm keys and only
311    /// adds (or overrides) the few search-option toggles it owns. Checked
312    /// after this context's own bindings but before the Normal fallthrough.
313    pub fn parent_context(&self) -> Option<Self> {
314        match self {
315            Self::SearchPrompt => Some(Self::Prompt),
316            _ => None,
317        }
318    }
319
320    /// Parse context from a "when" string
321    pub fn from_when_clause(when: &str) -> Option<Self> {
322        let trimmed = when.trim();
323        if let Some(mode_name) = trimmed.strip_prefix("mode:") {
324            return Some(Self::Mode(mode_name.to_string()));
325        }
326        Some(match trimmed {
327            "global" => Self::Global,
328            "prompt" => Self::Prompt,
329            "searchPrompt" | "search_prompt" => Self::SearchPrompt,
330            "popup" => Self::Popup,
331            "completion" => Self::Completion,
332            "fileExplorer" | "file_explorer" => Self::FileExplorer,
333            "dock" => Self::Dock,
334            "normal" => Self::Normal,
335            "menu" => Self::Menu,
336            "terminal" => Self::Terminal,
337            "settings" => Self::Settings,
338            "compositeBuffer" | "composite_buffer" => Self::CompositeBuffer,
339            _ => return None,
340        })
341    }
342
343    /// Convert context to "when" clause string
344    pub fn to_when_clause(&self) -> String {
345        match self {
346            Self::Global => "global".to_string(),
347            Self::Normal => "normal".to_string(),
348            Self::Prompt => "prompt".to_string(),
349            Self::SearchPrompt => "searchPrompt".to_string(),
350            Self::Popup => "popup".to_string(),
351            Self::Completion => "completion".to_string(),
352            Self::FileExplorer => "fileExplorer".to_string(),
353            Self::Dock => "dock".to_string(),
354            Self::Menu => "menu".to_string(),
355            Self::Terminal => "terminal".to_string(),
356            Self::Settings => "settings".to_string(),
357            Self::CompositeBuffer => "compositeBuffer".to_string(),
358            Self::Mode(name) => format!("mode:{}", name),
359        }
360    }
361}
362
363/// High-level actions that can be performed in the editor
364#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
365pub enum Action {
366    // Character input
367    InsertChar(char),
368    InsertNewline,
369    InsertTab,
370
371    // Basic movement
372    MoveLeft,
373    MoveRight,
374    MoveUp,
375    MoveDown,
376    MoveWordLeft,
377    MoveWordRight,
378    MoveWordEnd,     // Move to end of current word (Ctrl+Right style, past the end)
379    ViMoveWordEnd,   // Vim 'e' - move to end of word (ON last char, advances from word-end)
380    MoveLeftInLine,  // Move left without crossing line boundaries
381    MoveRightInLine, // Move right without crossing line boundaries
382    MoveLineStart,
383    MoveLineEnd,
384    MoveLineUp,
385    MoveLineDown,
386    MoveToParagraphUp,
387    MoveToParagraphDown,
388    MovePageUp,
389    MovePageDown,
390    MoveDocumentStart,
391    MoveDocumentEnd,
392
393    // Selection movement (extends selection while moving)
394    SelectLeft,
395    SelectRight,
396    SelectUp,
397    SelectDown,
398    SelectToParagraphUp,   // Jump to previous empty line with selection
399    SelectToParagraphDown, // Jump to next empty line with selection
400    SelectWordLeft,
401    SelectWordRight,
402    SelectWordEnd,   // Select to end of current word
403    ViSelectWordEnd, // Vim 'e' selection - select to end of word (ON last char)
404    SelectLineStart,
405    SelectLineEnd,
406    SelectDocumentStart,
407    SelectDocumentEnd,
408    SelectPageUp,
409    SelectPageDown,
410    SelectAll,
411    SelectWord,
412    SelectLine,
413    ExpandSelection,
414
415    // Block/rectangular selection (column-wise)
416    BlockSelectLeft,
417    BlockSelectRight,
418    BlockSelectUp,
419    BlockSelectDown,
420
421    // Editing
422    DeleteBackward,
423    DeleteForward,
424    DeleteWordBackward,
425    DeleteWordForward,
426    DeleteLine,
427    DeleteToLineEnd,
428    DeleteToLineStart,
429    DeleteViWordEnd, // Delete from cursor to end of word (vim de)
430    TransposeChars,
431    OpenLine,
432    DuplicateLine,
433
434    // View
435    Recenter,
436
437    // Selection
438    SetMark,
439    CancelMark,
440    ClearMark,
441
442    // Clipboard
443    Copy,
444    CopyWithTheme(String),
445    Cut,
446    Paste,
447    /// Copy the absolute filesystem path of the active buffer's file to the clipboard.
448    CopyFilePath,
449    /// Copy the active buffer's file path relative to the workspace root, falling
450    /// back to the absolute path if the file lives outside the workspace.
451    CopyRelativeFilePath,
452
453    // Vi-style yank (copy without selection, then restore cursor)
454    YankWordForward,
455    YankWordBackward,
456    YankToLineEnd,
457    YankToLineStart,
458    YankViWordEnd, // Yank from cursor to end of word (vim ye)
459
460    // Multi-cursor
461    AddCursorAbove,
462    AddCursorBelow,
463    AddCursorNextMatch,
464    AddCursorsToLineEnds,
465    RemoveSecondaryCursors,
466
467    // File operations
468    Save,
469    SaveAs,
470    Open,
471    SwitchProject,
472    New,
473    Close,
474    CloseTab,
475    Quit,
476    ForceQuit,
477    Detach,
478    Revert,
479    ToggleAutoRevert,
480    FormatBuffer,
481    TrimTrailingWhitespace,
482    EnsureFinalNewline,
483
484    // Navigation
485    GotoLine,
486    ScanLineIndex,
487    GoToMatchingBracket,
488    JumpToNextError,
489    JumpToPreviousError,
490
491    // Smart editing
492    SmartHome,
493    DedentSelection,
494    ToggleComment,
495    DabbrevExpand,
496    ToggleFold,
497
498    // Bookmarks
499    SetBookmark(char),
500    JumpToBookmark(char),
501    ClearBookmark(char),
502    ListBookmarks,
503
504    // Search options
505    ToggleSearchCaseSensitive,
506    ToggleSearchWholeWord,
507    ToggleSearchRegex,
508    ToggleSearchConfirmEach,
509
510    // Macros
511    StartMacroRecording,
512    StopMacroRecording,
513    PlayMacro(char),
514    ToggleMacroRecording(char),
515    ShowMacro(char),
516    ListMacros,
517    PromptRecordMacro,
518    PromptPlayMacro,
519    PlayLastMacro,
520    /// Prompt for a register, then append its macro to `init.ts` as an
521    /// editable `editor.defineMacro(...)` block and reload.
522    PromptSaveMacroToInit,
523    /// Prompt for a register, then append its macro to `init.ts` as an
524    /// editable `registerHandler`/`registerCommand` stub and reload.
525    PromptPromoteMacro,
526
527    // Bookmarks (prompt-based)
528    PromptSetBookmark,
529    PromptJumpToBookmark,
530
531    // Undo/redo
532    Undo,
533    Redo,
534
535    // View
536    ScrollUp,
537    ScrollDown,
538    ShowHelp,
539    ShowKeyboardShortcuts,
540    ShowWarnings,
541    ShowStatusLog,
542    ShowLspStatus,
543    ShowRemoteIndicatorMenu,
544    ShowReadOnlyMenu,
545    ClearWarnings,
546    CommandPalette, // Alias for QuickOpen — kept for keymap/plugin compatibility
547    /// Quick Open - unified prompt with prefix-based provider routing
548    QuickOpen,
549    /// Quick Open - buffers (prefix: "#")
550    QuickOpenBuffers,
551    /// Quick Open - files (empty prefix)
552    QuickOpenFiles,
553    /// Open Live Grep as a floating overlay (issue #1796).
554    OpenLiveGrep,
555    /// Re-open Live Grep with the prior query and selection.
556    ResumeLiveGrep,
557    /// Toggle focus on the Utility Dock. If the dock exists and is not
558    /// focused, focus it. If it is focused, return focus to the
559    /// previously active editor split.
560    ToggleUtilityDock,
561    /// Open a terminal inside the Utility Dock (creates the dock if
562    /// absent; otherwise swaps the active dock buffer to a terminal).
563    OpenTerminalInDock,
564    /// Switch the Live Grep overlay to the next available registered
565    /// search provider (skipping unavailable ones), then re-run the
566    /// current query under it. Plumbs through to the
567    /// `live_grep_cycle_provider` plugin handler.
568    CycleLiveGrepProvider,
569    ToggleLineWrap,
570    ToggleCurrentLineHighlight,
571    ToggleOccurrenceHighlight,
572    ToggleReadOnly,
573    TogglePageView,
574    SetPageWidth,
575    InspectThemeAtCursor,
576    SelectTheme,
577    SelectKeybindingMap,
578    SelectCursorStyle,
579    SelectLocale,
580
581    // Buffer/tab navigation
582    NextBuffer,
583    PrevBuffer,
584    SwitchToPreviousTab,
585    SwitchToTabByName,
586
587    // Tab scrolling
588    ScrollTabsLeft,
589    ScrollTabsRight,
590
591    // Position history navigation
592    NavigateBack,
593    NavigateForward,
594
595    // Split view operations
596    SplitHorizontal,
597    SplitVertical,
598    CloseSplit,
599    NextSplit,
600    PrevSplit,
601    NextWindow,
602    PrevWindow,
603    IncreaseSplitSize,
604    DecreaseSplitSize,
605    ToggleMaximizeSplit,
606
607    // Prompt mode actions
608    PromptConfirm,
609    /// PromptConfirm with recorded text for macro playback
610    PromptConfirmWithText(String),
611    PromptCancel,
612    PromptBackspace,
613    PromptDelete,
614    PromptMoveLeft,
615    PromptMoveRight,
616    PromptMoveStart,
617    PromptMoveEnd,
618    PromptSelectPrev,
619    PromptSelectNext,
620    PromptPageUp,
621    PromptPageDown,
622    PromptAcceptSuggestion,
623    PromptMoveWordLeft,
624    PromptMoveWordRight,
625    // Advanced prompt editing (word operations, clipboard)
626    PromptDeleteWordForward,
627    PromptDeleteWordBackward,
628    PromptDeleteToLineEnd,
629    PromptCopy,
630    PromptCut,
631    PromptPaste,
632    // Prompt selection actions
633    PromptMoveLeftSelecting,
634    PromptMoveRightSelecting,
635    PromptMoveHomeSelecting,
636    PromptMoveEndSelecting,
637    PromptSelectWordLeft,
638    PromptSelectWordRight,
639    PromptSelectAll,
640
641    // File browser actions
642    FileBrowserToggleHidden,
643    FileBrowserToggleDetectEncoding,
644
645    // Popup mode actions
646    PopupSelectNext,
647    PopupSelectPrev,
648    PopupPageUp,
649    PopupPageDown,
650    PopupConfirm,
651    PopupCancel,
652    /// Transfer keyboard focus to the topmost visible popup. LSP popups
653    /// are shown unfocused (so they don't silently swallow keystrokes);
654    /// this action lets the user grab them with the keyboard. Default
655    /// binding is `Alt+T` and is configurable via the keybinding map.
656    PopupFocus,
657
658    // Completion popup actions (override generic Popup keys for PopupKind::Completion)
659    CompletionAccept,
660    CompletionDismiss,
661
662    // File explorer operations
663    ToggleFileExplorer,
664    // File explorer side (left/right) toggle
665    ToggleFileExplorerSide,
666    // Menu bar visibility
667    ToggleMenuBar,
668    // Tab bar visibility
669    ToggleTabBar,
670    // Status bar visibility
671    ToggleStatusBar,
672    // Prompt line visibility
673    TogglePromptLine,
674    // Scrollbar visibility
675    ToggleVerticalScrollbar,
676    ToggleHorizontalScrollbar,
677    FocusFileExplorer,
678    FocusEditor,
679    /// Toggle keyboard focus between the editor/explorer area and the
680    /// persistent orchestrator dock (the left session column). When the
681    /// dock is hidden, this opens and focuses it.
682    ToggleDockFocus,
683    FileExplorerUp,
684    FileExplorerDown,
685    FileExplorerPageUp,
686    FileExplorerPageDown,
687    FileExplorerExpand,
688    FileExplorerCollapse,
689    FileExplorerOpen,
690    FileExplorerRefresh,
691    FileExplorerNewFile,
692    FileExplorerNewDirectory,
693    FileExplorerDelete,
694    FileExplorerRename,
695    FileExplorerToggleHidden,
696    FileExplorerToggleGitignored,
697    FileExplorerSearchClear,
698    FileExplorerSearchBackspace,
699    FileExplorerCopy,
700    FileExplorerCut,
701    FileExplorerPaste,
702    FileExplorerDuplicate,
703    FileExplorerCopyFullPath,
704    FileExplorerCopyRelativePath,
705    FileExplorerExtendSelectionUp,
706    FileExplorerExtendSelectionDown,
707    FileExplorerToggleSelect,
708    FileExplorerSelectAll,
709
710    // LSP operations
711    LspCompletion,
712    LspGotoDefinition,
713    LspReferences,
714    LspImplementation,
715    LspRename,
716    LspHover,
717    LspSignatureHelp,
718    LspCodeActions,
719    LspRestart,
720    LspStop,
721    LspToggleForBuffer,
722    ToggleInlayHints,
723    ToggleMouseHover,
724
725    // View toggles
726    ToggleLineNumbers,
727    /// Toggle line-number visibility for the current buffer only (per-buffer
728    /// override that persists across restart, without touching the global
729    /// default or other buffers).
730    ToggleLineNumbersCurrentBuffer,
731    /// Toggle line wrap for the current buffer only (per-buffer override that
732    /// persists across restart, without touching the global default or other
733    /// buffers).
734    ToggleLineWrapCurrentBuffer,
735    /// Playful full-screen wave that bounces all painted content around.
736    TriggerWaveAnimation,
737    ToggleScrollSync,
738    ToggleMouseCapture,
739    ToggleDebugHighlights, // Debug mode: show highlight/overlay byte ranges
740    SetBackground,
741    SetBackgroundBlend,
742
743    // Buffer settings (per-buffer overrides)
744    SetTabSize,
745    SetLineEnding,
746    SetEncoding,
747    ReloadWithEncoding,
748    SetLanguage,
749    ToggleIndentationStyle,
750    ToggleTabIndicators,
751    ToggleWhitespaceIndicators,
752    ResetBufferSettings,
753    AddRuler,
754    RemoveRuler,
755
756    // Config operations
757    DumpConfig,
758
759    // Force a full terminal clear + redraw (fixes display corruption from external output)
760    RedrawScreen,
761
762    // Search and replace
763    Search,
764    FindInSelection,
765    FindNext,
766    FindPrevious,
767    FindSelectionNext,     // Quick find next occurrence of selection (Ctrl+F3)
768    FindSelectionPrevious, // Quick find previous occurrence of selection (Ctrl+Shift+F3)
769    ClearSearch,           // Clear active search highlights (no-op if no search is active)
770    Replace,
771    QueryReplace, // Interactive replace (y/n/!/q for each match)
772
773    // Menu navigation
774    MenuActivate,     // Open menu bar (Alt or F10)
775    MenuClose,        // Close menu (Esc)
776    MenuLeft,         // Navigate to previous menu
777    MenuRight,        // Navigate to next menu
778    MenuUp,           // Navigate to previous item in menu
779    MenuDown,         // Navigate to next item in menu
780    MenuExecute,      // Execute selected menu item (Enter)
781    MenuOpen(String), // Open a specific menu by name (e.g., "File", "Edit")
782
783    // Keybinding map switching
784    SwitchKeybindingMap(String), // Switch to a named keybinding map (e.g., "default", "emacs", "vscode")
785
786    // Plugin custom actions
787    PluginAction(String),
788
789    // Settings operations
790    OpenSettings,        // Open the settings modal
791    CloseSettings,       // Close the settings modal
792    SettingsSave,        // Save settings changes
793    SettingsReset,       // Reset current setting to default
794    SettingsToggleFocus, // Toggle focus between category and settings panels
795    SettingsActivate,    // Activate/toggle the current setting
796    SettingsSearch,      // Start search in settings
797    SettingsHelp,        // Show settings help overlay
798    SettingsIncrement,   // Increment number value or next dropdown option
799    SettingsDecrement,   // Decrement number value or previous dropdown option
800    SettingsInherit,     // Set nullable setting to null (inherit value)
801
802    // Terminal operations
803    OpenTerminal,            // Open a new terminal in the current split
804    OpenTerminalRight,       // Open a new terminal in a split to the right (vertical split)
805    OpenTerminalBelow,       // Open a new terminal in a split below (horizontal split)
806    CloseTerminal,           // Close the current terminal
807    FocusTerminal,           // Focus the terminal buffer (if viewing terminal, focus input)
808    TerminalEscape,          // Escape from terminal mode back to editor
809    ToggleKeyboardCapture,   // Toggle keyboard capture mode (all keys go to terminal)
810    TerminalPaste,           // Paste clipboard contents into terminal as a single batch
811    SendSelectionToTerminal, // Run the selection (or current line) in the last-focused terminal
812
813    // Shell command operations
814    ShellCommand,        // Run shell command on buffer/selection, output to new buffer
815    ShellCommandReplace, // Run shell command on buffer/selection, replace content
816
817    // Case conversion
818    ToUpperCase, // Convert selection to uppercase
819    ToLowerCase, // Convert selection to lowercase
820    ToggleCase,  // Toggle case of character under cursor (vim ~)
821    SortLines,   // Sort selected lines alphabetically
822
823    // Input calibration
824    CalibrateInput, // Open the input calibration wizard
825
826    // Event debug
827    EventDebug, // Open the event debug dialog
828
829    // Process control
830    SuspendProcess, // Suspend the editor process (SIGTSTP on Unix); resume with `fg`
831
832    // Keybinding editor
833    OpenKeybindingEditor, // Open the keybinding editor modal
834
835    // Plugin development
836    LoadPluginFromBuffer, // Load current buffer as a plugin
837
838    // User init.ts (design M4, M5, M6)
839    InitReload, // Reload ~/.config/fresh/init.ts via the existing plugin pipeline
840    InitEdit,   // Open ~/.config/fresh/init.ts (creates from template if missing)
841    InitCheck,  // Syntax-check ~/.config/fresh/init.ts via oxc
842
843    // Composite buffer (side-by-side diff) hunk navigation
844    CompositeNextHunk, // Navigate to the next hunk in a composite diff view
845    CompositePrevHunk, // Navigate to the previous hunk in a composite diff view
846
847    // Workspace trust (per-project process-execution policy)
848    WorkspaceTrustTrust,    // Trust this workspace: allow all process execution
849    WorkspaceTrustRestrict, // Restrict: no repo-controlled execution (the safe default)
850    WorkspaceTrustBlock,    // Block: no process execution at all
851    WorkspaceTrustPrompt,   // Open the workspace-trust dialog to change the level
852
853    // No-op
854    None,
855}
856
857/// Macro that generates both `Action::from_str` and `Action::all_action_names` from a single
858/// definition, ensuring the list of valid action name strings is always in sync at compile time.
859///
860/// The first argument (`$args_name`) is the identifier used for the args parameter in custom
861/// bodies. This is needed so that macro hygiene allows the custom body expressions to reference
862/// the function parameter (both the definition and usage share the call-site span).
863///
864/// Four categories of action mappings:
865/// - `simple`: `"name" => Variant` — no args needed
866/// - `alias`: `"name" => Variant` — like simple, but only for from_str (not to_action_str)
867/// - `with_char`: `"name" => Variant` — passes through `with_char(args, ...)` for char-arg actions
868/// - `custom`: `"name" => { body }` — arbitrary expression using `$args_name` for complex arg parsing
869macro_rules! define_action_str_mapping {
870    (
871        $args_name:ident;
872        simple { $($s_name:literal => $s_variant:ident),* $(,)? }
873        alias { $($a_name:literal => $a_variant:ident),* $(,)? }
874        with_char { $($c_name:literal => $c_variant:ident),* $(,)? }
875        custom { $($x_name:literal => $x_variant:ident : $x_body:expr),* $(,)? }
876    ) => {
877        /// Parse action from string (used when loading from config)
878        pub fn from_str(s: &str, $args_name: &HashMap<String, serde_json::Value>) -> Option<Self> {
879            Some(match s {
880                $($s_name => Self::$s_variant,)*
881                $($a_name => Self::$a_variant,)*
882                $($c_name => return Self::with_char($args_name, Self::$c_variant),)*
883                $($x_name => $x_body,)*
884                // Unrecognized action names are treated as plugin actions, allowing
885                // keybindings for plugin-registered commands to load from config.
886                _ => Self::PluginAction(s.to_string()),
887            })
888        }
889
890        /// Convert an action back to its string name (inverse of from_str).
891        /// Returns the canonical action name string.
892        pub fn to_action_str(&self) -> String {
893            match self {
894                $(Self::$s_variant => $s_name.to_string(),)*
895                $(Self::$c_variant(_) => $c_name.to_string(),)*
896                $(Self::$x_variant(_) => $x_name.to_string(),)*
897                Self::PluginAction(name) => name.clone(),
898            }
899        }
900
901        /// All valid action name strings, sorted alphabetically.
902        /// Generated from the same macro as `from_str`, guaranteeing compile-time completeness.
903        pub fn all_action_names() -> Vec<String> {
904            let mut names = vec![
905                $($s_name.to_string(),)*
906                $($a_name.to_string(),)*
907                $($c_name.to_string(),)*
908                $($x_name.to_string(),)*
909            ];
910            names.sort();
911            names
912        }
913    };
914}
915
916impl Action {
917    fn with_char(
918        args: &HashMap<String, serde_json::Value>,
919        make_action: impl FnOnce(char) -> Self,
920    ) -> Option<Self> {
921        if let Some(serde_json::Value::String(value)) = args.get("char") {
922            value.chars().next().map(make_action)
923        } else {
924            None
925        }
926    }
927
928    define_action_str_mapping! {
929        args;
930        simple {
931            "insert_newline" => InsertNewline,
932            "insert_tab" => InsertTab,
933
934            "move_left" => MoveLeft,
935            "move_right" => MoveRight,
936            "move_up" => MoveUp,
937            "move_down" => MoveDown,
938            "move_word_left" => MoveWordLeft,
939            "move_word_right" => MoveWordRight,
940            "move_word_end" => MoveWordEnd,
941            "vi_move_word_end" => ViMoveWordEnd,
942            "move_left_in_line" => MoveLeftInLine,
943            "move_right_in_line" => MoveRightInLine,
944            "move_line_start" => MoveLineStart,
945            "move_line_end" => MoveLineEnd,
946            "move_line_up" => MoveLineUp,
947            "move_line_down" => MoveLineDown,
948            "move_page_up" => MovePageUp,
949            "move_page_down" => MovePageDown,
950            "move_document_start" => MoveDocumentStart,
951            "move_document_end" => MoveDocumentEnd,
952            "move_to_paragraph_up" => MoveToParagraphUp,
953            "move_to_paragraph_down" => MoveToParagraphDown,
954
955            "select_left" => SelectLeft,
956            "select_right" => SelectRight,
957            "select_up" => SelectUp,
958            "select_down" => SelectDown,
959            "select_to_paragraph_up" => SelectToParagraphUp,
960            "select_to_paragraph_down" => SelectToParagraphDown,
961            "select_word_left" => SelectWordLeft,
962            "select_word_right" => SelectWordRight,
963            "select_word_end" => SelectWordEnd,
964            "vi_select_word_end" => ViSelectWordEnd,
965            "select_line_start" => SelectLineStart,
966            "select_line_end" => SelectLineEnd,
967            "select_document_start" => SelectDocumentStart,
968            "select_document_end" => SelectDocumentEnd,
969            "select_page_up" => SelectPageUp,
970            "select_page_down" => SelectPageDown,
971            "select_all" => SelectAll,
972            "select_word" => SelectWord,
973            "select_line" => SelectLine,
974            "expand_selection" => ExpandSelection,
975
976            "block_select_left" => BlockSelectLeft,
977            "block_select_right" => BlockSelectRight,
978            "block_select_up" => BlockSelectUp,
979            "block_select_down" => BlockSelectDown,
980
981            "delete_backward" => DeleteBackward,
982            "delete_forward" => DeleteForward,
983            "delete_word_backward" => DeleteWordBackward,
984            "delete_word_forward" => DeleteWordForward,
985            "delete_line" => DeleteLine,
986            "delete_to_line_end" => DeleteToLineEnd,
987            "delete_to_line_start" => DeleteToLineStart,
988            "delete_vi_word_end" => DeleteViWordEnd,
989            "transpose_chars" => TransposeChars,
990            "open_line" => OpenLine,
991            "duplicate_line" => DuplicateLine,
992            "recenter" => Recenter,
993            "set_mark" => SetMark,
994            "cancel_mark" => CancelMark,
995            "clear_mark" => ClearMark,
996
997            "copy" => Copy,
998            "cut" => Cut,
999            "paste" => Paste,
1000            "copy_file_path" => CopyFilePath,
1001            "copy_relative_file_path" => CopyRelativeFilePath,
1002
1003            "yank_word_forward" => YankWordForward,
1004            "yank_word_backward" => YankWordBackward,
1005            "yank_to_line_end" => YankToLineEnd,
1006            "yank_to_line_start" => YankToLineStart,
1007            "yank_vi_word_end" => YankViWordEnd,
1008
1009            "add_cursor_above" => AddCursorAbove,
1010            "add_cursor_below" => AddCursorBelow,
1011            "add_cursor_next_match" => AddCursorNextMatch,
1012            "add_cursors_to_line_ends" => AddCursorsToLineEnds,
1013            "remove_secondary_cursors" => RemoveSecondaryCursors,
1014
1015            "save" => Save,
1016            "save_as" => SaveAs,
1017            "open" => Open,
1018            "switch_project" => SwitchProject,
1019            "new" => New,
1020            "close" => Close,
1021            "close_tab" => CloseTab,
1022            "quit" => Quit,
1023            "force_quit" => ForceQuit,
1024            "detach" => Detach,
1025            "revert" => Revert,
1026            "toggle_auto_revert" => ToggleAutoRevert,
1027            "format_buffer" => FormatBuffer,
1028            "trim_trailing_whitespace" => TrimTrailingWhitespace,
1029            "ensure_final_newline" => EnsureFinalNewline,
1030            "goto_line" => GotoLine,
1031            "scan_line_index" => ScanLineIndex,
1032            "goto_matching_bracket" => GoToMatchingBracket,
1033            "jump_to_next_error" => JumpToNextError,
1034            "jump_to_previous_error" => JumpToPreviousError,
1035
1036            "smart_home" => SmartHome,
1037            "dedent_selection" => DedentSelection,
1038            "toggle_comment" => ToggleComment,
1039            "dabbrev_expand" => DabbrevExpand,
1040            "toggle_fold" => ToggleFold,
1041
1042            "list_bookmarks" => ListBookmarks,
1043
1044            "toggle_search_case_sensitive" => ToggleSearchCaseSensitive,
1045            "toggle_search_whole_word" => ToggleSearchWholeWord,
1046            "toggle_search_regex" => ToggleSearchRegex,
1047            "toggle_search_confirm_each" => ToggleSearchConfirmEach,
1048
1049            "start_macro_recording" => StartMacroRecording,
1050            "stop_macro_recording" => StopMacroRecording,
1051
1052            "list_macros" => ListMacros,
1053            "prompt_record_macro" => PromptRecordMacro,
1054            "prompt_play_macro" => PromptPlayMacro,
1055            "play_last_macro" => PlayLastMacro,
1056            "prompt_save_macro_to_init" => PromptSaveMacroToInit,
1057            "prompt_promote_macro" => PromptPromoteMacro,
1058            "prompt_set_bookmark" => PromptSetBookmark,
1059            "prompt_jump_to_bookmark" => PromptJumpToBookmark,
1060
1061            "undo" => Undo,
1062            "redo" => Redo,
1063
1064            "scroll_up" => ScrollUp,
1065            "scroll_down" => ScrollDown,
1066            "show_help" => ShowHelp,
1067            "keyboard_shortcuts" => ShowKeyboardShortcuts,
1068            "show_warnings" => ShowWarnings,
1069            "show_status_log" => ShowStatusLog,
1070            "show_lsp_status" => ShowLspStatus,
1071            "show_remote_indicator_menu" => ShowRemoteIndicatorMenu,
1072            "show_read_only_menu" => ShowReadOnlyMenu,
1073            "clear_warnings" => ClearWarnings,
1074            "command_palette" => CommandPalette,
1075            "quick_open" => QuickOpen,
1076            "quick_open_buffers" => QuickOpenBuffers,
1077            "quick_open_files" => QuickOpenFiles,
1078            "open_live_grep" => OpenLiveGrep,
1079            "resume_live_grep" => ResumeLiveGrep,
1080            "toggle_utility_dock" => ToggleUtilityDock,
1081            "open_terminal_in_dock" => OpenTerminalInDock,
1082            "cycle_live_grep_provider" => CycleLiveGrepProvider,
1083            "toggle_line_wrap" => ToggleLineWrap,
1084            "toggle_current_line_highlight" => ToggleCurrentLineHighlight,
1085            "toggle_occurrence_highlight" => ToggleOccurrenceHighlight,
1086            "toggle_read_only" => ToggleReadOnly,
1087            "toggle_page_view" => TogglePageView,
1088            "set_page_width" => SetPageWidth,
1089
1090            "next_buffer" => NextBuffer,
1091            "prev_buffer" => PrevBuffer,
1092            "switch_to_previous_tab" => SwitchToPreviousTab,
1093            "switch_to_tab_by_name" => SwitchToTabByName,
1094            "scroll_tabs_left" => ScrollTabsLeft,
1095            "scroll_tabs_right" => ScrollTabsRight,
1096
1097            "navigate_back" => NavigateBack,
1098            "navigate_forward" => NavigateForward,
1099
1100            "split_horizontal" => SplitHorizontal,
1101            "split_vertical" => SplitVertical,
1102            "close_split" => CloseSplit,
1103            "next_split" => NextSplit,
1104            "prev_split" => PrevSplit,
1105            "next_window" => NextWindow,
1106            "prev_window" => PrevWindow,
1107            "increase_split_size" => IncreaseSplitSize,
1108            "decrease_split_size" => DecreaseSplitSize,
1109            "toggle_maximize_split" => ToggleMaximizeSplit,
1110
1111            "prompt_confirm" => PromptConfirm,
1112            "prompt_cancel" => PromptCancel,
1113            "prompt_backspace" => PromptBackspace,
1114            "prompt_move_left" => PromptMoveLeft,
1115            "prompt_move_right" => PromptMoveRight,
1116            "prompt_move_start" => PromptMoveStart,
1117            "prompt_move_end" => PromptMoveEnd,
1118            "prompt_select_prev" => PromptSelectPrev,
1119            "prompt_select_next" => PromptSelectNext,
1120            "prompt_page_up" => PromptPageUp,
1121            "prompt_page_down" => PromptPageDown,
1122            "prompt_accept_suggestion" => PromptAcceptSuggestion,
1123            "prompt_delete_word_forward" => PromptDeleteWordForward,
1124            "prompt_delete_word_backward" => PromptDeleteWordBackward,
1125            "prompt_delete_to_line_end" => PromptDeleteToLineEnd,
1126            "prompt_copy" => PromptCopy,
1127            "prompt_cut" => PromptCut,
1128            "prompt_paste" => PromptPaste,
1129            "prompt_move_left_selecting" => PromptMoveLeftSelecting,
1130            "prompt_move_right_selecting" => PromptMoveRightSelecting,
1131            "prompt_move_home_selecting" => PromptMoveHomeSelecting,
1132            "prompt_move_end_selecting" => PromptMoveEndSelecting,
1133            "prompt_select_word_left" => PromptSelectWordLeft,
1134            "prompt_select_word_right" => PromptSelectWordRight,
1135            "prompt_select_all" => PromptSelectAll,
1136            "file_browser_toggle_hidden" => FileBrowserToggleHidden,
1137            "file_browser_toggle_detect_encoding" => FileBrowserToggleDetectEncoding,
1138            "prompt_move_word_left" => PromptMoveWordLeft,
1139            "prompt_move_word_right" => PromptMoveWordRight,
1140            "prompt_delete" => PromptDelete,
1141
1142            "popup_select_next" => PopupSelectNext,
1143            "popup_select_prev" => PopupSelectPrev,
1144            "popup_page_up" => PopupPageUp,
1145            "popup_page_down" => PopupPageDown,
1146            "popup_confirm" => PopupConfirm,
1147            "popup_cancel" => PopupCancel,
1148            "popup_focus" => PopupFocus,
1149
1150            "completion_accept" => CompletionAccept,
1151            "completion_dismiss" => CompletionDismiss,
1152
1153            "toggle_file_explorer" => ToggleFileExplorer,
1154            "toggle_file_explorer_side" => ToggleFileExplorerSide,
1155            "toggle_menu_bar" => ToggleMenuBar,
1156            "toggle_tab_bar" => ToggleTabBar,
1157            "toggle_status_bar" => ToggleStatusBar,
1158            "toggle_prompt_line" => TogglePromptLine,
1159            "toggle_vertical_scrollbar" => ToggleVerticalScrollbar,
1160            "toggle_horizontal_scrollbar" => ToggleHorizontalScrollbar,
1161            "focus_file_explorer" => FocusFileExplorer,
1162            "focus_editor" => FocusEditor,
1163            "toggle_dock_focus" => ToggleDockFocus,
1164            "file_explorer_up" => FileExplorerUp,
1165            "file_explorer_down" => FileExplorerDown,
1166            "file_explorer_page_up" => FileExplorerPageUp,
1167            "file_explorer_page_down" => FileExplorerPageDown,
1168            "file_explorer_expand" => FileExplorerExpand,
1169            "file_explorer_collapse" => FileExplorerCollapse,
1170            "file_explorer_open" => FileExplorerOpen,
1171            "file_explorer_refresh" => FileExplorerRefresh,
1172            "file_explorer_new_file" => FileExplorerNewFile,
1173            "file_explorer_new_directory" => FileExplorerNewDirectory,
1174            "file_explorer_delete" => FileExplorerDelete,
1175            "file_explorer_rename" => FileExplorerRename,
1176            "file_explorer_toggle_hidden" => FileExplorerToggleHidden,
1177            "file_explorer_toggle_gitignored" => FileExplorerToggleGitignored,
1178            "file_explorer_search_clear" => FileExplorerSearchClear,
1179            "file_explorer_search_backspace" => FileExplorerSearchBackspace,
1180            "file_explorer_copy" => FileExplorerCopy,
1181            "file_explorer_cut" => FileExplorerCut,
1182            "file_explorer_paste" => FileExplorerPaste,
1183            "file_explorer_duplicate" => FileExplorerDuplicate,
1184            "file_explorer_copy_full_path" => FileExplorerCopyFullPath,
1185            "file_explorer_copy_relative_path" => FileExplorerCopyRelativePath,
1186            "file_explorer_extend_selection_up" => FileExplorerExtendSelectionUp,
1187            "file_explorer_extend_selection_down" => FileExplorerExtendSelectionDown,
1188            "file_explorer_toggle_select" => FileExplorerToggleSelect,
1189            "file_explorer_select_all" => FileExplorerSelectAll,
1190
1191            "lsp_completion" => LspCompletion,
1192            "lsp_goto_definition" => LspGotoDefinition,
1193            "lsp_references" => LspReferences,
1194            "lsp_implementation" => LspImplementation,
1195            "lsp_rename" => LspRename,
1196            "lsp_hover" => LspHover,
1197            "lsp_signature_help" => LspSignatureHelp,
1198            "lsp_code_actions" => LspCodeActions,
1199            "lsp_restart" => LspRestart,
1200            "lsp_stop" => LspStop,
1201            "lsp_toggle_for_buffer" => LspToggleForBuffer,
1202            "toggle_inlay_hints" => ToggleInlayHints,
1203            "toggle_mouse_hover" => ToggleMouseHover,
1204
1205            "toggle_line_numbers" => ToggleLineNumbers,
1206            "toggle_line_numbers_current_buffer" => ToggleLineNumbersCurrentBuffer,
1207            "toggle_line_wrap_current_buffer" => ToggleLineWrapCurrentBuffer,
1208            "trigger_wave_animation" => TriggerWaveAnimation,
1209            "toggle_scroll_sync" => ToggleScrollSync,
1210            "toggle_mouse_capture" => ToggleMouseCapture,
1211            "toggle_debug_highlights" => ToggleDebugHighlights,
1212            "set_background" => SetBackground,
1213            "set_background_blend" => SetBackgroundBlend,
1214            "inspect_theme_at_cursor" => InspectThemeAtCursor,
1215            "select_theme" => SelectTheme,
1216            "select_keybinding_map" => SelectKeybindingMap,
1217            "select_cursor_style" => SelectCursorStyle,
1218            "select_locale" => SelectLocale,
1219
1220            "set_tab_size" => SetTabSize,
1221            "set_line_ending" => SetLineEnding,
1222            "set_encoding" => SetEncoding,
1223            "reload_with_encoding" => ReloadWithEncoding,
1224            "set_language" => SetLanguage,
1225            "toggle_indentation_style" => ToggleIndentationStyle,
1226            "toggle_tab_indicators" => ToggleTabIndicators,
1227            "toggle_whitespace_indicators" => ToggleWhitespaceIndicators,
1228            "reset_buffer_settings" => ResetBufferSettings,
1229            "add_ruler" => AddRuler,
1230            "remove_ruler" => RemoveRuler,
1231
1232            "dump_config" => DumpConfig,
1233            "redraw_screen" => RedrawScreen,
1234
1235            "search" => Search,
1236            "find_in_selection" => FindInSelection,
1237            "find_next" => FindNext,
1238            "find_previous" => FindPrevious,
1239            "find_selection_next" => FindSelectionNext,
1240            "find_selection_previous" => FindSelectionPrevious,
1241            "clear_search" => ClearSearch,
1242            "replace" => Replace,
1243            "query_replace" => QueryReplace,
1244
1245            "menu_activate" => MenuActivate,
1246            "menu_close" => MenuClose,
1247            "menu_left" => MenuLeft,
1248            "menu_right" => MenuRight,
1249            "menu_up" => MenuUp,
1250            "menu_down" => MenuDown,
1251            "menu_execute" => MenuExecute,
1252
1253            "open_terminal" => OpenTerminal,
1254            "open_terminal_right" => OpenTerminalRight,
1255            "open_terminal_below" => OpenTerminalBelow,
1256            "close_terminal" => CloseTerminal,
1257            "focus_terminal" => FocusTerminal,
1258            "terminal_escape" => TerminalEscape,
1259            "toggle_keyboard_capture" => ToggleKeyboardCapture,
1260            "terminal_paste" => TerminalPaste,
1261            "send_selection_to_terminal" => SendSelectionToTerminal,
1262
1263            "shell_command" => ShellCommand,
1264            "shell_command_replace" => ShellCommandReplace,
1265
1266            "to_upper_case" => ToUpperCase,
1267            "to_lower_case" => ToLowerCase,
1268            "toggle_case" => ToggleCase,
1269            "sort_lines" => SortLines,
1270
1271            "calibrate_input" => CalibrateInput,
1272            "event_debug" => EventDebug,
1273            "suspend_process" => SuspendProcess,
1274            "load_plugin_from_buffer" => LoadPluginFromBuffer,
1275            "init_reload" => InitReload,
1276            "init_edit" => InitEdit,
1277            "init_check" => InitCheck,
1278            "open_keybinding_editor" => OpenKeybindingEditor,
1279
1280            "composite_next_hunk" => CompositeNextHunk,
1281            "composite_prev_hunk" => CompositePrevHunk,
1282
1283            "workspace_trust_trust" => WorkspaceTrustTrust,
1284            "workspace_trust_restrict" => WorkspaceTrustRestrict,
1285            "workspace_trust_block" => WorkspaceTrustBlock,
1286            "workspace_trust_prompt" => WorkspaceTrustPrompt,
1287
1288            "noop" => None,
1289
1290            "open_settings" => OpenSettings,
1291            "close_settings" => CloseSettings,
1292            "settings_save" => SettingsSave,
1293            "settings_reset" => SettingsReset,
1294            "settings_toggle_focus" => SettingsToggleFocus,
1295            "settings_activate" => SettingsActivate,
1296            "settings_search" => SettingsSearch,
1297            "settings_help" => SettingsHelp,
1298            "settings_increment" => SettingsIncrement,
1299            "settings_decrement" => SettingsDecrement,
1300            "settings_inherit" => SettingsInherit,
1301        }
1302        alias {
1303            "toggle_compose_mode" => TogglePageView,
1304            "set_compose_width" => SetPageWidth,
1305            // Common synonym users reach for when trying to disable a
1306            // default binding (issue #2030). Without this alias,
1307            // `Action::from_str` returns `None` for `"none"` and the
1308            // user's override silently fails to load.
1309            "none" => None,
1310        }
1311        with_char {
1312            "insert_char" => InsertChar,
1313            "set_bookmark" => SetBookmark,
1314            "jump_to_bookmark" => JumpToBookmark,
1315            "clear_bookmark" => ClearBookmark,
1316            "play_macro" => PlayMacro,
1317            "toggle_macro_recording" => ToggleMacroRecording,
1318            "show_macro" => ShowMacro,
1319        }
1320        custom {
1321            "copy_with_theme" => CopyWithTheme : {
1322                // Empty theme = open theme picker prompt
1323                let theme = args.get("theme").and_then(|v| v.as_str()).unwrap_or("");
1324                Self::CopyWithTheme(theme.to_string())
1325            },
1326            "menu_open" => MenuOpen : {
1327                let name = args.get("name")?.as_str()?;
1328                Self::MenuOpen(name.to_string())
1329            },
1330            "switch_keybinding_map" => SwitchKeybindingMap : {
1331                let map_name = args.get("map")?.as_str()?;
1332                Self::SwitchKeybindingMap(map_name.to_string())
1333            },
1334            "prompt_confirm_with_text" => PromptConfirmWithText : {
1335                let text = args.get("text")?.as_str()?;
1336                Self::PromptConfirmWithText(text.to_string())
1337            },
1338        }
1339    }
1340
1341    /// For action names whose string form takes a string-typed arg, return the
1342    /// arg-map key that carries the variant value (e.g. `menu_open` → `"name"`).
1343    /// Returns `None` for actions with no enumerable string variant.
1344    ///
1345    /// Drives the keybinding editor's qualified-name syntax
1346    /// (`menu_open:File` ↔ `{action: "menu_open", args: {name: "File"}}`).
1347    pub fn variant_arg_key(bare_action: &str) -> Option<&'static str> {
1348        match bare_action {
1349            "menu_open" => Some("name"),
1350            "switch_keybinding_map" => Some("map"),
1351            _ => None,
1352        }
1353    }
1354
1355    /// Collapse an `(action, args)` pair into a qualified action string.
1356    /// Parameterised actions with a string variant become `bare:value`
1357    /// (e.g. `menu_open:File`); everything else is returned unchanged.
1358    pub fn qualify_action(bare_action: &str, args: &HashMap<String, serde_json::Value>) -> String {
1359        if let Some(key) = Self::variant_arg_key(bare_action) {
1360            if let Some(v) = args.get(key).and_then(|v| v.as_str()) {
1361                return format!("{}:{}", bare_action, v);
1362            }
1363        }
1364        bare_action.to_string()
1365    }
1366
1367    /// Qualified string for this Action value — the inverse of
1368    /// [`Self::unqualify_action`]. Used when we already hold an `Action`
1369    /// enum (e.g. from a plugin mode's default bindings) and want the same
1370    /// qualified form the editor uses elsewhere.
1371    pub fn to_qualified_action_str(&self) -> String {
1372        match self {
1373            Self::MenuOpen(name) => format!("menu_open:{}", name),
1374            Self::SwitchKeybindingMap(map) => format!("switch_keybinding_map:{}", map),
1375            other => other.to_action_str(),
1376        }
1377    }
1378
1379    /// Render this action as an [`ActionSpec`] — its canonical name plus the
1380    /// payload args needed to reconstruct it via [`Self::from_str`]. This is
1381    /// the inverse of `from_str`/`with_char`/`custom`, and the keystone of the
1382    /// macro<->code bridge: a recorded `Vec<Action>` maps through here into the
1383    /// exact `ActionSpec[]` shape `editor.executeActions` consumes, so a macro
1384    /// and its replay script are one representation.
1385    ///
1386    /// `count` is always 1 — recorded macros carry one Action per step; the
1387    /// repeat-count sugar is a hand-authoring convenience, not something we
1388    /// emit. Payload variants populate `args` with the same keys `from_str`
1389    /// reads (`char`, `text`, `theme`, `name`, `map`); everything else gets an
1390    /// empty map.
1391    pub fn to_action_spec(&self) -> fresh_core::api::ActionSpec {
1392        use serde_json::Value;
1393        let mut args: HashMap<String, Value> = HashMap::new();
1394        match self {
1395            // `with_char` family — all carry a single `char` arg.
1396            Self::InsertChar(c)
1397            | Self::SetBookmark(c)
1398            | Self::JumpToBookmark(c)
1399            | Self::ClearBookmark(c)
1400            | Self::PlayMacro(c)
1401            | Self::ToggleMacroRecording(c)
1402            | Self::ShowMacro(c) => {
1403                args.insert("char".to_string(), Value::String(c.to_string()));
1404            }
1405            // `custom` family — each carries a distinct string-keyed arg.
1406            Self::CopyWithTheme(theme) => {
1407                args.insert("theme".to_string(), Value::String(theme.clone()));
1408            }
1409            Self::MenuOpen(name) => {
1410                args.insert("name".to_string(), Value::String(name.clone()));
1411            }
1412            Self::SwitchKeybindingMap(map) => {
1413                args.insert("map".to_string(), Value::String(map.clone()));
1414            }
1415            Self::PromptConfirmWithText(text) => {
1416                args.insert("text".to_string(), Value::String(text.clone()));
1417            }
1418            // No-arg actions (motions, edits, commands): empty args.
1419            _ => {}
1420        }
1421        fresh_core::api::ActionSpec {
1422            action: self.to_action_str(),
1423            count: 1,
1424            args,
1425        }
1426    }
1427
1428    /// Inverse of [`qualify_action`]: split a qualified action string into the
1429    /// bare action name and the args map it implies. For unqualified strings
1430    /// (or suffix syntax used on an action with no variant arg) returns the
1431    /// input unchanged with empty args.
1432    pub fn unqualify_action(qualified: &str) -> (String, HashMap<String, serde_json::Value>) {
1433        if let Some((bare, suffix)) = qualified.split_once(':') {
1434            if let Some(arg_key) = Self::variant_arg_key(bare) {
1435                let mut args = HashMap::new();
1436                args.insert(
1437                    arg_key.to_string(),
1438                    serde_json::Value::String(suffix.to_string()),
1439                );
1440                return (bare.to_string(), args);
1441            }
1442        }
1443        (qualified.to_string(), HashMap::new())
1444    }
1445
1446    /// Check if this action is a movement or editing action that should be
1447    /// ignored in virtual buffers with hidden cursors.
1448    pub fn is_movement_or_editing(&self) -> bool {
1449        matches!(
1450            self,
1451            // Movement actions
1452            Action::MoveLeft
1453                | Action::MoveRight
1454                | Action::MoveUp
1455                | Action::MoveDown
1456                | Action::MoveWordLeft
1457                | Action::MoveWordRight
1458                | Action::MoveWordEnd
1459                | Action::ViMoveWordEnd
1460                | Action::MoveLeftInLine
1461                | Action::MoveRightInLine
1462                | Action::MoveLineStart
1463                | Action::MoveLineEnd
1464                | Action::MovePageUp
1465                | Action::MovePageDown
1466                | Action::MoveDocumentStart
1467                | Action::MoveDocumentEnd
1468                | Action::MoveToParagraphUp
1469                | Action::MoveToParagraphDown
1470                // Selection actions
1471                | Action::SelectLeft
1472                | Action::SelectRight
1473                | Action::SelectUp
1474                | Action::SelectDown
1475                | Action::SelectToParagraphUp
1476                | Action::SelectToParagraphDown
1477                | Action::SelectWordLeft
1478                | Action::SelectWordRight
1479                | Action::SelectWordEnd
1480                | Action::ViSelectWordEnd
1481                | Action::SelectLineStart
1482                | Action::SelectLineEnd
1483                | Action::SelectDocumentStart
1484                | Action::SelectDocumentEnd
1485                | Action::SelectPageUp
1486                | Action::SelectPageDown
1487                | Action::SelectAll
1488                | Action::SelectWord
1489                | Action::SelectLine
1490                | Action::ExpandSelection
1491                // Block selection
1492                | Action::BlockSelectLeft
1493                | Action::BlockSelectRight
1494                | Action::BlockSelectUp
1495                | Action::BlockSelectDown
1496                // Editing actions
1497                | Action::InsertChar(_)
1498                | Action::InsertNewline
1499                | Action::InsertTab
1500                | Action::DeleteBackward
1501                | Action::DeleteForward
1502                | Action::DeleteWordBackward
1503                | Action::DeleteWordForward
1504                | Action::DeleteLine
1505                | Action::DeleteToLineEnd
1506                | Action::DeleteToLineStart
1507                | Action::TransposeChars
1508                | Action::OpenLine
1509                | Action::DuplicateLine
1510                | Action::MoveLineUp
1511                | Action::MoveLineDown
1512                // Clipboard editing (but not Copy)
1513                | Action::Cut
1514                | Action::Paste
1515                // Undo/Redo
1516                | Action::Undo
1517                | Action::Redo
1518        )
1519    }
1520
1521    /// Check if this action modifies buffer content (for block selection conversion).
1522    /// Block selections should be converted to multi-cursor before these actions.
1523    pub fn is_editing(&self) -> bool {
1524        matches!(
1525            self,
1526            Action::InsertChar(_)
1527                | Action::InsertNewline
1528                | Action::InsertTab
1529                | Action::DeleteBackward
1530                | Action::DeleteForward
1531                | Action::DeleteWordBackward
1532                | Action::DeleteWordForward
1533                | Action::DeleteLine
1534                | Action::DeleteToLineEnd
1535                | Action::DeleteToLineStart
1536                | Action::DeleteViWordEnd
1537                | Action::TransposeChars
1538                | Action::OpenLine
1539                | Action::DuplicateLine
1540                | Action::MoveLineUp
1541                | Action::MoveLineDown
1542                | Action::Cut
1543                | Action::Paste
1544        )
1545    }
1546}
1547
1548/// Result of chord resolution
1549#[derive(Debug, Clone, PartialEq)]
1550pub enum ChordResolution {
1551    /// Complete match: execute the action
1552    Complete(Action),
1553    /// Partial match: continue waiting for more keys in the sequence
1554    Partial,
1555    /// No match: the sequence doesn't match any binding
1556    NoMatch,
1557}
1558
1559/// Resolves key events to actions based on configuration
1560#[derive(Clone)]
1561pub struct KeybindingResolver {
1562    /// Map from context to key bindings (single key bindings)
1563    /// Context-specific bindings have priority over normal bindings
1564    bindings: HashMap<KeyContext, HashMap<(KeyCode, KeyModifiers), Action>>,
1565
1566    /// Default bindings for each context (single key bindings)
1567    default_bindings: HashMap<KeyContext, HashMap<(KeyCode, KeyModifiers), Action>>,
1568
1569    /// Plugin default bindings (third tier, after custom and keymap defaults)
1570    /// Used for mode bindings registered by plugins via defineMode()
1571    plugin_defaults: HashMap<KeyContext, HashMap<(KeyCode, KeyModifiers), Action>>,
1572
1573    /// Chord bindings (multi-key sequences)
1574    /// Maps context -> sequence -> action
1575    chord_bindings: HashMap<KeyContext, HashMap<Vec<(KeyCode, KeyModifiers)>, Action>>,
1576
1577    /// Default chord bindings for each context
1578    default_chord_bindings: HashMap<KeyContext, HashMap<Vec<(KeyCode, KeyModifiers)>, Action>>,
1579
1580    /// Plugin default chord bindings (for mode chord bindings from defineMode)
1581    plugin_chord_defaults: HashMap<KeyContext, HashMap<Vec<(KeyCode, KeyModifiers)>, Action>>,
1582
1583    /// Plugin modes that want unbound keys to fall through to Normal
1584    /// bindings (motion, selection, copy). Populated by `defineMode` when
1585    /// `inheritNormalBindings: true`.
1586    inheriting_modes: std::collections::HashSet<String>,
1587}
1588
1589impl KeybindingResolver {
1590    /// Create a new resolver from configuration
1591    pub fn new(config: &Config) -> Self {
1592        let mut resolver = Self {
1593            bindings: HashMap::new(),
1594            default_bindings: HashMap::new(),
1595            plugin_defaults: HashMap::new(),
1596            chord_bindings: HashMap::new(),
1597            default_chord_bindings: HashMap::new(),
1598            plugin_chord_defaults: HashMap::new(),
1599            inheriting_modes: std::collections::HashSet::new(),
1600        };
1601
1602        // Load bindings from the active keymap (with inheritance resolution) into default_bindings
1603        let map_bindings = config.resolve_keymap(&config.active_keybinding_map);
1604        resolver.load_default_bindings_from_vec(&map_bindings);
1605
1606        // Then, load custom keybindings (these override the default map bindings)
1607        resolver.load_bindings_from_vec(&config.keybindings);
1608
1609        resolver
1610    }
1611
1612    /// Reload all configuration-derived bindings from `config` while
1613    /// preserving plugin-contributed runtime state.
1614    ///
1615    /// The keymap bindings (`default_bindings` / `default_chord_bindings`) and
1616    /// the user's custom overrides (`bindings` / `chord_bindings`) are rebuilt
1617    /// from scratch exactly as [`KeybindingResolver::new`] does. Plugin state —
1618    /// mode bindings registered at runtime via `defineMode` (`plugin_defaults`,
1619    /// `plugin_chord_defaults`) and the set of modes inheriting Normal bindings
1620    /// (`inheriting_modes`) — is not represented in `config`, so it is carried
1621    /// over untouched.
1622    ///
1623    /// Use this instead of replacing the resolver with a fresh
1624    /// [`KeybindingResolver::new`] whenever a config change forces a rebuild
1625    /// (switching the active keybinding map, saving settings, editing
1626    /// keybindings). Otherwise every plugin-contributed binding is dropped until
1627    /// the next restart (issue #2307).
1628    pub fn reload_from_config(&mut self, config: &Config) {
1629        let mut rebuilt = Self::new(config);
1630        // Carry over runtime plugin state, which lives only in the resolver.
1631        rebuilt.plugin_defaults = std::mem::take(&mut self.plugin_defaults);
1632        rebuilt.plugin_chord_defaults = std::mem::take(&mut self.plugin_chord_defaults);
1633        rebuilt.inheriting_modes = std::mem::take(&mut self.inheriting_modes);
1634        *self = rebuilt;
1635    }
1636
1637    /// Load default bindings from a vector of keybinding definitions (into default_bindings/default_chord_bindings)
1638    fn load_default_bindings_from_vec(&mut self, bindings: &[crate::config::Keybinding]) {
1639        for binding in bindings {
1640            // Determine context from "when" clause
1641            let context = if let Some(ref when) = binding.when {
1642                KeyContext::from_when_clause(when).unwrap_or(KeyContext::Normal)
1643            } else {
1644                KeyContext::Normal
1645            };
1646
1647            if let Some(action) = Action::from_str(&binding.action, &binding.args) {
1648                // Check if this is a chord binding (has keys field)
1649                if !binding.keys.is_empty() {
1650                    // Parse the chord sequence
1651                    let mut sequence = Vec::new();
1652                    for key_press in &binding.keys {
1653                        if let Some(key_code) = Self::parse_key(&key_press.key) {
1654                            let modifiers = Self::parse_modifiers(&key_press.modifiers);
1655                            sequence.push((key_code, modifiers));
1656                        } else {
1657                            // Invalid key in sequence, skip this binding
1658                            break;
1659                        }
1660                    }
1661
1662                    // Only add if all keys in sequence were valid
1663                    if sequence.len() == binding.keys.len() && !sequence.is_empty() {
1664                        self.default_chord_bindings
1665                            .entry(context)
1666                            .or_default()
1667                            .insert(sequence, action);
1668                    }
1669                } else if let Some(key_code) = Self::parse_key(&binding.key) {
1670                    // Single key binding (legacy format)
1671                    let modifiers = Self::parse_modifiers(&binding.modifiers);
1672
1673                    // Insert the primary binding
1674                    self.insert_binding_with_equivalents(
1675                        context,
1676                        key_code,
1677                        modifiers,
1678                        action,
1679                        &binding.key,
1680                    );
1681                }
1682            }
1683        }
1684    }
1685
1686    /// Insert a binding and automatically add terminal key equivalents.
1687    /// Logs a warning if an equivalent key is already bound to a different action.
1688    fn insert_binding_with_equivalents(
1689        &mut self,
1690        context: KeyContext,
1691        key_code: KeyCode,
1692        modifiers: KeyModifiers,
1693        action: Action,
1694        key_name: &str,
1695    ) {
1696        let context_bindings = self.default_bindings.entry(context.clone()).or_default();
1697
1698        // Insert the primary binding
1699        context_bindings.insert((key_code, modifiers), action.clone());
1700
1701        // Get terminal key equivalents and add them as aliases
1702        let equivalents = terminal_key_equivalents(key_code, modifiers);
1703        for (equiv_key, equiv_mods) in equivalents {
1704            // Check if this equivalent is already bound
1705            if let Some(existing_action) = context_bindings.get(&(equiv_key, equiv_mods)) {
1706                // Only warn if bound to a DIFFERENT action
1707                if existing_action != &action {
1708                    let equiv_name = format!("{:?}", equiv_key);
1709                    tracing::warn!(
1710                        "Terminal key equivalent conflict in {:?} context: {} (equivalent of {}) \
1711                         is bound to {:?}, but {} is bound to {:?}. \
1712                         The explicit binding takes precedence.",
1713                        context,
1714                        equiv_name,
1715                        key_name,
1716                        existing_action,
1717                        key_name,
1718                        action
1719                    );
1720                }
1721                // Don't override explicit bindings with auto-generated equivalents
1722            } else {
1723                // Add the equivalent binding
1724                context_bindings.insert((equiv_key, equiv_mods), action.clone());
1725            }
1726        }
1727    }
1728
1729    /// Load custom bindings from a vector of keybinding definitions (into bindings/chord_bindings)
1730    fn load_bindings_from_vec(&mut self, bindings: &[crate::config::Keybinding]) {
1731        for binding in bindings {
1732            // Determine context from "when" clause
1733            let context = if let Some(ref when) = binding.when {
1734                KeyContext::from_when_clause(when).unwrap_or(KeyContext::Normal)
1735            } else {
1736                KeyContext::Normal
1737            };
1738
1739            if let Some(action) = Action::from_str(&binding.action, &binding.args) {
1740                // Check if this is a chord binding (has keys field)
1741                if !binding.keys.is_empty() {
1742                    // Parse the chord sequence
1743                    let mut sequence = Vec::new();
1744                    for key_press in &binding.keys {
1745                        if let Some(key_code) = Self::parse_key(&key_press.key) {
1746                            let modifiers = Self::parse_modifiers(&key_press.modifiers);
1747                            sequence.push((key_code, modifiers));
1748                        } else {
1749                            // Invalid key in sequence, skip this binding
1750                            break;
1751                        }
1752                    }
1753
1754                    // Only add if all keys in sequence were valid
1755                    if sequence.len() == binding.keys.len() && !sequence.is_empty() {
1756                        self.chord_bindings
1757                            .entry(context)
1758                            .or_default()
1759                            .insert(sequence, action);
1760                    }
1761                } else if let Some(key_code) = Self::parse_key(&binding.key) {
1762                    // Single key binding (legacy format)
1763                    let modifiers = Self::parse_modifiers(&binding.modifiers);
1764                    self.bindings
1765                        .entry(context)
1766                        .or_default()
1767                        .insert((key_code, modifiers), action);
1768                }
1769            }
1770        }
1771    }
1772
1773    /// Load a plugin default binding (for mode bindings registered via defineMode)
1774    pub fn load_plugin_default(
1775        &mut self,
1776        context: KeyContext,
1777        key_code: KeyCode,
1778        modifiers: KeyModifiers,
1779        action: Action,
1780    ) {
1781        self.plugin_defaults
1782            .entry(context)
1783            .or_default()
1784            .insert((key_code, modifiers), action);
1785    }
1786
1787    /// Load a plugin default chord binding (for mode chord bindings from defineMode)
1788    pub fn load_plugin_chord_default(
1789        &mut self,
1790        context: KeyContext,
1791        sequence: Vec<(KeyCode, KeyModifiers)>,
1792        action: Action,
1793    ) {
1794        self.plugin_chord_defaults
1795            .entry(context)
1796            .or_default()
1797            .insert(sequence, action);
1798    }
1799
1800    /// Clear all plugin default bindings (single-key and chord) for a specific mode
1801    pub fn clear_plugin_defaults_for_mode(&mut self, mode_name: &str) {
1802        let context = KeyContext::Mode(mode_name.to_string());
1803        self.plugin_defaults.remove(&context);
1804        self.plugin_chord_defaults.remove(&context);
1805        self.inheriting_modes.remove(mode_name);
1806    }
1807
1808    /// Mark (or unmark) a plugin mode as inheriting Normal-context bindings
1809    /// for keys it doesn't bind itself.
1810    pub fn set_mode_inherits_normal_bindings(&mut self, mode_name: &str, inherit: bool) {
1811        if inherit {
1812            self.inheriting_modes.insert(mode_name.to_string());
1813        } else {
1814            self.inheriting_modes.remove(mode_name);
1815        }
1816    }
1817
1818    /// Get all plugin default bindings (for keybinding editor display)
1819    pub fn get_plugin_defaults(
1820        &self,
1821    ) -> &HashMap<KeyContext, HashMap<(KeyCode, KeyModifiers), Action>> {
1822        &self.plugin_defaults
1823    }
1824
1825    /// Check if an action is application-wide (should be accessible in all contexts)
1826    fn is_application_wide_action(action: &Action) -> bool {
1827        matches!(
1828            action,
1829            Action::Quit
1830                | Action::ForceQuit
1831                | Action::Save
1832                | Action::SaveAs
1833                | Action::ShowHelp
1834                | Action::ShowKeyboardShortcuts
1835                | Action::PromptCancel  // Esc should always cancel
1836                | Action::PopupCancel // Esc should always cancel
1837        )
1838    }
1839
1840    /// Check if an action is a UI action that should work in terminal mode
1841    /// (without keyboard capture). These are general navigation and UI actions
1842    /// that don't involve text editing.
1843    pub fn is_terminal_ui_action(action: &Action) -> bool {
1844        matches!(
1845            action,
1846            // Global UI actions
1847            Action::CommandPalette
1848                | Action::QuickOpen
1849                | Action::QuickOpenBuffers
1850                | Action::QuickOpenFiles
1851                | Action::OpenLiveGrep
1852                | Action::ResumeLiveGrep
1853                | Action::ToggleUtilityDock
1854                | Action::OpenTerminalInDock
1855                | Action::ToggleDockFocus
1856                | Action::CycleLiveGrepProvider
1857                | Action::OpenSettings
1858                | Action::MenuActivate
1859                | Action::MenuOpen(_)
1860                | Action::ShowHelp
1861                | Action::ShowKeyboardShortcuts
1862                | Action::Quit
1863                | Action::ForceQuit
1864                // Split navigation
1865                | Action::NextSplit
1866                | Action::PrevSplit
1867                // Window navigation
1868                | Action::NextWindow
1869                | Action::PrevWindow
1870                | Action::SplitHorizontal
1871                | Action::SplitVertical
1872                | Action::CloseSplit
1873                | Action::ToggleMaximizeSplit
1874                // Tab/buffer navigation
1875                | Action::NextBuffer
1876                | Action::PrevBuffer
1877                | Action::Close
1878                | Action::CloseTab
1879                | Action::ScrollTabsLeft
1880                | Action::ScrollTabsRight
1881                // Terminal control
1882                | Action::TerminalEscape
1883                | Action::ToggleKeyboardCapture
1884                | Action::OpenTerminal
1885                | Action::OpenTerminalRight
1886                | Action::OpenTerminalBelow
1887                | Action::CloseTerminal
1888                | Action::TerminalPaste
1889                // File explorer
1890                | Action::ToggleFileExplorer
1891                | Action::ToggleFileExplorerSide
1892                // Menu bar
1893                | Action::ToggleMenuBar
1894        )
1895    }
1896
1897    /// Resolve a key event with chord state to check for multi-key sequences
1898    /// Returns:
1899    /// - Complete(action): The sequence is complete, execute the action
1900    /// - Partial: The sequence is partial (prefix of a chord), wait for more keys
1901    /// - NoMatch: The sequence doesn't match any chord binding
1902    pub fn resolve_chord(
1903        &self,
1904        chord_state: &[(KeyCode, KeyModifiers)],
1905        event: &KeyEvent,
1906        context: KeyContext,
1907    ) -> ChordResolution {
1908        // Build the full sequence: existing chord state + new key, all normalized
1909        let mut full_sequence: Vec<(KeyCode, KeyModifiers)> = chord_state
1910            .iter()
1911            .map(|(c, m)| normalize_key(*c, *m))
1912            .collect();
1913        let (norm_code, norm_mods) = normalize_key(event.code, event.modifiers);
1914        full_sequence.push((norm_code, norm_mods));
1915
1916        tracing::trace!(
1917            "KeybindingResolver.resolve_chord: sequence={:?}, context={:?}",
1918            full_sequence,
1919            context
1920        );
1921
1922        // Check all chord binding sources in priority order
1923        let search_order = vec![
1924            (&self.chord_bindings, &KeyContext::Global, "custom global"),
1925            (
1926                &self.default_chord_bindings,
1927                &KeyContext::Global,
1928                "default global",
1929            ),
1930            (&self.chord_bindings, &context, "custom context"),
1931            (&self.default_chord_bindings, &context, "default context"),
1932            (
1933                &self.plugin_chord_defaults,
1934                &context,
1935                "plugin default context",
1936            ),
1937        ];
1938
1939        let mut has_partial_match = false;
1940
1941        for (binding_map, bind_context, label) in search_order {
1942            if let Some(context_chords) = binding_map.get(bind_context) {
1943                // Check for exact match
1944                if let Some(action) = context_chords.get(&full_sequence) {
1945                    tracing::trace!("  -> Complete chord match in {}: {:?}", label, action);
1946                    return ChordResolution::Complete(action.clone());
1947                }
1948
1949                // Check for partial match (our sequence is a prefix of any binding)
1950                for (chord_seq, _) in context_chords.iter() {
1951                    if chord_seq.len() > full_sequence.len()
1952                        && chord_seq[..full_sequence.len()] == full_sequence[..]
1953                    {
1954                        tracing::trace!("  -> Partial chord match in {}", label);
1955                        has_partial_match = true;
1956                        break;
1957                    }
1958                }
1959            }
1960        }
1961
1962        if has_partial_match {
1963            ChordResolution::Partial
1964        } else {
1965            tracing::trace!("  -> No chord match");
1966            ChordResolution::NoMatch
1967        }
1968    }
1969
1970    /// Resolve a key event to an action in the given context
1971    pub fn resolve(&self, event: &KeyEvent, context: KeyContext) -> Action {
1972        // Normalize key for lookups (e.g., BackTab+SHIFT → BackTab, Char('T')+SHIFT → Char('t')+SHIFT)
1973        // but keep original event for the InsertChar fallback at the end.
1974        let (norm_code, norm_mods) = normalize_key(event.code, event.modifiers);
1975        let norm = &(norm_code, norm_mods);
1976        tracing::trace!(
1977            "KeybindingResolver.resolve: code={:?}, modifiers={:?}, context={:?}",
1978            event.code,
1979            event.modifiers,
1980            context
1981        );
1982
1983        // Check Global bindings first (highest priority - work in all contexts)
1984        if let Some(global_bindings) = self.bindings.get(&KeyContext::Global) {
1985            if let Some(action) = global_bindings.get(norm) {
1986                tracing::trace!("  -> Found in custom global bindings: {:?}", action);
1987                return action.clone();
1988            }
1989        }
1990
1991        if let Some(global_bindings) = self.default_bindings.get(&KeyContext::Global) {
1992            if let Some(action) = global_bindings.get(norm) {
1993                tracing::trace!("  -> Found in default global bindings: {:?}", action);
1994                return action.clone();
1995            }
1996        }
1997
1998        // Try context-specific custom bindings
1999        if let Some(context_bindings) = self.bindings.get(&context) {
2000            if let Some(action) = context_bindings.get(norm) {
2001                tracing::trace!(
2002                    "  -> Found in custom {} bindings: {:?}",
2003                    context.to_when_clause(),
2004                    action
2005                );
2006                return action.clone();
2007            }
2008        }
2009
2010        // Try context-specific default bindings
2011        if let Some(context_bindings) = self.default_bindings.get(&context) {
2012            if let Some(action) = context_bindings.get(norm) {
2013                tracing::trace!(
2014                    "  -> Found in default {} bindings: {:?}",
2015                    context.to_when_clause(),
2016                    action
2017                );
2018                return action.clone();
2019            }
2020        }
2021
2022        // Try plugin default bindings (mode bindings from defineMode)
2023        if let Some(plugin_bindings) = self.plugin_defaults.get(&context) {
2024            if let Some(action) = plugin_bindings.get(norm) {
2025                tracing::trace!(
2026                    "  -> Found in plugin default {} bindings: {:?}",
2027                    context.to_when_clause(),
2028                    action
2029                );
2030                return action.clone();
2031            }
2032        }
2033
2034        // Fall through to the parent context's bindings (e.g. SearchPrompt →
2035        // Prompt) so a narrowed context inherits all of its parent's keys and
2036        // only owns/overrides the few it declares. Checked after this context's
2037        // own bindings but before the Normal fallthrough below, so the parent's
2038        // editing/navigation keys outrank Normal.
2039        if let Some(parent) = context.parent_context() {
2040            if let Some(parent_bindings) = self.bindings.get(&parent) {
2041                if let Some(action) = parent_bindings.get(norm) {
2042                    return action.clone();
2043                }
2044            }
2045            if let Some(parent_bindings) = self.default_bindings.get(&parent) {
2046                if let Some(action) = parent_bindings.get(norm) {
2047                    return action.clone();
2048                }
2049            }
2050        }
2051
2052        // Fall back to Normal context bindings.
2053        // Contexts with allows_normal_fallthrough (e.g. CompositeBuffer) get ALL
2054        // Normal bindings; other contexts only get application-wide actions.
2055        if context != KeyContext::Normal {
2056            let full_fallthrough = context.allows_normal_fallthrough()
2057                || matches!(&context, KeyContext::Mode(name) if self.inheriting_modes.contains(name));
2058
2059            let ui_fallthrough = context.allows_ui_fallthrough();
2060
2061            // A user binding in Normal context shadows the default
2062            // Normal binding for the same key — even if the user's
2063            // action doesn't qualify for fallthrough (e.g. `noop` to
2064            // disable a default). Without this, the resolver fell
2065            // through to the default's application-wide entry,
2066            // making it impossible to disable application-wide
2067            // bindings like `Ctrl+Q → Quit` from a user config
2068            // (issue #2030).
2069            let custom_normal_has_binding = self
2070                .bindings
2071                .get(&KeyContext::Normal)
2072                .and_then(|m| m.get(norm))
2073                .is_some();
2074
2075            if let Some(normal_bindings) = self.bindings.get(&KeyContext::Normal) {
2076                if let Some(action) = normal_bindings.get(norm) {
2077                    if full_fallthrough
2078                        || Self::is_application_wide_action(action)
2079                        || (ui_fallthrough && Self::is_terminal_ui_action(action))
2080                    {
2081                        tracing::trace!(
2082                            "  -> Found action in custom normal bindings (fallthrough): {:?}",
2083                            action
2084                        );
2085                        return action.clone();
2086                    }
2087                }
2088            }
2089
2090            if !custom_normal_has_binding {
2091                if let Some(normal_bindings) = self.default_bindings.get(&KeyContext::Normal) {
2092                    if let Some(action) = normal_bindings.get(norm) {
2093                        if full_fallthrough
2094                            || Self::is_application_wide_action(action)
2095                            || (ui_fallthrough && Self::is_terminal_ui_action(action))
2096                        {
2097                            tracing::trace!(
2098                                "  -> Found action in default normal bindings (fallthrough): {:?}",
2099                                action
2100                            );
2101                            return action.clone();
2102                        }
2103                    }
2104                }
2105            }
2106        }
2107
2108        // Handle regular character input in text input contexts
2109        if context.allows_text_input() && is_text_input_modifier(event.modifiers) {
2110            if let KeyCode::Char(c) = event.code {
2111                tracing::trace!("  -> Character input: '{}'", c);
2112                return Action::InsertChar(c);
2113            }
2114        }
2115
2116        tracing::trace!("  -> No binding found, returning Action::None");
2117        Action::None
2118    }
2119
2120    /// Resolve a key event looking only in the specified context (no Global fallback).
2121    /// This is used when a modal context (like Prompt) needs to check if it has
2122    /// a specific binding without being overridden by Global bindings.
2123    /// Returns None if no binding found in the specified context.
2124    pub fn resolve_in_context_only(&self, event: &KeyEvent, context: KeyContext) -> Option<Action> {
2125        let norm = normalize_key(event.code, event.modifiers);
2126        // Try custom bindings for this context
2127        if let Some(context_bindings) = self.bindings.get(&context) {
2128            if let Some(action) = context_bindings.get(&norm) {
2129                return Some(action.clone());
2130            }
2131        }
2132
2133        // Try default bindings for this context
2134        if let Some(context_bindings) = self.default_bindings.get(&context) {
2135            if let Some(action) = context_bindings.get(&norm) {
2136                return Some(action.clone());
2137            }
2138        }
2139
2140        None
2141    }
2142
2143    /// `true` iff this context has its own binding for `event` —
2144    /// either user-customised, built-in default, or plugin-default
2145    /// (from `defineMode`). Unlike [`resolve`], this does **not**
2146    /// fall back to Global or Normal-context bindings; it's the
2147    /// "did someone *explicitly* claim this key for this mode"
2148    /// check used by `dispatch_floating_widget_key` to decide
2149    /// whether to let mode dispatch override its smart-key defaults.
2150    pub fn has_explicit_binding(&self, event: &KeyEvent, context: &KeyContext) -> bool {
2151        let norm = normalize_key(event.code, event.modifiers);
2152        if let Some(bindings) = self.bindings.get(context) {
2153            if bindings.contains_key(&norm) {
2154                return true;
2155            }
2156        }
2157        if let Some(bindings) = self.default_bindings.get(context) {
2158            if bindings.contains_key(&norm) {
2159                return true;
2160            }
2161        }
2162        if let Some(bindings) = self.plugin_defaults.get(context) {
2163            if bindings.contains_key(&norm) {
2164                return true;
2165            }
2166        }
2167        false
2168    }
2169
2170    /// Resolve a key event to a UI action for terminal mode.
2171    /// Only returns actions that are classified as UI actions (is_terminal_ui_action).
2172    /// Returns Action::None if the key doesn't map to a UI action.
2173    pub fn resolve_terminal_ui_action(&self, event: &KeyEvent) -> Action {
2174        let norm = normalize_key(event.code, event.modifiers);
2175        tracing::trace!(
2176            "KeybindingResolver.resolve_terminal_ui_action: code={:?}, modifiers={:?}",
2177            event.code,
2178            event.modifiers
2179        );
2180
2181        // Check Terminal context bindings first (highest priority for terminal mode)
2182        for bindings in [&self.bindings, &self.default_bindings] {
2183            if let Some(terminal_bindings) = bindings.get(&KeyContext::Terminal) {
2184                if let Some(action) = terminal_bindings.get(&norm) {
2185                    if Self::is_terminal_ui_action(action) {
2186                        tracing::trace!("  -> Found UI action in terminal bindings: {:?}", action);
2187                        return action.clone();
2188                    }
2189                }
2190            }
2191        }
2192
2193        // Check Global bindings (work in all contexts)
2194        for bindings in [&self.bindings, &self.default_bindings] {
2195            if let Some(global_bindings) = bindings.get(&KeyContext::Global) {
2196                if let Some(action) = global_bindings.get(&norm) {
2197                    if Self::is_terminal_ui_action(action) {
2198                        tracing::trace!("  -> Found UI action in global bindings: {:?}", action);
2199                        return action.clone();
2200                    }
2201                }
2202            }
2203        }
2204
2205        // Check Normal context bindings (for actions like next_split that are in Normal context)
2206        for bindings in [&self.bindings, &self.default_bindings] {
2207            if let Some(normal_bindings) = bindings.get(&KeyContext::Normal) {
2208                if let Some(action) = normal_bindings.get(&norm) {
2209                    if Self::is_terminal_ui_action(action) {
2210                        tracing::trace!("  -> Found UI action in normal bindings: {:?}", action);
2211                        return action.clone();
2212                    }
2213                }
2214            }
2215        }
2216
2217        tracing::trace!("  -> No UI action found");
2218        Action::None
2219    }
2220
2221    /// Find the primary keybinding for a given action (for display in menus)
2222    /// Returns a formatted string like "Ctrl+S" or "F12"
2223    /// Names of every `PluginAction` bound in any context (custom or
2224    /// default). Used to populate `getKeybindingLabel` for plugin actions,
2225    /// which aren't in `Action::all_action_names()`.
2226    pub fn bound_plugin_action_names(&self) -> std::collections::HashSet<String> {
2227        let mut names = std::collections::HashSet::new();
2228        for map in self.bindings.values().chain(self.default_bindings.values()) {
2229            for action in map.values() {
2230                if let Action::PluginAction(name) = action {
2231                    names.insert(name.clone());
2232                }
2233            }
2234        }
2235        names
2236    }
2237
2238    pub fn find_keybinding_for_action(
2239        &self,
2240        action_name: &str,
2241        context: KeyContext,
2242    ) -> Option<String> {
2243        // Parse the action from the action name
2244        let target_action = Action::from_str(action_name, &HashMap::new())?;
2245
2246        // Search in custom bindings first, then default bindings
2247        let search_maps = vec![
2248            self.bindings.get(&context),
2249            self.bindings.get(&KeyContext::Global),
2250            self.default_bindings.get(&context),
2251            self.default_bindings.get(&KeyContext::Global),
2252        ];
2253
2254        for map in search_maps.into_iter().flatten() {
2255            // Collect all matching keybindings for deterministic selection
2256            let mut matches: Vec<(KeyCode, KeyModifiers)> = map
2257                .iter()
2258                .filter(|(_, action)| {
2259                    // PluginAction carries the action name in its payload, so
2260                    // every plugin action shares one enum discriminant —
2261                    // match on the inner string instead, or all plugin
2262                    // actions would collide onto the first binding.
2263                    match (*action, &target_action) {
2264                        (Action::PluginAction(a), Action::PluginAction(b)) => a == b,
2265                        (a, b) => std::mem::discriminant(a) == std::mem::discriminant(b),
2266                    }
2267                })
2268                .map(|((key_code, modifiers), _)| (*key_code, *modifiers))
2269                .collect();
2270
2271            if !matches.is_empty() {
2272                // Sort to get deterministic order: prefer fewer modifiers, then by key
2273                matches.sort_by(|(key_a, mod_a), (key_b, mod_b)| {
2274                    // Compare by number of modifiers first (prefer simpler bindings)
2275                    let mod_count_a = mod_a.bits().count_ones();
2276                    let mod_count_b = mod_b.bits().count_ones();
2277                    match mod_count_a.cmp(&mod_count_b) {
2278                        std::cmp::Ordering::Equal => {
2279                            // Then by modifier bits (for consistent ordering)
2280                            match mod_a.bits().cmp(&mod_b.bits()) {
2281                                std::cmp::Ordering::Equal => {
2282                                    // Finally by key code
2283                                    Self::key_code_sort_key(key_a)
2284                                        .cmp(&Self::key_code_sort_key(key_b))
2285                                }
2286                                other => other,
2287                            }
2288                        }
2289                        other => other,
2290                    }
2291                });
2292
2293                let (key_code, modifiers) = matches[0];
2294                return Some(format_keybinding(&key_code, &modifiers));
2295            }
2296        }
2297
2298        None
2299    }
2300
2301    /// Generate a sort key for KeyCode to ensure deterministic ordering
2302    fn key_code_sort_key(key_code: &KeyCode) -> (u8, u32) {
2303        match key_code {
2304            KeyCode::Char(c) => (0, *c as u32),
2305            KeyCode::F(n) => (1, *n as u32),
2306            KeyCode::Enter => (2, 0),
2307            KeyCode::Tab => (2, 1),
2308            KeyCode::Backspace => (2, 2),
2309            KeyCode::Delete => (2, 3),
2310            KeyCode::Esc => (2, 4),
2311            KeyCode::Left => (3, 0),
2312            KeyCode::Right => (3, 1),
2313            KeyCode::Up => (3, 2),
2314            KeyCode::Down => (3, 3),
2315            KeyCode::Home => (3, 4),
2316            KeyCode::End => (3, 5),
2317            KeyCode::PageUp => (3, 6),
2318            KeyCode::PageDown => (3, 7),
2319            _ => (255, 0),
2320        }
2321    }
2322
2323    /// Find the mnemonic character for a menu (based on Alt+letter keybindings)
2324    /// Returns the character that should be underlined in the menu label
2325    pub fn find_menu_mnemonic(&self, menu_name: &str) -> Option<char> {
2326        // Search in custom bindings first, then default bindings
2327        let search_maps = vec![
2328            self.bindings.get(&KeyContext::Normal),
2329            self.bindings.get(&KeyContext::Global),
2330            self.default_bindings.get(&KeyContext::Normal),
2331            self.default_bindings.get(&KeyContext::Global),
2332        ];
2333
2334        for map in search_maps.into_iter().flatten() {
2335            for ((key_code, modifiers), action) in map {
2336                // Check if this is an Alt+letter binding for MenuOpen with matching name
2337                if let Action::MenuOpen(name) = action {
2338                    if name.eq_ignore_ascii_case(menu_name) && *modifiers == KeyModifiers::ALT {
2339                        // Return the character for Alt+letter bindings
2340                        if let KeyCode::Char(c) = key_code {
2341                            return Some(c.to_ascii_lowercase());
2342                        }
2343                    }
2344                }
2345            }
2346        }
2347
2348        None
2349    }
2350
2351    /// Parse a key string to KeyCode
2352    fn parse_key(key: &str) -> Option<KeyCode> {
2353        let lower = key.to_lowercase();
2354        match lower.as_str() {
2355            "enter" => Some(KeyCode::Enter),
2356            "backspace" => Some(KeyCode::Backspace),
2357            "delete" | "del" => Some(KeyCode::Delete),
2358            "tab" => Some(KeyCode::Tab),
2359            "backtab" => Some(KeyCode::BackTab),
2360            "esc" | "escape" => Some(KeyCode::Esc),
2361            "space" => Some(KeyCode::Char(' ')),
2362
2363            "left" => Some(KeyCode::Left),
2364            "right" => Some(KeyCode::Right),
2365            "up" => Some(KeyCode::Up),
2366            "down" => Some(KeyCode::Down),
2367            "home" => Some(KeyCode::Home),
2368            "end" => Some(KeyCode::End),
2369            "pageup" => Some(KeyCode::PageUp),
2370            "pagedown" => Some(KeyCode::PageDown),
2371
2372            s if s.len() == 1 => s.chars().next().map(KeyCode::Char),
2373            // Handle function keys like "f1", "f2", ..., "f12"
2374            s if s.starts_with('f') && s.len() >= 2 => s[1..].parse::<u8>().ok().map(KeyCode::F),
2375            _ => None,
2376        }
2377    }
2378
2379    /// Parse modifiers from strings
2380    fn parse_modifiers(modifiers: &[String]) -> KeyModifiers {
2381        let mut result = KeyModifiers::empty();
2382        for m in modifiers {
2383            match m.to_lowercase().as_str() {
2384                "ctrl" | "control" => result |= KeyModifiers::CONTROL,
2385                "shift" => result |= KeyModifiers::SHIFT,
2386                "alt" => result |= KeyModifiers::ALT,
2387                "super" | "cmd" | "command" | "meta" => result |= KeyModifiers::SUPER,
2388                _ => {}
2389            }
2390        }
2391        result
2392    }
2393
2394    /// Create default keybindings organized by context
2395    /// Get all keybindings (for help display)
2396    /// Returns a Vec of (key_description, action_description)
2397    pub fn get_all_bindings(&self) -> Vec<(String, String)> {
2398        let mut bindings = Vec::new();
2399
2400        // Collect all bindings from all contexts
2401        for context in &[
2402            KeyContext::Normal,
2403            KeyContext::Prompt,
2404            KeyContext::Popup,
2405            KeyContext::FileExplorer,
2406            KeyContext::Menu,
2407            KeyContext::CompositeBuffer,
2408        ] {
2409            let mut all_keys: HashMap<(KeyCode, KeyModifiers), Action> = HashMap::new();
2410
2411            // Start with defaults for this context
2412            if let Some(context_defaults) = self.default_bindings.get(context) {
2413                for (key, action) in context_defaults {
2414                    all_keys.insert(*key, action.clone());
2415                }
2416            }
2417
2418            // Override with custom bindings for this context
2419            if let Some(context_bindings) = self.bindings.get(context) {
2420                for (key, action) in context_bindings {
2421                    all_keys.insert(*key, action.clone());
2422                }
2423            }
2424
2425            // Convert to readable format with context prefix
2426            let context_str = if *context != KeyContext::Normal {
2427                format!("[{}] ", context.to_when_clause())
2428            } else {
2429                String::new()
2430            };
2431
2432            for ((key_code, modifiers), action) in all_keys {
2433                let key_str = Self::format_key(key_code, modifiers);
2434                let action_str = format!("{}{}", context_str, Self::format_action(&action));
2435                bindings.push((key_str, action_str));
2436            }
2437        }
2438
2439        // Sort by action description for easier browsing
2440        bindings.sort_by(|a, b| a.1.cmp(&b.1));
2441
2442        bindings
2443    }
2444
2445    /// Format a key combination as a readable string
2446    fn format_key(key_code: KeyCode, modifiers: KeyModifiers) -> String {
2447        format_keybinding(&key_code, &modifiers)
2448    }
2449
2450    /// Format an action as a readable description
2451    pub fn format_action(action: &Action) -> String {
2452        match action {
2453            Action::InsertChar(c) => t!("action.insert_char", char = c),
2454            Action::InsertNewline => t!("action.insert_newline"),
2455            Action::InsertTab => t!("action.insert_tab"),
2456            Action::MoveLeft => t!("action.move_left"),
2457            Action::MoveRight => t!("action.move_right"),
2458            Action::MoveUp => t!("action.move_up"),
2459            Action::MoveDown => t!("action.move_down"),
2460            Action::MoveWordLeft => t!("action.move_word_left"),
2461            Action::MoveWordRight => t!("action.move_word_right"),
2462            Action::MoveWordEnd => t!("action.move_word_end"),
2463            Action::ViMoveWordEnd => t!("action.move_word_end"),
2464            Action::MoveLeftInLine => t!("action.move_left"),
2465            Action::MoveRightInLine => t!("action.move_right"),
2466            Action::MoveLineStart => t!("action.move_line_start"),
2467            Action::MoveLineEnd => t!("action.move_line_end"),
2468            Action::MoveLineUp => t!("action.move_line_up"),
2469            Action::MoveLineDown => t!("action.move_line_down"),
2470            Action::MovePageUp => t!("action.move_page_up"),
2471            Action::MovePageDown => t!("action.move_page_down"),
2472            Action::MoveDocumentStart => t!("action.move_document_start"),
2473            Action::MoveDocumentEnd => t!("action.move_document_end"),
2474            Action::SelectLeft => t!("action.select_left"),
2475            Action::SelectRight => t!("action.select_right"),
2476            Action::SelectUp => t!("action.select_up"),
2477            Action::SelectDown => t!("action.select_down"),
2478            Action::SelectToParagraphUp => t!("action.select_to_paragraph_up"),
2479            Action::SelectToParagraphDown => t!("action.select_to_paragraph_down"),
2480            Action::MoveToParagraphUp => t!("action.move_to_paragraph_up"),
2481            Action::MoveToParagraphDown => t!("action.move_to_paragraph_down"),
2482            Action::SelectWordLeft => t!("action.select_word_left"),
2483            Action::SelectWordRight => t!("action.select_word_right"),
2484            Action::SelectWordEnd => t!("action.select_word_end"),
2485            Action::ViSelectWordEnd => t!("action.select_word_end"),
2486            Action::SelectLineStart => t!("action.select_line_start"),
2487            Action::SelectLineEnd => t!("action.select_line_end"),
2488            Action::SelectDocumentStart => t!("action.select_document_start"),
2489            Action::SelectDocumentEnd => t!("action.select_document_end"),
2490            Action::SelectPageUp => t!("action.select_page_up"),
2491            Action::SelectPageDown => t!("action.select_page_down"),
2492            Action::SelectAll => t!("action.select_all"),
2493            Action::SelectWord => t!("action.select_word"),
2494            Action::SelectLine => t!("action.select_line"),
2495            Action::ExpandSelection => t!("action.expand_selection"),
2496            Action::BlockSelectLeft => t!("action.block_select_left"),
2497            Action::BlockSelectRight => t!("action.block_select_right"),
2498            Action::BlockSelectUp => t!("action.block_select_up"),
2499            Action::BlockSelectDown => t!("action.block_select_down"),
2500            Action::DeleteBackward => t!("action.delete_backward"),
2501            Action::DeleteForward => t!("action.delete_forward"),
2502            Action::DeleteWordBackward => t!("action.delete_word_backward"),
2503            Action::DeleteWordForward => t!("action.delete_word_forward"),
2504            Action::DeleteLine => t!("action.delete_line"),
2505            Action::DeleteToLineEnd => t!("action.delete_to_line_end"),
2506            Action::DeleteToLineStart => t!("action.delete_to_line_start"),
2507            Action::DeleteViWordEnd => t!("action.delete_word_forward"),
2508            Action::TransposeChars => t!("action.transpose_chars"),
2509            Action::OpenLine => t!("action.open_line"),
2510            Action::DuplicateLine => t!("action.duplicate_line"),
2511            Action::Recenter => t!("action.recenter"),
2512            Action::SetMark => t!("action.set_mark"),
2513            Action::CancelMark => t!("action.cancel_mark"),
2514            Action::ClearMark => t!("action.clear_mark"),
2515            Action::Copy => t!("action.copy"),
2516            Action::CopyWithTheme(theme) if theme.is_empty() => t!("action.copy_with_formatting"),
2517            Action::CopyWithTheme(theme) => t!("action.copy_with_theme", theme = theme),
2518            Action::Cut => t!("action.cut"),
2519            Action::Paste => t!("action.paste"),
2520            Action::CopyFilePath => t!("action.copy_file_path"),
2521            Action::CopyRelativeFilePath => t!("action.copy_relative_file_path"),
2522            Action::YankWordForward => t!("action.yank_word_forward"),
2523            Action::YankWordBackward => t!("action.yank_word_backward"),
2524            Action::YankToLineEnd => t!("action.yank_to_line_end"),
2525            Action::YankToLineStart => t!("action.yank_to_line_start"),
2526            Action::YankViWordEnd => t!("action.yank_word_forward"),
2527            Action::AddCursorAbove => t!("action.add_cursor_above"),
2528            Action::AddCursorBelow => t!("action.add_cursor_below"),
2529            Action::AddCursorNextMatch => t!("action.add_cursor_next_match"),
2530            Action::AddCursorsToLineEnds => t!("action.add_cursors_to_line_ends"),
2531            Action::RemoveSecondaryCursors => t!("action.remove_secondary_cursors"),
2532            Action::Save => t!("action.save"),
2533            Action::SaveAs => t!("action.save_as"),
2534            Action::Open => t!("action.open"),
2535            Action::SwitchProject => t!("action.switch_project"),
2536            Action::New => t!("action.new"),
2537            Action::Close => t!("action.close"),
2538            Action::CloseTab => t!("action.close_tab"),
2539            Action::Quit => t!("action.quit"),
2540            Action::ForceQuit => t!("action.force_quit"),
2541            Action::Detach => t!("action.detach"),
2542            Action::Revert => t!("action.revert"),
2543            Action::ToggleAutoRevert => t!("action.toggle_auto_revert"),
2544            Action::FormatBuffer => t!("action.format_buffer"),
2545            Action::TrimTrailingWhitespace => t!("action.trim_trailing_whitespace"),
2546            Action::EnsureFinalNewline => t!("action.ensure_final_newline"),
2547            Action::GotoLine => t!("action.goto_line"),
2548            Action::ScanLineIndex => t!("action.scan_line_index"),
2549            Action::GoToMatchingBracket => t!("action.goto_matching_bracket"),
2550            Action::JumpToNextError => t!("action.jump_to_next_error"),
2551            Action::JumpToPreviousError => t!("action.jump_to_previous_error"),
2552            Action::SmartHome => t!("action.smart_home"),
2553            Action::DedentSelection => t!("action.dedent_selection"),
2554            Action::ToggleComment => t!("action.toggle_comment"),
2555            Action::DabbrevExpand => std::borrow::Cow::Borrowed("Expand abbreviation (dabbrev)"),
2556            Action::ToggleFold => t!("action.toggle_fold"),
2557            Action::SetBookmark(c) => t!("action.set_bookmark", key = c),
2558            Action::JumpToBookmark(c) => t!("action.jump_to_bookmark", key = c),
2559            Action::ClearBookmark(c) => t!("action.clear_bookmark", key = c),
2560            Action::ListBookmarks => t!("action.list_bookmarks"),
2561            Action::ToggleSearchCaseSensitive => t!("action.toggle_search_case_sensitive"),
2562            Action::ToggleSearchWholeWord => t!("action.toggle_search_whole_word"),
2563            Action::ToggleSearchRegex => t!("action.toggle_search_regex"),
2564            Action::ToggleSearchConfirmEach => t!("action.toggle_search_confirm_each"),
2565            Action::StartMacroRecording => t!("action.start_macro_recording"),
2566            Action::StopMacroRecording => t!("action.stop_macro_recording"),
2567            Action::PlayMacro(c) => t!("action.play_macro", key = c),
2568            Action::ToggleMacroRecording(c) => t!("action.toggle_macro_recording", key = c),
2569            Action::ShowMacro(c) => t!("action.show_macro", key = c),
2570            Action::ListMacros => t!("action.list_macros"),
2571            Action::PromptRecordMacro => t!("action.prompt_record_macro"),
2572            Action::PromptPlayMacro => t!("action.prompt_play_macro"),
2573            Action::PlayLastMacro => t!("action.play_last_macro"),
2574            Action::PromptSaveMacroToInit => t!("action.prompt_save_macro_to_init"),
2575            Action::PromptPromoteMacro => t!("action.prompt_promote_macro"),
2576            Action::PromptSetBookmark => t!("action.prompt_set_bookmark"),
2577            Action::PromptJumpToBookmark => t!("action.prompt_jump_to_bookmark"),
2578            Action::Undo => t!("action.undo"),
2579            Action::Redo => t!("action.redo"),
2580            Action::ScrollUp => t!("action.scroll_up"),
2581            Action::ScrollDown => t!("action.scroll_down"),
2582            Action::ShowHelp => t!("action.show_help"),
2583            Action::ShowKeyboardShortcuts => t!("action.show_keyboard_shortcuts"),
2584            Action::ShowWarnings => t!("action.show_warnings"),
2585            Action::ShowStatusLog => t!("action.show_status_log"),
2586            Action::ShowLspStatus => t!("action.show_lsp_status"),
2587            Action::ShowRemoteIndicatorMenu => t!("action.show_remote_indicator_menu"),
2588            Action::ShowReadOnlyMenu => t!("action.show_read_only_menu"),
2589            Action::ClearWarnings => t!("action.clear_warnings"),
2590            Action::CommandPalette => t!("action.command_palette"),
2591            Action::QuickOpen => t!("action.quick_open"),
2592            Action::QuickOpenBuffers => t!("action.quick_open_buffers"),
2593            Action::QuickOpenFiles => t!("action.quick_open_files"),
2594            Action::OpenLiveGrep => t!("action.open_live_grep"),
2595            Action::ResumeLiveGrep => t!("action.resume_live_grep"),
2596            Action::ToggleUtilityDock => t!("action.toggle_utility_dock"),
2597            Action::OpenTerminalInDock => t!("action.open_terminal_in_dock"),
2598            Action::CycleLiveGrepProvider => t!("action.cycle_live_grep_provider"),
2599            Action::InspectThemeAtCursor => t!("action.inspect_theme_at_cursor"),
2600            Action::ToggleLineWrap => t!("action.toggle_line_wrap"),
2601            Action::ToggleCurrentLineHighlight => t!("action.toggle_current_line_highlight"),
2602            Action::ToggleOccurrenceHighlight => t!("action.toggle_occurrence_highlight"),
2603            Action::ToggleReadOnly => t!("action.toggle_read_only"),
2604            Action::TogglePageView => t!("action.toggle_page_view"),
2605            Action::SetPageWidth => t!("action.set_page_width"),
2606            Action::NextBuffer => t!("action.next_buffer"),
2607            Action::PrevBuffer => t!("action.prev_buffer"),
2608            Action::NavigateBack => t!("action.navigate_back"),
2609            Action::NavigateForward => t!("action.navigate_forward"),
2610            Action::SplitHorizontal => t!("action.split_horizontal"),
2611            Action::SplitVertical => t!("action.split_vertical"),
2612            Action::CloseSplit => t!("action.close_split"),
2613            Action::NextSplit => t!("action.next_split"),
2614            Action::PrevSplit => t!("action.prev_split"),
2615            Action::NextWindow => t!("action.next_window"),
2616            Action::PrevWindow => t!("action.prev_window"),
2617            Action::IncreaseSplitSize => t!("action.increase_split_size"),
2618            Action::DecreaseSplitSize => t!("action.decrease_split_size"),
2619            Action::ToggleMaximizeSplit => t!("action.toggle_maximize_split"),
2620            Action::PromptConfirm => t!("action.prompt_confirm"),
2621            Action::PromptConfirmWithText(ref text) => {
2622                format!("{} ({})", t!("action.prompt_confirm"), text).into()
2623            }
2624            Action::PromptCancel => t!("action.prompt_cancel"),
2625            Action::PromptBackspace => t!("action.prompt_backspace"),
2626            Action::PromptDelete => t!("action.prompt_delete"),
2627            Action::PromptMoveLeft => t!("action.prompt_move_left"),
2628            Action::PromptMoveRight => t!("action.prompt_move_right"),
2629            Action::PromptMoveStart => t!("action.prompt_move_start"),
2630            Action::PromptMoveEnd => t!("action.prompt_move_end"),
2631            Action::PromptSelectPrev => t!("action.prompt_select_prev"),
2632            Action::PromptSelectNext => t!("action.prompt_select_next"),
2633            Action::PromptPageUp => t!("action.prompt_page_up"),
2634            Action::PromptPageDown => t!("action.prompt_page_down"),
2635            Action::PromptAcceptSuggestion => t!("action.prompt_accept_suggestion"),
2636            Action::PromptMoveWordLeft => t!("action.prompt_move_word_left"),
2637            Action::PromptMoveWordRight => t!("action.prompt_move_word_right"),
2638            Action::PromptDeleteWordForward => t!("action.prompt_delete_word_forward"),
2639            Action::PromptDeleteWordBackward => t!("action.prompt_delete_word_backward"),
2640            Action::PromptDeleteToLineEnd => t!("action.prompt_delete_to_line_end"),
2641            Action::PromptCopy => t!("action.prompt_copy"),
2642            Action::PromptCut => t!("action.prompt_cut"),
2643            Action::PromptPaste => t!("action.prompt_paste"),
2644            Action::PromptMoveLeftSelecting => t!("action.prompt_move_left_selecting"),
2645            Action::PromptMoveRightSelecting => t!("action.prompt_move_right_selecting"),
2646            Action::PromptMoveHomeSelecting => t!("action.prompt_move_home_selecting"),
2647            Action::PromptMoveEndSelecting => t!("action.prompt_move_end_selecting"),
2648            Action::PromptSelectWordLeft => t!("action.prompt_select_word_left"),
2649            Action::PromptSelectWordRight => t!("action.prompt_select_word_right"),
2650            Action::PromptSelectAll => t!("action.prompt_select_all"),
2651            Action::FileBrowserToggleHidden => t!("action.file_browser_toggle_hidden"),
2652            Action::FileBrowserToggleDetectEncoding => {
2653                t!("action.file_browser_toggle_detect_encoding")
2654            }
2655            Action::PopupSelectNext => t!("action.popup_select_next"),
2656            Action::PopupSelectPrev => t!("action.popup_select_prev"),
2657            Action::PopupPageUp => t!("action.popup_page_up"),
2658            Action::PopupPageDown => t!("action.popup_page_down"),
2659            Action::PopupConfirm => t!("action.popup_confirm"),
2660            Action::PopupCancel => t!("action.popup_cancel"),
2661            Action::PopupFocus => t!("action.popup_focus"),
2662            Action::CompletionAccept => t!("action.completion_accept"),
2663            Action::CompletionDismiss => t!("action.completion_dismiss"),
2664            Action::ToggleFileExplorer => t!("action.toggle_file_explorer"),
2665            Action::ToggleFileExplorerSide => t!("action.toggle_file_explorer_side"),
2666            Action::ToggleMenuBar => t!("action.toggle_menu_bar"),
2667            Action::ToggleTabBar => t!("action.toggle_tab_bar"),
2668            Action::ToggleStatusBar => t!("action.toggle_status_bar"),
2669            Action::TogglePromptLine => t!("action.toggle_prompt_line"),
2670            Action::ToggleVerticalScrollbar => t!("action.toggle_vertical_scrollbar"),
2671            Action::ToggleHorizontalScrollbar => t!("action.toggle_horizontal_scrollbar"),
2672            Action::FocusFileExplorer => t!("action.focus_file_explorer"),
2673            Action::FocusEditor => t!("action.focus_editor"),
2674            Action::ToggleDockFocus => t!("action.toggle_dock_focus"),
2675            Action::FileExplorerUp => t!("action.file_explorer_up"),
2676            Action::FileExplorerDown => t!("action.file_explorer_down"),
2677            Action::FileExplorerPageUp => t!("action.file_explorer_page_up"),
2678            Action::FileExplorerPageDown => t!("action.file_explorer_page_down"),
2679            Action::FileExplorerExpand => t!("action.file_explorer_expand"),
2680            Action::FileExplorerCollapse => t!("action.file_explorer_collapse"),
2681            Action::FileExplorerOpen => t!("action.file_explorer_open"),
2682            Action::FileExplorerRefresh => t!("action.file_explorer_refresh"),
2683            Action::FileExplorerNewFile => t!("action.file_explorer_new_file"),
2684            Action::FileExplorerNewDirectory => t!("action.file_explorer_new_directory"),
2685            Action::FileExplorerDelete => t!("action.file_explorer_delete"),
2686            Action::FileExplorerRename => t!("action.file_explorer_rename"),
2687            Action::FileExplorerToggleHidden => t!("action.file_explorer_toggle_hidden"),
2688            Action::FileExplorerToggleGitignored => t!("action.file_explorer_toggle_gitignored"),
2689            Action::FileExplorerSearchClear => t!("action.file_explorer_search_clear"),
2690            Action::FileExplorerSearchBackspace => t!("action.file_explorer_search_backspace"),
2691            Action::FileExplorerCopy => t!("action.file_explorer_copy"),
2692            Action::FileExplorerCut => t!("action.file_explorer_cut"),
2693            Action::FileExplorerPaste => t!("action.file_explorer_paste"),
2694            Action::FileExplorerDuplicate => t!("action.file_explorer_duplicate"),
2695            Action::FileExplorerCopyFullPath => t!("action.file_explorer_copy_full_path"),
2696            Action::FileExplorerCopyRelativePath => t!("action.file_explorer_copy_relative_path"),
2697            Action::FileExplorerExtendSelectionUp => t!("action.file_explorer_extend_selection_up"),
2698            Action::FileExplorerExtendSelectionDown => {
2699                t!("action.file_explorer_extend_selection_down")
2700            }
2701            Action::FileExplorerToggleSelect => t!("action.file_explorer_toggle_select"),
2702            Action::FileExplorerSelectAll => t!("action.file_explorer_select_all"),
2703            Action::LspCompletion => t!("action.lsp_completion"),
2704            Action::LspGotoDefinition => t!("action.lsp_goto_definition"),
2705            Action::LspReferences => t!("action.lsp_references"),
2706            Action::LspImplementation => t!("action.lsp_implementation"),
2707            Action::LspRename => t!("action.lsp_rename"),
2708            Action::LspHover => t!("action.lsp_hover"),
2709            Action::LspSignatureHelp => t!("action.lsp_signature_help"),
2710            Action::LspCodeActions => t!("action.lsp_code_actions"),
2711            Action::LspRestart => t!("action.lsp_restart"),
2712            Action::LspStop => t!("action.lsp_stop"),
2713            Action::LspToggleForBuffer => t!("action.lsp_toggle_for_buffer"),
2714            Action::ToggleInlayHints => t!("action.toggle_inlay_hints"),
2715            Action::ToggleMouseHover => t!("action.toggle_mouse_hover"),
2716            Action::ToggleLineNumbers => t!("action.toggle_line_numbers"),
2717            Action::ToggleLineNumbersCurrentBuffer => {
2718                t!("action.toggle_line_numbers_current_buffer")
2719            }
2720            Action::ToggleLineWrapCurrentBuffer => t!("action.toggle_line_wrap_current_buffer"),
2721            Action::TriggerWaveAnimation => t!("action.trigger_wave_animation"),
2722            Action::ToggleScrollSync => t!("action.toggle_scroll_sync"),
2723            Action::ToggleMouseCapture => t!("action.toggle_mouse_capture"),
2724            Action::ToggleDebugHighlights => t!("action.toggle_debug_highlights"),
2725            Action::SetBackground => t!("action.set_background"),
2726            Action::SetBackgroundBlend => t!("action.set_background_blend"),
2727            Action::AddRuler => t!("action.add_ruler"),
2728            Action::RemoveRuler => t!("action.remove_ruler"),
2729            Action::SetTabSize => t!("action.set_tab_size"),
2730            Action::SetLineEnding => t!("action.set_line_ending"),
2731            Action::SetEncoding => t!("action.set_encoding"),
2732            Action::ReloadWithEncoding => t!("action.reload_with_encoding"),
2733            Action::SetLanguage => t!("action.set_language"),
2734            Action::ToggleIndentationStyle => t!("action.toggle_indentation_style"),
2735            Action::ToggleTabIndicators => t!("action.toggle_tab_indicators"),
2736            Action::ToggleWhitespaceIndicators => t!("action.toggle_whitespace_indicators"),
2737            Action::ResetBufferSettings => t!("action.reset_buffer_settings"),
2738            Action::DumpConfig => t!("action.dump_config"),
2739            Action::RedrawScreen => t!("action.redraw_screen"),
2740            Action::Search => t!("action.search"),
2741            Action::FindInSelection => t!("action.find_in_selection"),
2742            Action::FindNext => t!("action.find_next"),
2743            Action::FindPrevious => t!("action.find_previous"),
2744            Action::FindSelectionNext => t!("action.find_selection_next"),
2745            Action::FindSelectionPrevious => t!("action.find_selection_previous"),
2746            Action::ClearSearch => t!("action.clear_search"),
2747            Action::Replace => t!("action.replace"),
2748            Action::QueryReplace => t!("action.query_replace"),
2749            Action::MenuActivate => t!("action.menu_activate"),
2750            Action::MenuClose => t!("action.menu_close"),
2751            Action::MenuLeft => t!("action.menu_left"),
2752            Action::MenuRight => t!("action.menu_right"),
2753            Action::MenuUp => t!("action.menu_up"),
2754            Action::MenuDown => t!("action.menu_down"),
2755            Action::MenuExecute => t!("action.menu_execute"),
2756            Action::MenuOpen(name) => t!("action.menu_open", name = name),
2757            Action::SwitchKeybindingMap(map) => t!("action.switch_keybinding_map", map = map),
2758            Action::PluginAction(name) => t!("action.plugin_action", name = name),
2759            Action::ScrollTabsLeft => t!("action.scroll_tabs_left"),
2760            Action::ScrollTabsRight => t!("action.scroll_tabs_right"),
2761            Action::SelectTheme => t!("action.select_theme"),
2762            Action::SelectKeybindingMap => t!("action.select_keybinding_map"),
2763            Action::SelectCursorStyle => t!("action.select_cursor_style"),
2764            Action::SelectLocale => t!("action.select_locale"),
2765            Action::SwitchToPreviousTab => t!("action.switch_to_previous_tab"),
2766            Action::SwitchToTabByName => t!("action.switch_to_tab_by_name"),
2767            Action::OpenTerminal => t!("action.open_terminal"),
2768            Action::OpenTerminalRight => t!("action.open_terminal_right"),
2769            Action::OpenTerminalBelow => t!("action.open_terminal_below"),
2770            Action::CloseTerminal => t!("action.close_terminal"),
2771            Action::FocusTerminal => t!("action.focus_terminal"),
2772            Action::TerminalEscape => t!("action.terminal_escape"),
2773            Action::ToggleKeyboardCapture => t!("action.toggle_keyboard_capture"),
2774            Action::TerminalPaste => t!("action.terminal_paste"),
2775            Action::SendSelectionToTerminal => t!("action.send_selection_to_terminal"),
2776            Action::OpenSettings => t!("action.open_settings"),
2777            Action::CloseSettings => t!("action.close_settings"),
2778            Action::SettingsSave => t!("action.settings_save"),
2779            Action::SettingsReset => t!("action.settings_reset"),
2780            Action::SettingsToggleFocus => t!("action.settings_toggle_focus"),
2781            Action::SettingsActivate => t!("action.settings_activate"),
2782            Action::SettingsSearch => t!("action.settings_search"),
2783            Action::SettingsHelp => t!("action.settings_help"),
2784            Action::SettingsIncrement => t!("action.settings_increment"),
2785            Action::SettingsDecrement => t!("action.settings_decrement"),
2786            Action::SettingsInherit => t!("action.settings_inherit"),
2787            Action::ShellCommand => t!("action.shell_command"),
2788            Action::ShellCommandReplace => t!("action.shell_command_replace"),
2789            Action::ToUpperCase => t!("action.to_uppercase"),
2790            Action::ToLowerCase => t!("action.to_lowercase"),
2791            Action::ToggleCase => t!("action.to_uppercase"),
2792            Action::SortLines => t!("action.sort_lines"),
2793            Action::CalibrateInput => t!("action.calibrate_input"),
2794            Action::EventDebug => t!("action.event_debug"),
2795            Action::SuspendProcess => t!("action.suspend_process"),
2796            Action::LoadPluginFromBuffer => "Load Plugin from Buffer".into(),
2797            Action::InitReload => "Reload init.ts".into(),
2798            Action::InitEdit => "Edit init.ts".into(),
2799            Action::InitCheck => "Check init.ts".into(),
2800            Action::OpenKeybindingEditor => "Keybinding Editor".into(),
2801            Action::CompositeNextHunk => t!("action.composite_next_hunk"),
2802            Action::CompositePrevHunk => t!("action.composite_prev_hunk"),
2803            Action::WorkspaceTrustTrust => t!("action.workspace_trust_trust"),
2804            Action::WorkspaceTrustRestrict => t!("action.workspace_trust_restrict"),
2805            Action::WorkspaceTrustBlock => t!("action.workspace_trust_block"),
2806            Action::WorkspaceTrustPrompt => t!("action.workspace_trust_prompt"),
2807            Action::None => t!("action.none"),
2808        }
2809        .to_string()
2810    }
2811
2812    /// Public wrapper for parse_key (for keybinding editor)
2813    pub fn parse_key_public(key: &str) -> Option<KeyCode> {
2814        Self::parse_key(key)
2815    }
2816
2817    /// Public wrapper for parse_modifiers (for keybinding editor)
2818    pub fn parse_modifiers_public(modifiers: &[String]) -> KeyModifiers {
2819        Self::parse_modifiers(modifiers)
2820    }
2821
2822    /// Format an action name string as a human-readable description.
2823    /// Used by the keybinding editor to display action names without needing
2824    /// a full Action enum parse.
2825    pub fn format_action_from_str(action_name: &str) -> String {
2826        Self::format_action_from_str_with_args(action_name, &std::collections::HashMap::new())
2827    }
2828
2829    /// Like `format_action_from_str` but uses the provided args so parameterised
2830    /// actions (e.g. `menu_open` with `{"name": "File"}`) produce distinct,
2831    /// informative descriptions instead of a generic fallback.
2832    pub fn format_action_from_str_with_args(
2833        action_name: &str,
2834        args: &std::collections::HashMap<String, serde_json::Value>,
2835    ) -> String {
2836        // Try to parse as Action enum first
2837        if let Some(action) = Action::from_str(action_name, args) {
2838            Self::format_action(&action)
2839        } else {
2840            // Fallback: convert snake_case to Title Case
2841            action_name
2842                .split('_')
2843                .map(|word| {
2844                    let mut chars = word.chars();
2845                    match chars.next() {
2846                        Some(c) => {
2847                            let upper: String = c.to_uppercase().collect();
2848                            format!("{}{}", upper, chars.as_str())
2849                        }
2850                        None => String::new(),
2851                    }
2852                })
2853                .collect::<Vec<_>>()
2854                .join(" ")
2855        }
2856    }
2857
2858    /// Return a sorted list of all valid action name strings.
2859    /// Delegates to `Action::all_action_names()` which is generated by the
2860    /// `define_action_str_mapping!` macro (same source of truth as `Action::from_str`).
2861    pub fn all_action_names() -> Vec<String> {
2862        Action::all_action_names()
2863    }
2864
2865    /// Get the keybinding string for an action in a specific context
2866    /// Returns the first keybinding found (prioritizing custom bindings over defaults)
2867    /// When multiple keybindings exist for the same action, prefers canonical keys over
2868    /// terminal equivalents (e.g., "Space" over "@")
2869    /// Returns None if no binding is found
2870    pub fn get_keybinding_for_action(
2871        &self,
2872        action: &Action,
2873        context: KeyContext,
2874    ) -> Option<String> {
2875        self.get_keybinding_event_for_action(action, context)
2876            .map(|(k, m)| format_keybinding(&k, &m))
2877    }
2878
2879    /// Raw-event counterpart to `get_keybinding_for_action`: returns the
2880    /// `(KeyCode, KeyModifiers)` pair bound to `action` in `context` — or
2881    /// falls through to the same Normal-context chain the string version
2882    /// does — so callers (notably tests simulating user input) can feed
2883    /// the bound key through the editor's key dispatcher without
2884    /// hardcoding a default that a rebind would invalidate.
2885    pub fn get_keybinding_event_for_action(
2886        &self,
2887        action: &Action,
2888        context: KeyContext,
2889    ) -> Option<(KeyCode, KeyModifiers)> {
2890        // Helper to collect all matching keybindings from a map and pick the best one
2891        fn find_best_keybinding(
2892            bindings: &HashMap<(KeyCode, KeyModifiers), Action>,
2893            action: &Action,
2894        ) -> Option<(KeyCode, KeyModifiers)> {
2895            let matches: Vec<_> = bindings
2896                .iter()
2897                .filter(|(_, a)| *a == action)
2898                .map(|((k, m), _)| (*k, *m))
2899                .collect();
2900
2901            if matches.is_empty() {
2902                return None;
2903            }
2904
2905            // Sort to prefer canonical keys over terminal equivalents
2906            // Terminal equivalents like '@' (for space), '7' (for '/'), etc. should be deprioritized
2907            let mut sorted = matches;
2908            sorted.sort_by(|(k1, m1), (k2, m2)| {
2909                let score1 = keybinding_priority_score(k1);
2910                let score2 = keybinding_priority_score(k2);
2911                // Lower score = higher priority
2912                match score1.cmp(&score2) {
2913                    std::cmp::Ordering::Equal => {
2914                        // Tie-break by formatted string for full determinism
2915                        let s1 = format_keybinding(k1, m1);
2916                        let s2 = format_keybinding(k2, m2);
2917                        s1.cmp(&s2)
2918                    }
2919                    other => other,
2920                }
2921            });
2922
2923            sorted.into_iter().next()
2924        }
2925
2926        // Check custom bindings first (higher priority)
2927        if let Some(context_bindings) = self.bindings.get(&context) {
2928            if let Some(hit) = find_best_keybinding(context_bindings, action) {
2929                return Some(hit);
2930            }
2931        }
2932
2933        // Check default bindings for this context
2934        if let Some(context_bindings) = self.default_bindings.get(&context) {
2935            if let Some(hit) = find_best_keybinding(context_bindings, action) {
2936                return Some(hit);
2937            }
2938        }
2939
2940        // For certain contexts, also check Normal context for fallthrough actions
2941        if context != KeyContext::Normal
2942            && (context.allows_normal_fallthrough()
2943                || Self::is_application_wide_action(action)
2944                || (context.allows_ui_fallthrough() && Self::is_terminal_ui_action(action)))
2945        {
2946            // Check custom normal bindings
2947            if let Some(normal_bindings) = self.bindings.get(&KeyContext::Normal) {
2948                if let Some(hit) = find_best_keybinding(normal_bindings, action) {
2949                    return Some(hit);
2950                }
2951            }
2952
2953            // Check default normal bindings
2954            if let Some(normal_bindings) = self.default_bindings.get(&KeyContext::Normal) {
2955                if let Some(hit) = find_best_keybinding(normal_bindings, action) {
2956                    return Some(hit);
2957                }
2958            }
2959        }
2960
2961        None
2962    }
2963
2964    /// Reload bindings from config (for hot reload)
2965    pub fn reload(&mut self, config: &Config) {
2966        self.bindings.clear();
2967        for binding in &config.keybindings {
2968            if let Some(key_code) = Self::parse_key(&binding.key) {
2969                let modifiers = Self::parse_modifiers(&binding.modifiers);
2970                if let Some(action) = Action::from_str(&binding.action, &binding.args) {
2971                    // Determine context from "when" clause
2972                    let context = if let Some(ref when) = binding.when {
2973                        KeyContext::from_when_clause(when).unwrap_or(KeyContext::Normal)
2974                    } else {
2975                        KeyContext::Normal
2976                    };
2977
2978                    self.bindings
2979                        .entry(context)
2980                        .or_default()
2981                        .insert((key_code, modifiers), action);
2982                }
2983            }
2984        }
2985    }
2986}
2987
2988#[cfg(test)]
2989mod tests {
2990    use super::*;
2991
2992    #[test]
2993    fn test_parse_key() {
2994        assert_eq!(KeybindingResolver::parse_key("enter"), Some(KeyCode::Enter));
2995        assert_eq!(
2996            KeybindingResolver::parse_key("backspace"),
2997            Some(KeyCode::Backspace)
2998        );
2999        assert_eq!(KeybindingResolver::parse_key("tab"), Some(KeyCode::Tab));
3000        assert_eq!(
3001            KeybindingResolver::parse_key("backtab"),
3002            Some(KeyCode::BackTab)
3003        );
3004        assert_eq!(
3005            KeybindingResolver::parse_key("BackTab"),
3006            Some(KeyCode::BackTab)
3007        );
3008        assert_eq!(KeybindingResolver::parse_key("a"), Some(KeyCode::Char('a')));
3009    }
3010
3011    #[test]
3012    fn test_parse_modifiers() {
3013        let mods = vec!["ctrl".to_string()];
3014        assert_eq!(
3015            KeybindingResolver::parse_modifiers(&mods),
3016            KeyModifiers::CONTROL
3017        );
3018
3019        let mods = vec!["ctrl".to_string(), "shift".to_string()];
3020        assert_eq!(
3021            KeybindingResolver::parse_modifiers(&mods),
3022            KeyModifiers::CONTROL | KeyModifiers::SHIFT
3023        );
3024    }
3025
3026    #[test]
3027    fn test_format_action_from_str_distinguishes_menu_open_by_name() {
3028        // Regression test for #1407: all menu_open bindings used to render with
3029        // the identical "Menu Open" fallback because the args weren't considered.
3030        let mut file_args = HashMap::new();
3031        file_args.insert(
3032            "name".to_string(),
3033            serde_json::Value::String("File".to_string()),
3034        );
3035        let mut edit_args = HashMap::new();
3036        edit_args.insert(
3037            "name".to_string(),
3038            serde_json::Value::String("Edit".to_string()),
3039        );
3040
3041        let file_display =
3042            KeybindingResolver::format_action_from_str_with_args("menu_open", &file_args);
3043        let edit_display =
3044            KeybindingResolver::format_action_from_str_with_args("menu_open", &edit_args);
3045        let no_args_display = KeybindingResolver::format_action_from_str("menu_open");
3046
3047        assert_ne!(
3048            file_display, edit_display,
3049            "menu_open with different names should produce different descriptions"
3050        );
3051        assert!(
3052            file_display.contains("File"),
3053            "expected the File menu description to contain \"File\", got {file_display:?}"
3054        );
3055        assert!(
3056            edit_display.contains("Edit"),
3057            "expected the Edit menu description to contain \"Edit\", got {edit_display:?}"
3058        );
3059        // Without args the parameterised action can't be reconstructed, so the
3060        // generic fallback is used — which is the bug this fix routes around
3061        // whenever callers have the args available.
3062        assert_eq!(no_args_display, "Menu Open");
3063    }
3064
3065    #[test]
3066    fn test_format_action_word_end_actions_are_localized() {
3067        // Regression test for #1878: `action.move_word_end` and
3068        // `action.select_word_end` were referenced via `t!()` from
3069        // `format_action` but missing from every locale file, so the
3070        // Keyboard Shortcuts menu rendered the raw key as the description.
3071        crate::i18n::set_locale("en");
3072
3073        let move_desc = KeybindingResolver::format_action(&Action::MoveWordEnd);
3074        assert_ne!(
3075            move_desc, "action.move_word_end",
3076            "MoveWordEnd should resolve to a translated description"
3077        );
3078        let select_desc = KeybindingResolver::format_action(&Action::SelectWordEnd);
3079        assert_ne!(
3080            select_desc, "action.select_word_end",
3081            "SelectWordEnd should resolve to a translated description"
3082        );
3083
3084        // Vim aliases share the same translation keys.
3085        assert_eq!(
3086            KeybindingResolver::format_action(&Action::ViMoveWordEnd),
3087            move_desc,
3088        );
3089        assert_eq!(
3090            KeybindingResolver::format_action(&Action::ViSelectWordEnd),
3091            select_desc,
3092        );
3093    }
3094
3095    #[test]
3096    fn test_qualify_and_unqualify_roundtrip_menu_open() {
3097        let mut args = HashMap::new();
3098        args.insert(
3099            "name".to_string(),
3100            serde_json::Value::String("File".to_string()),
3101        );
3102
3103        let qualified = Action::qualify_action("menu_open", &args);
3104        assert_eq!(qualified, "menu_open:File");
3105
3106        let (bare, parsed_args) = Action::unqualify_action(&qualified);
3107        assert_eq!(bare, "menu_open");
3108        assert_eq!(
3109            parsed_args.get("name").and_then(|v| v.as_str()),
3110            Some("File")
3111        );
3112    }
3113
3114    #[test]
3115    fn test_qualify_action_passthrough_for_unparameterised() {
3116        // Non-parameterised actions should round-trip as-is, with no suffix.
3117        let args = HashMap::new();
3118        assert_eq!(Action::qualify_action("save", &args), "save");
3119        let (bare, parsed) = Action::unqualify_action("save");
3120        assert_eq!(bare, "save");
3121        assert!(parsed.is_empty());
3122    }
3123
3124    #[test]
3125    fn test_qualify_action_no_suffix_when_arg_missing() {
3126        // A parameterised action without its variant arg keeps the bare form —
3127        // caller (the editor) treats this as "needs a variant picked".
3128        let args = HashMap::new();
3129        assert_eq!(Action::qualify_action("menu_open", &args), "menu_open");
3130    }
3131
3132    #[test]
3133    fn test_unqualify_action_ignores_colon_on_unknown_action() {
3134        // Plugin action names aren't the variant-arg kind, so the colon must
3135        // not be treated as a variant separator.
3136        let (bare, parsed) = Action::unqualify_action("my_plugin:action_with:colons");
3137        assert_eq!(bare, "my_plugin:action_with:colons");
3138        assert!(parsed.is_empty());
3139    }
3140
3141    #[test]
3142    fn test_to_qualified_action_str_for_menu_open() {
3143        let action = Action::MenuOpen("Edit".to_string());
3144        assert_eq!(action.to_qualified_action_str(), "menu_open:Edit");
3145    }
3146
3147    #[test]
3148    fn test_resolve_basic() {
3149        let config = Config::default();
3150        let resolver = KeybindingResolver::new(&config);
3151
3152        let event = KeyEvent::new(KeyCode::Left, KeyModifiers::empty());
3153        assert_eq!(
3154            resolver.resolve(&event, KeyContext::Normal),
3155            Action::MoveLeft
3156        );
3157
3158        let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty());
3159        assert_eq!(
3160            resolver.resolve(&event, KeyContext::Normal),
3161            Action::InsertChar('a')
3162        );
3163    }
3164
3165    /// Editor-global navigation actions (split nav, save, palette, …)
3166    /// must resolve when a plugin `Mode(_)` context is active, even
3167    /// though the mode itself doesn't bind those keys. Without this,
3168    /// focus inside a panel (search/replace, dashboard, git log, …)
3169    /// swallows `Alt+]`, `Ctrl+S`, `Ctrl+P`, etc. Regression guard for
3170    /// §18 of `docs/internal/search-replace-scope-replan-on-widgets.md`.
3171    #[test]
3172    fn test_panel_mode_passthrough_for_ui_actions() {
3173        let config = Config::default();
3174        let resolver = KeybindingResolver::new(&config);
3175        let mode_ctx = KeyContext::Mode("search-replace-list".to_string());
3176
3177        // Alt+] is bound to next_split in the keymap (Normal context).
3178        let alt_close = KeyEvent::new(KeyCode::Char(']'), KeyModifiers::ALT);
3179        assert_eq!(
3180            resolver.resolve(&alt_close, mode_ctx.clone()),
3181            Action::NextSplit,
3182            "Alt+] should fall through to next_split inside a panel mode"
3183        );
3184
3185        // Alt+[ → prev_split.
3186        let alt_open = KeyEvent::new(KeyCode::Char('['), KeyModifiers::ALT);
3187        assert_eq!(
3188            resolver.resolve(&alt_open, mode_ctx.clone()),
3189            Action::PrevSplit,
3190            "Alt+[ should fall through to prev_split inside a panel mode"
3191        );
3192
3193        // Ctrl+S is application-wide (covered by `is_application_wide_action`),
3194        // but also `is_terminal_ui_action`-true — verify the UI fallthrough
3195        // path doesn't accidentally exclude it.
3196        let ctrl_s = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
3197        assert_eq!(
3198            resolver.resolve(&ctrl_s, mode_ctx.clone()),
3199            Action::Save,
3200            "Ctrl+S should still save while a panel mode is active"
3201        );
3202
3203        // Editing actions on the source buffer must NOT pass through.
3204        // Ctrl+D (add cursor next match) is editor-only and absent from
3205        // `is_terminal_ui_action`.
3206        let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
3207        assert_ne!(
3208            resolver.resolve(&ctrl_d, mode_ctx),
3209            Action::AddCursorNextMatch,
3210            "Ctrl+D (add cursor next match) must not pass through to a panel mode"
3211        );
3212    }
3213
3214    #[test]
3215    fn test_shift_backspace_matches_backspace() {
3216        // Regression test for https://github.com/sinelaw/fresh/issues/1588
3217        // Shift+Backspace should resolve to the same action as plain Backspace
3218        // in every context where Backspace has a binding.
3219        let config = Config::default();
3220        let resolver = KeybindingResolver::new(&config);
3221
3222        let backspace = KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty());
3223        let shift_backspace = KeyEvent::new(KeyCode::Backspace, KeyModifiers::SHIFT);
3224
3225        // Normal context: backspace deletes backward
3226        assert_eq!(
3227            resolver.resolve(&backspace, KeyContext::Normal),
3228            Action::DeleteBackward,
3229            "Backspace should resolve to DeleteBackward in Normal context"
3230        );
3231        assert_eq!(
3232            resolver.resolve(&shift_backspace, KeyContext::Normal),
3233            Action::DeleteBackward,
3234            "Shift+Backspace should resolve to DeleteBackward (same as Backspace) in Normal context"
3235        );
3236
3237        // Prompt context: prompt_backspace
3238        assert_eq!(
3239            resolver.resolve(&backspace, KeyContext::Prompt),
3240            Action::PromptBackspace,
3241            "Backspace should resolve to PromptBackspace in Prompt context"
3242        );
3243        assert_eq!(
3244            resolver.resolve(&shift_backspace, KeyContext::Prompt),
3245            Action::PromptBackspace,
3246            "Shift+Backspace should resolve to PromptBackspace (same as Backspace) in Prompt context"
3247        );
3248
3249        // FileExplorer context: file_explorer_search_backspace
3250        assert_eq!(
3251            resolver.resolve(&backspace, KeyContext::FileExplorer),
3252            Action::FileExplorerSearchBackspace,
3253            "Backspace should resolve to FileExplorerSearchBackspace in FileExplorer context"
3254        );
3255        assert_eq!(
3256            resolver.resolve(&shift_backspace, KeyContext::FileExplorer),
3257            Action::FileExplorerSearchBackspace,
3258            "Shift+Backspace should resolve to FileExplorerSearchBackspace (same as Backspace) in FileExplorer context"
3259        );
3260    }
3261
3262    #[test]
3263    fn test_shift_letter_binding_works_without_terminal_shift_modifier() {
3264        // Regression test for https://github.com/sinelaw/fresh/issues/1899
3265        // Most terminals don't report the SHIFT modifier when sending an
3266        // uppercase letter — the case carries the shift information instead.
3267        // A binding defined as `Shift+P` (key=p, modifiers=[shift]) must
3268        // still trigger when the terminal sends `Char('P')` with no modifiers.
3269        let mut config = Config::default();
3270        config.keybindings.push(crate::config::Keybinding {
3271            key: "p".to_string(),
3272            modifiers: vec!["shift".to_string()],
3273            keys: Vec::new(),
3274            action: "save".to_string(),
3275            args: HashMap::new(),
3276            when: Some("normal".to_string()),
3277        });
3278        let resolver = KeybindingResolver::new(&config);
3279
3280        // Case 1: kitty keyboard protocol — explicit SHIFT modifier alongside uppercase.
3281        let kitty_upper_shift = KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
3282        assert_eq!(
3283            resolver.resolve(&kitty_upper_shift, KeyContext::Normal),
3284            Action::Save,
3285            "Char('P')+SHIFT should match Shift+P binding"
3286        );
3287
3288        // Case 2: kitty protocol with lowercased char (caps lock or some terminals).
3289        let kitty_lower_shift = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::SHIFT);
3290        assert_eq!(
3291            resolver.resolve(&kitty_lower_shift, KeyContext::Normal),
3292            Action::Save,
3293            "Char('p')+SHIFT should match Shift+P binding"
3294        );
3295
3296        // Case 3: typical terminal without kitty protocol — uppercase only, no modifier.
3297        // This is the case that failed before the fix.
3298        let plain_upper = KeyEvent::new(KeyCode::Char('P'), KeyModifiers::empty());
3299        assert_eq!(
3300            resolver.resolve(&plain_upper, KeyContext::Normal),
3301            Action::Save,
3302            "Char('P') with no modifier should match Shift+P binding \
3303             (typical terminal behavior — case alone carries shift)"
3304        );
3305    }
3306
3307    #[test]
3308    fn test_alt_shift_letter_binding_matches_across_terminal_encodings() {
3309        // Alt+Shift+F is encoded differently by different terminals: some send
3310        // `Char('F')` + ALT (no SHIFT bit — the case carries the shift), others
3311        // send `Char('F')` + ALT + SHIFT. Both must match an `Alt+Shift+F`
3312        // binding, and both must stay distinct from a plain `Alt+F` (Alt+f).
3313        let mut config = Config::default();
3314        config.keybindings.push(crate::config::Keybinding {
3315            key: "f".to_string(),
3316            modifiers: vec!["alt".to_string(), "shift".to_string()],
3317            keys: Vec::new(),
3318            action: "save".to_string(),
3319            args: HashMap::new(),
3320            when: Some("normal".to_string()),
3321        });
3322        let resolver = KeybindingResolver::new(&config);
3323
3324        // Encoding A: uppercase + ALT, terminal omits the SHIFT bit.
3325        let no_shift_bit = KeyEvent::new(KeyCode::Char('F'), KeyModifiers::ALT);
3326        assert_eq!(
3327            resolver.resolve(&no_shift_bit, KeyContext::Normal),
3328            Action::Save,
3329            "Char('F')+ALT (no SHIFT bit) should match Alt+Shift+F"
3330        );
3331
3332        // Encoding B: uppercase + ALT + SHIFT, terminal reports the SHIFT bit.
3333        let with_shift_bit =
3334            KeyEvent::new(KeyCode::Char('F'), KeyModifiers::ALT | KeyModifiers::SHIFT);
3335        assert_eq!(
3336            resolver.resolve(&with_shift_bit, KeyContext::Normal),
3337            Action::Save,
3338            "Char('F')+ALT+SHIFT should match Alt+Shift+F"
3339        );
3340
3341        // Plain Alt+f (no shift) must NOT match the Alt+Shift+F binding.
3342        let alt_f = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT);
3343        assert_ne!(
3344            resolver.resolve(&alt_f, KeyContext::Normal),
3345            Action::Save,
3346            "Alt+f (lowercase, no shift) must stay distinct from Alt+Shift+F"
3347        );
3348    }
3349
3350    #[test]
3351    fn test_capslock_ctrl_letter_still_matches_ctrl_letter_binding() {
3352        // The fix above must not regress the long-standing behavior where
3353        // CapsLock+Ctrl+A is treated equivalently to Ctrl+A. When the
3354        // character arrives uppercase with another modifier (CONTROL), we
3355        // don't infer SHIFT — that case is ambiguous and the existing intent
3356        // is to fold caps lock away.
3357        let config = Config::default();
3358        let resolver = KeybindingResolver::new(&config);
3359
3360        // Ctrl+A (lowercase, real key press).
3361        let ctrl_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
3362        let action_ctrl_a = resolver.resolve(&ctrl_a, KeyContext::Normal);
3363
3364        // CapsLock+Ctrl+A (uppercase from caps, CONTROL modifier only).
3365        let caps_ctrl_a = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::CONTROL);
3366        let action_caps_ctrl_a = resolver.resolve(&caps_ctrl_a, KeyContext::Normal);
3367
3368        assert_eq!(
3369            action_ctrl_a, action_caps_ctrl_a,
3370            "CapsLock+Ctrl+A must resolve to the same action as Ctrl+A"
3371        );
3372    }
3373
3374    #[test]
3375    fn test_uppercase_without_binding_falls_through_to_insert_char() {
3376        // When there's no binding for a Shift+letter, an uppercase letter must
3377        // still be inserted as text. The fix to normalize uppercase → SHIFT
3378        // for lookup must not interfere with the InsertChar fallback (which
3379        // uses the original event, not the normalized one).
3380        let config = Config::default();
3381        let resolver = KeybindingResolver::new(&config);
3382
3383        // 'Z' has no Shift+z binding in the default config.
3384        let upper_z_no_mod = KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::empty());
3385        assert_eq!(
3386            resolver.resolve(&upper_z_no_mod, KeyContext::Normal),
3387            Action::InsertChar('Z'),
3388            "Char('Z') with no modifier should still be inserted as 'Z'"
3389        );
3390
3391        let upper_z_shift = KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::SHIFT);
3392        assert_eq!(
3393            resolver.resolve(&upper_z_shift, KeyContext::Normal),
3394            Action::InsertChar('Z'),
3395            "Char('Z')+SHIFT should still be inserted as 'Z'"
3396        );
3397    }
3398
3399    #[test]
3400    fn test_file_explorer_ui_fallthrough() {
3401        // Regression test for https://github.com/sinelaw/fresh/issues/1903
3402        // From the FileExplorer context, application-level UI / navigation
3403        // bindings (next/prev buffer, scroll tabs, close tab, next/prev
3404        // split, ...) bound only in Normal must still fire — the user
3405        // expects to act on the "tabbed files" without first transferring
3406        // focus out of the explorer.
3407        let config = Config::default();
3408        let resolver = KeybindingResolver::new(&config);
3409
3410        let cases = [
3411            (
3412                KeyCode::PageUp,
3413                KeyModifiers::CONTROL,
3414                Action::PrevBuffer,
3415                "Ctrl+PageUp -> prev_buffer",
3416            ),
3417            (
3418                KeyCode::PageDown,
3419                KeyModifiers::CONTROL,
3420                Action::NextBuffer,
3421                "Ctrl+PageDown -> next_buffer",
3422            ),
3423            (
3424                KeyCode::PageUp,
3425                KeyModifiers::ALT,
3426                Action::ScrollTabsLeft,
3427                "Alt+PageUp -> scroll_tabs_left",
3428            ),
3429            (
3430                KeyCode::PageDown,
3431                KeyModifiers::ALT,
3432                Action::ScrollTabsRight,
3433                "Alt+PageDown -> scroll_tabs_right",
3434            ),
3435            (
3436                KeyCode::Char('w'),
3437                KeyModifiers::ALT,
3438                Action::CloseTab,
3439                "Alt+W -> close_tab",
3440            ),
3441        ];
3442
3443        for (code, mods, expected, label) in cases {
3444            let event = KeyEvent::new(code, mods);
3445            assert_eq!(
3446                resolver.resolve(&event, KeyContext::FileExplorer),
3447                expected,
3448                "{label} should fall through from FileExplorer to Normal"
3449            );
3450        }
3451
3452        // Cursor-level Normal bindings (e.g. arrow keys for cursor movement)
3453        // must NOT fall through — the explorer has its own arrow handlers.
3454        // Up arrow in FileExplorer is bound to file_explorer_up; verify
3455        // the explorer binding wins over a hypothetical Normal MoveUp.
3456        let up = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
3457        assert_eq!(
3458            resolver.resolve(&up, KeyContext::FileExplorer),
3459            Action::FileExplorerUp,
3460            "Up must continue to navigate the explorer, not move the cursor"
3461        );
3462
3463        // Plain InsertChar fallthrough must NOT happen for non-UI Normal
3464        // bindings: 'd' (without Alt/Ctrl) is part of FileExplorer text
3465        // input (search-as-you-type), it must remain InsertChar and not
3466        // resolve to AddCursorNextMatch (Ctrl+D) or anything else.
3467        let plain_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::empty());
3468        assert_eq!(
3469            resolver.resolve(&plain_d, KeyContext::FileExplorer),
3470            Action::InsertChar('d'),
3471            "Plain 'd' must remain text input for explorer search-as-you-type"
3472        );
3473    }
3474
3475    #[test]
3476    fn to_action_spec_round_trips_through_from_str() {
3477        // A representative spread: no-arg, char-payload, and string-payload
3478        // actions. Each must survive Action -> ActionSpec -> Action unchanged,
3479        // which is the invariant the macro<->code bridge relies on.
3480        let cases = vec![
3481            Action::MoveLeft,
3482            Action::DeleteLine,
3483            Action::InsertChar('x'),
3484            Action::InsertChar(' '),
3485            Action::SetBookmark('3'),
3486            Action::PlayMacro('q'),
3487            Action::ShowMacro('0'),
3488            Action::PromptConfirmWithText("hello world".to_string()),
3489            Action::MenuOpen("File".to_string()),
3490            Action::SwitchKeybindingMap("emacs".to_string()),
3491            Action::CopyWithTheme("one-dark".to_string()),
3492        ];
3493        for action in cases {
3494            let spec = action.to_action_spec();
3495            assert_eq!(spec.count, 1, "to_action_spec emits count == 1");
3496            let back = Action::from_str(&spec.action, &spec.args)
3497                .unwrap_or_else(|| panic!("from_str failed for {:?}", action));
3498            assert_eq!(back, action, "round-trip mismatch via spec {:?}", spec);
3499        }
3500    }
3501
3502    #[test]
3503    fn to_action_spec_omits_args_for_plain_actions() {
3504        assert!(Action::MoveRight.to_action_spec().args.is_empty());
3505        // Char actions carry their payload.
3506        let spec = Action::InsertChar('z').to_action_spec();
3507        assert_eq!(spec.args.get("char").and_then(|v| v.as_str()), Some("z"));
3508    }
3509
3510    #[test]
3511    fn test_action_from_str() {
3512        let args = HashMap::new();
3513        assert_eq!(Action::from_str("move_left", &args), Some(Action::MoveLeft));
3514        assert_eq!(Action::from_str("save", &args), Some(Action::Save));
3515        // Unknown action names are treated as plugin actions
3516        assert_eq!(
3517            Action::from_str("unknown", &args),
3518            Some(Action::PluginAction("unknown".to_string()))
3519        );
3520
3521        // Test new context-specific actions
3522        assert_eq!(
3523            Action::from_str("keyboard_shortcuts", &args),
3524            Some(Action::ShowKeyboardShortcuts)
3525        );
3526        assert_eq!(
3527            Action::from_str("prompt_confirm", &args),
3528            Some(Action::PromptConfirm)
3529        );
3530        assert_eq!(
3531            Action::from_str("popup_cancel", &args),
3532            Some(Action::PopupCancel)
3533        );
3534
3535        // Test calibrate_input action
3536        assert_eq!(
3537            Action::from_str("calibrate_input", &args),
3538            Some(Action::CalibrateInput)
3539        );
3540    }
3541
3542    #[test]
3543    fn test_key_context_from_when_clause() {
3544        assert_eq!(
3545            KeyContext::from_when_clause("normal"),
3546            Some(KeyContext::Normal)
3547        );
3548        assert_eq!(
3549            KeyContext::from_when_clause("prompt"),
3550            Some(KeyContext::Prompt)
3551        );
3552        assert_eq!(
3553            KeyContext::from_when_clause("searchPrompt"),
3554            Some(KeyContext::SearchPrompt)
3555        );
3556        assert_eq!(
3557            KeyContext::from_when_clause("search_prompt"),
3558            Some(KeyContext::SearchPrompt)
3559        );
3560        assert_eq!(
3561            KeyContext::from_when_clause("popup"),
3562            Some(KeyContext::Popup)
3563        );
3564        assert_eq!(KeyContext::from_when_clause("help"), None);
3565        assert_eq!(KeyContext::from_when_clause("  help  "), None); // Test trimming
3566        assert_eq!(KeyContext::from_when_clause("unknown"), None);
3567        assert_eq!(KeyContext::from_when_clause(""), None);
3568    }
3569
3570    #[test]
3571    fn test_key_context_to_when_clause() {
3572        assert_eq!(KeyContext::Normal.to_when_clause(), "normal");
3573        assert_eq!(KeyContext::Prompt.to_when_clause(), "prompt");
3574        assert_eq!(KeyContext::SearchPrompt.to_when_clause(), "searchPrompt");
3575        assert_eq!(KeyContext::Popup.to_when_clause(), "popup");
3576        // Round-trips through from_when_clause.
3577        assert_eq!(
3578            KeyContext::from_when_clause(&KeyContext::SearchPrompt.to_when_clause()),
3579            Some(KeyContext::SearchPrompt)
3580        );
3581    }
3582
3583    #[test]
3584    fn test_context_specific_bindings() {
3585        let config = Config::default();
3586        let resolver = KeybindingResolver::new(&config);
3587
3588        // Test prompt context bindings
3589        let enter_event = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty());
3590        assert_eq!(
3591            resolver.resolve(&enter_event, KeyContext::Prompt),
3592            Action::PromptConfirm
3593        );
3594        assert_eq!(
3595            resolver.resolve(&enter_event, KeyContext::Normal),
3596            Action::InsertNewline
3597        );
3598
3599        // Test popup context bindings
3600        let up_event = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
3601        assert_eq!(
3602            resolver.resolve(&up_event, KeyContext::Popup),
3603            Action::PopupSelectPrev
3604        );
3605        assert_eq!(
3606            resolver.resolve(&up_event, KeyContext::Normal),
3607            Action::MoveUp
3608        );
3609    }
3610
3611    #[test]
3612    fn test_search_prompt_owns_toggles_and_inherits_prompt() {
3613        let config = Config::default();
3614        let resolver = KeybindingResolver::new(&config);
3615
3616        let alt_w = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT);
3617
3618        // Alt+W toggles whole-word ONLY in the SearchPrompt context.
3619        assert_eq!(
3620            resolver.resolve(&alt_w, KeyContext::SearchPrompt),
3621            Action::ToggleSearchWholeWord,
3622        );
3623        // In the broad Prompt context (e.g. the save/discard/cancel close
3624        // confirmation) Alt+W must NOT toggle whole-word — that was the bug.
3625        assert_ne!(
3626            resolver.resolve(&alt_w, KeyContext::Prompt),
3627            Action::ToggleSearchWholeWord,
3628        );
3629
3630        // The other match-mode toggles are likewise SearchPrompt-only.
3631        let alt_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT);
3632        let alt_r = KeyEvent::new(KeyCode::Char('r'), KeyModifiers::ALT);
3633        assert_eq!(
3634            resolver.resolve(&alt_c, KeyContext::SearchPrompt),
3635            Action::ToggleSearchCaseSensitive,
3636        );
3637        assert_eq!(
3638            resolver.resolve(&alt_r, KeyContext::SearchPrompt),
3639            Action::ToggleSearchRegex,
3640        );
3641
3642        // SearchPrompt inherits all generic editing/navigation keys from its
3643        // parent Prompt context (Enter confirms, Esc cancels, text types).
3644        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty());
3645        assert_eq!(
3646            resolver.resolve(&enter, KeyContext::SearchPrompt),
3647            resolver.resolve(&enter, KeyContext::Prompt),
3648        );
3649        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::empty());
3650        assert_eq!(
3651            resolver.resolve(&esc, KeyContext::SearchPrompt),
3652            resolver.resolve(&esc, KeyContext::Prompt),
3653        );
3654        assert_eq!(
3655            resolver.resolve(
3656                &KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty()),
3657                KeyContext::SearchPrompt,
3658            ),
3659            Action::InsertChar('x'),
3660        );
3661    }
3662
3663    #[test]
3664    fn test_context_fallback_to_normal() {
3665        let config = Config::default();
3666        let resolver = KeybindingResolver::new(&config);
3667
3668        // Ctrl+S should work in all contexts (falls back to normal)
3669        let save_event = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL);
3670        assert_eq!(
3671            resolver.resolve(&save_event, KeyContext::Normal),
3672            Action::Save
3673        );
3674        assert_eq!(
3675            resolver.resolve(&save_event, KeyContext::Popup),
3676            Action::Save
3677        );
3678        // Note: Prompt context might handle this differently in practice
3679    }
3680
3681    #[test]
3682    fn test_context_priority_resolution() {
3683        use crate::config::Keybinding;
3684
3685        // Create a config with a custom binding that overrides default in help context
3686        let mut config = Config::default();
3687        config.keybindings.push(Keybinding {
3688            key: "esc".to_string(),
3689            modifiers: vec![],
3690            keys: vec![],
3691            action: "quit".to_string(), // Override Esc in popup context to quit
3692            args: HashMap::new(),
3693            when: Some("popup".to_string()),
3694        });
3695
3696        let resolver = KeybindingResolver::new(&config);
3697        let esc_event = KeyEvent::new(KeyCode::Esc, KeyModifiers::empty());
3698
3699        // In popup context, custom binding should override default PopupCancel
3700        assert_eq!(
3701            resolver.resolve(&esc_event, KeyContext::Popup),
3702            Action::Quit
3703        );
3704
3705        // In normal context, should still be RemoveSecondaryCursors
3706        assert_eq!(
3707            resolver.resolve(&esc_event, KeyContext::Normal),
3708            Action::RemoveSecondaryCursors
3709        );
3710    }
3711
3712    #[test]
3713    fn test_character_input_in_contexts() {
3714        let config = Config::default();
3715        let resolver = KeybindingResolver::new(&config);
3716
3717        let char_event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty());
3718
3719        // Character input should work in Normal and Prompt contexts
3720        assert_eq!(
3721            resolver.resolve(&char_event, KeyContext::Normal),
3722            Action::InsertChar('a')
3723        );
3724        assert_eq!(
3725            resolver.resolve(&char_event, KeyContext::Prompt),
3726            Action::InsertChar('a')
3727        );
3728
3729        // But not in Popup contexts (returns None)
3730        assert_eq!(
3731            resolver.resolve(&char_event, KeyContext::Popup),
3732            Action::None
3733        );
3734    }
3735
3736    #[test]
3737    fn test_custom_keybinding_loading() {
3738        use crate::config::Keybinding;
3739
3740        let mut config = Config::default();
3741
3742        // Add a custom keybinding for normal context
3743        config.keybindings.push(Keybinding {
3744            key: "f".to_string(),
3745            modifiers: vec!["ctrl".to_string()],
3746            keys: vec![],
3747            action: "command_palette".to_string(),
3748            args: HashMap::new(),
3749            when: None, // Default to normal context
3750        });
3751
3752        let resolver = KeybindingResolver::new(&config);
3753
3754        // Test normal context custom binding
3755        let ctrl_f = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL);
3756        assert_eq!(
3757            resolver.resolve(&ctrl_f, KeyContext::Normal),
3758            Action::CommandPalette
3759        );
3760
3761        // Test prompt context custom binding
3762        let ctrl_k = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL);
3763        assert_eq!(
3764            resolver.resolve(&ctrl_k, KeyContext::Prompt),
3765            Action::PromptDeleteToLineEnd
3766        );
3767        assert_eq!(
3768            resolver.resolve(&ctrl_k, KeyContext::Normal),
3769            Action::DeleteToLineEnd
3770        );
3771    }
3772
3773    #[test]
3774    fn test_all_context_default_bindings_exist() {
3775        let config = Config::default();
3776        let resolver = KeybindingResolver::new(&config);
3777
3778        // Verify that default bindings exist for all contexts
3779        assert!(resolver.default_bindings.contains_key(&KeyContext::Normal));
3780        assert!(resolver.default_bindings.contains_key(&KeyContext::Prompt));
3781        assert!(resolver.default_bindings.contains_key(&KeyContext::Popup));
3782        assert!(resolver
3783            .default_bindings
3784            .contains_key(&KeyContext::FileExplorer));
3785        assert!(resolver.default_bindings.contains_key(&KeyContext::Menu));
3786
3787        // Verify each context has some bindings
3788        assert!(!resolver.default_bindings[&KeyContext::Normal].is_empty());
3789        assert!(!resolver.default_bindings[&KeyContext::Prompt].is_empty());
3790        assert!(!resolver.default_bindings[&KeyContext::Popup].is_empty());
3791        assert!(!resolver.default_bindings[&KeyContext::FileExplorer].is_empty());
3792        assert!(!resolver.default_bindings[&KeyContext::Menu].is_empty());
3793    }
3794
3795    /// Validate that every action name in every built-in keymap resolves to a
3796    /// known built-in action, not a `PluginAction`.  This catches typos like
3797    /// `"prompt_delete_to_end"` (should be `"prompt_delete_to_line_end"`).
3798    ///
3799    /// A built-in keymap may also legitimately bind to a plugin action when
3800    /// the plugin ships with the editor and the host has no native equivalent
3801    /// (e.g. Alt+A → search/replace, see §10 of
3802    /// docs/internal/search-replace-scope-replan-on-widgets.md). Such actions
3803    /// are listed in `ALLOWED_PLUGIN_ACTIONS_IN_DEFAULTS` below; any new
3804    /// addition must be added there explicitly so genuine typos still fail.
3805    #[test]
3806    fn test_all_builtin_keymaps_have_valid_action_names() {
3807        let known_actions: std::collections::HashSet<String> =
3808            Action::all_action_names().into_iter().collect();
3809
3810        const ALLOWED_PLUGIN_ACTIONS_IN_DEFAULTS: &[&str] = &[
3811            "start_search_replace",
3812            // Universal Search scope toggles — handled by the live_grep
3813            // plugin; dispatched as plugin actions from the prompt context.
3814            "live_grep_toggle_files",
3815            "live_grep_toggle_ignored",
3816            "live_grep_toggle_buffers",
3817            "live_grep_toggle_terminals",
3818            "live_grep_toggle_diagnostics",
3819            "live_grep_toggle_word",
3820            "live_grep_toggle_regex",
3821            // Export current Live Grep results to the Quickfix dock panel
3822            // — handled by the live_grep plugin (Finder panel), dispatched
3823            // as a plugin action from the prompt context.
3824            "live_grep_export_quickfix",
3825        ];
3826
3827        let config = Config::default();
3828
3829        for map_name in crate::config::KeybindingMapName::BUILTIN_OPTIONS {
3830            let bindings = config.resolve_keymap(map_name);
3831            for binding in &bindings {
3832                let is_known_builtin = known_actions.contains(&binding.action);
3833                let is_allowed_plugin =
3834                    ALLOWED_PLUGIN_ACTIONS_IN_DEFAULTS.contains(&binding.action.as_str());
3835                assert!(
3836                    is_known_builtin || is_allowed_plugin,
3837                    "Keymap '{}' contains unknown action '{}' (key: '{}', when: {:?}). \
3838                     This will be treated as a plugin action at runtime. \
3839                     Check for typos in the keymap JSON file, or add the action to \
3840                     ALLOWED_PLUGIN_ACTIONS_IN_DEFAULTS if it's an intentional \
3841                     plugin-action binding.",
3842                    map_name,
3843                    binding.action,
3844                    binding.key,
3845                    binding.when,
3846                );
3847            }
3848        }
3849    }
3850
3851    #[test]
3852    fn test_resolve_determinism() {
3853        // Property: Resolving the same key in the same context should always return the same action
3854        let config = Config::default();
3855        let resolver = KeybindingResolver::new(&config);
3856
3857        let test_cases = vec![
3858            (KeyCode::Left, KeyModifiers::empty(), KeyContext::Normal),
3859            (
3860                KeyCode::Esc,
3861                KeyModifiers::empty(),
3862                KeyContext::FileExplorer,
3863            ),
3864            (KeyCode::Enter, KeyModifiers::empty(), KeyContext::Prompt),
3865            (KeyCode::Down, KeyModifiers::empty(), KeyContext::Popup),
3866        ];
3867
3868        for (key_code, modifiers, context) in test_cases {
3869            let event = KeyEvent::new(key_code, modifiers);
3870            let action1 = resolver.resolve(&event, context.clone());
3871            let action2 = resolver.resolve(&event, context.clone());
3872            let action3 = resolver.resolve(&event, context);
3873
3874            assert_eq!(action1, action2, "Resolve should be deterministic");
3875            assert_eq!(action2, action3, "Resolve should be deterministic");
3876        }
3877    }
3878
3879    #[test]
3880    fn test_modifier_combinations() {
3881        let config = Config::default();
3882        let resolver = KeybindingResolver::new(&config);
3883
3884        // Test that modifier combinations are distinguished correctly
3885        let char_s = KeyCode::Char('s');
3886
3887        let no_mod = KeyEvent::new(char_s, KeyModifiers::empty());
3888        let ctrl = KeyEvent::new(char_s, KeyModifiers::CONTROL);
3889        let shift = KeyEvent::new(char_s, KeyModifiers::SHIFT);
3890        let ctrl_shift = KeyEvent::new(char_s, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
3891
3892        let action_no_mod = resolver.resolve(&no_mod, KeyContext::Normal);
3893        let action_ctrl = resolver.resolve(&ctrl, KeyContext::Normal);
3894        let action_shift = resolver.resolve(&shift, KeyContext::Normal);
3895        let action_ctrl_shift = resolver.resolve(&ctrl_shift, KeyContext::Normal);
3896
3897        // These should all be different actions (or at least distinguishable)
3898        assert_eq!(action_no_mod, Action::InsertChar('s'));
3899        assert_eq!(action_ctrl, Action::Save);
3900        assert_eq!(action_shift, Action::InsertChar('s')); // Shift alone is still character input
3901                                                           // Ctrl+Shift+S is not bound by default, should return None
3902        assert_eq!(action_ctrl_shift, Action::None);
3903    }
3904
3905    #[test]
3906    fn test_scroll_keybindings() {
3907        let config = Config::default();
3908        let resolver = KeybindingResolver::new(&config);
3909
3910        // Test Ctrl+Up -> ScrollUp
3911        let ctrl_up = KeyEvent::new(KeyCode::Up, KeyModifiers::CONTROL);
3912        assert_eq!(
3913            resolver.resolve(&ctrl_up, KeyContext::Normal),
3914            Action::ScrollUp,
3915            "Ctrl+Up should resolve to ScrollUp"
3916        );
3917
3918        // Test Ctrl+Down -> ScrollDown
3919        let ctrl_down = KeyEvent::new(KeyCode::Down, KeyModifiers::CONTROL);
3920        assert_eq!(
3921            resolver.resolve(&ctrl_down, KeyContext::Normal),
3922            Action::ScrollDown,
3923            "Ctrl+Down should resolve to ScrollDown"
3924        );
3925    }
3926
3927    #[test]
3928    fn test_lsp_completion_keybinding() {
3929        let config = Config::default();
3930        let resolver = KeybindingResolver::new(&config);
3931
3932        // Test Ctrl+Space -> LspCompletion
3933        let ctrl_space = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL);
3934        assert_eq!(
3935            resolver.resolve(&ctrl_space, KeyContext::Normal),
3936            Action::LspCompletion,
3937            "Ctrl+Space should resolve to LspCompletion"
3938        );
3939    }
3940
3941    #[test]
3942    fn test_terminal_key_equivalents() {
3943        // Test that terminal_key_equivalents returns correct mappings
3944        let ctrl = KeyModifiers::CONTROL;
3945
3946        // Ctrl+/ <-> Ctrl+7
3947        let slash_equivs = terminal_key_equivalents(KeyCode::Char('/'), ctrl);
3948        assert_eq!(slash_equivs, vec![(KeyCode::Char('7'), ctrl)]);
3949
3950        let seven_equivs = terminal_key_equivalents(KeyCode::Char('7'), ctrl);
3951        assert_eq!(seven_equivs, vec![(KeyCode::Char('/'), ctrl)]);
3952
3953        // Ctrl+Backspace <-> Ctrl+H
3954        let backspace_equivs = terminal_key_equivalents(KeyCode::Backspace, ctrl);
3955        assert_eq!(backspace_equivs, vec![(KeyCode::Char('h'), ctrl)]);
3956
3957        let h_equivs = terminal_key_equivalents(KeyCode::Char('h'), ctrl);
3958        assert_eq!(h_equivs, vec![(KeyCode::Backspace, ctrl)]);
3959
3960        // No equivalents for regular keys
3961        let a_equivs = terminal_key_equivalents(KeyCode::Char('a'), ctrl);
3962        assert!(a_equivs.is_empty());
3963
3964        // No equivalents without Ctrl
3965        let slash_no_ctrl = terminal_key_equivalents(KeyCode::Char('/'), KeyModifiers::empty());
3966        assert!(slash_no_ctrl.is_empty());
3967    }
3968
3969    #[test]
3970    fn test_terminal_key_equivalents_auto_binding() {
3971        let config = Config::default();
3972        let resolver = KeybindingResolver::new(&config);
3973
3974        // Ctrl+/ should be bound to toggle_comment
3975        let ctrl_slash = KeyEvent::new(KeyCode::Char('/'), KeyModifiers::CONTROL);
3976        let action_slash = resolver.resolve(&ctrl_slash, KeyContext::Normal);
3977        assert_eq!(
3978            action_slash,
3979            Action::ToggleComment,
3980            "Ctrl+/ should resolve to ToggleComment"
3981        );
3982
3983        // Ctrl+7 should also be bound to toggle_comment (auto-generated equivalent)
3984        let ctrl_7 = KeyEvent::new(KeyCode::Char('7'), KeyModifiers::CONTROL);
3985        let action_7 = resolver.resolve(&ctrl_7, KeyContext::Normal);
3986        assert_eq!(
3987            action_7,
3988            Action::ToggleComment,
3989            "Ctrl+7 should resolve to ToggleComment (terminal equivalent of Ctrl+/)"
3990        );
3991    }
3992
3993    #[test]
3994    fn test_terminal_key_equivalents_normalization() {
3995        // This test verifies that all terminal key equivalents are correctly mapped
3996        // These mappings exist because terminals send different key codes for certain
3997        // key combinations due to historical terminal emulation reasons.
3998
3999        let ctrl = KeyModifiers::CONTROL;
4000
4001        // === Ctrl+/ <-> Ctrl+7 ===
4002        // Most terminals send Ctrl+7 (0x1F) when user presses Ctrl+/
4003        let slash_equivs = terminal_key_equivalents(KeyCode::Char('/'), ctrl);
4004        assert_eq!(
4005            slash_equivs,
4006            vec![(KeyCode::Char('7'), ctrl)],
4007            "Ctrl+/ should map to Ctrl+7"
4008        );
4009        let seven_equivs = terminal_key_equivalents(KeyCode::Char('7'), ctrl);
4010        assert_eq!(
4011            seven_equivs,
4012            vec![(KeyCode::Char('/'), ctrl)],
4013            "Ctrl+7 should map back to Ctrl+/"
4014        );
4015
4016        // === Ctrl+Backspace <-> Ctrl+H ===
4017        // Many terminals send Ctrl+H (0x08, ASCII backspace) for Ctrl+Backspace
4018        let backspace_equivs = terminal_key_equivalents(KeyCode::Backspace, ctrl);
4019        assert_eq!(
4020            backspace_equivs,
4021            vec![(KeyCode::Char('h'), ctrl)],
4022            "Ctrl+Backspace should map to Ctrl+H"
4023        );
4024        let h_equivs = terminal_key_equivalents(KeyCode::Char('h'), ctrl);
4025        assert_eq!(
4026            h_equivs,
4027            vec![(KeyCode::Backspace, ctrl)],
4028            "Ctrl+H should map back to Ctrl+Backspace"
4029        );
4030
4031        // === Ctrl+Space <-> Ctrl+@ ===
4032        // Ctrl+Space sends NUL (0x00), same as Ctrl+@
4033        let space_equivs = terminal_key_equivalents(KeyCode::Char(' '), ctrl);
4034        assert_eq!(
4035            space_equivs,
4036            vec![(KeyCode::Char('@'), ctrl)],
4037            "Ctrl+Space should map to Ctrl+@"
4038        );
4039        let at_equivs = terminal_key_equivalents(KeyCode::Char('@'), ctrl);
4040        assert_eq!(
4041            at_equivs,
4042            vec![(KeyCode::Char(' '), ctrl)],
4043            "Ctrl+@ should map back to Ctrl+Space"
4044        );
4045
4046        // === Ctrl+- <-> Ctrl+_ ===
4047        // Ctrl+- and Ctrl+_ both send 0x1F in some terminals
4048        let minus_equivs = terminal_key_equivalents(KeyCode::Char('-'), ctrl);
4049        assert_eq!(
4050            minus_equivs,
4051            vec![(KeyCode::Char('_'), ctrl)],
4052            "Ctrl+- should map to Ctrl+_"
4053        );
4054        let underscore_equivs = terminal_key_equivalents(KeyCode::Char('_'), ctrl);
4055        assert_eq!(
4056            underscore_equivs,
4057            vec![(KeyCode::Char('-'), ctrl)],
4058            "Ctrl+_ should map back to Ctrl+-"
4059        );
4060
4061        // === No equivalents for regular keys ===
4062        assert!(
4063            terminal_key_equivalents(KeyCode::Char('a'), ctrl).is_empty(),
4064            "Ctrl+A should have no terminal equivalents"
4065        );
4066        assert!(
4067            terminal_key_equivalents(KeyCode::Char('z'), ctrl).is_empty(),
4068            "Ctrl+Z should have no terminal equivalents"
4069        );
4070        assert!(
4071            terminal_key_equivalents(KeyCode::Enter, ctrl).is_empty(),
4072            "Ctrl+Enter should have no terminal equivalents"
4073        );
4074
4075        // === No equivalents without Ctrl modifier ===
4076        assert!(
4077            terminal_key_equivalents(KeyCode::Char('/'), KeyModifiers::empty()).is_empty(),
4078            "/ without Ctrl should have no equivalents"
4079        );
4080        assert!(
4081            terminal_key_equivalents(KeyCode::Char('7'), KeyModifiers::SHIFT).is_empty(),
4082            "Shift+7 should have no equivalents"
4083        );
4084        assert!(
4085            terminal_key_equivalents(KeyCode::Char('h'), KeyModifiers::ALT).is_empty(),
4086            "Alt+H should have no equivalents"
4087        );
4088
4089        // === Ctrl+H only maps to Backspace when ONLY Ctrl is pressed ===
4090        // Ctrl+Shift+H or Ctrl+Alt+H should NOT map to Backspace
4091        let ctrl_shift = KeyModifiers::CONTROL | KeyModifiers::SHIFT;
4092        let ctrl_shift_h_equivs = terminal_key_equivalents(KeyCode::Char('h'), ctrl_shift);
4093        assert!(
4094            ctrl_shift_h_equivs.is_empty(),
4095            "Ctrl+Shift+H should NOT map to Ctrl+Shift+Backspace"
4096        );
4097    }
4098
4099    #[test]
4100    fn test_no_duplicate_keybindings_in_keymaps() {
4101        // Load all keymaps and check for duplicate bindings within the same context
4102        // A duplicate is when the same key+modifiers+context is defined more than once
4103        use std::collections::HashMap;
4104
4105        let keymaps: &[(&str, &str)] = &[
4106            ("default", include_str!("../../keymaps/default.json")),
4107            ("macos", include_str!("../../keymaps/macos.json")),
4108        ];
4109
4110        for (keymap_name, json_content) in keymaps {
4111            let keymap: crate::config::KeymapConfig = serde_json::from_str(json_content)
4112                .unwrap_or_else(|e| panic!("Failed to parse keymap '{}': {}", keymap_name, e));
4113
4114            // Track seen bindings per context: (key, modifiers, context) -> action
4115            let mut seen: HashMap<(String, Vec<String>, String), String> = HashMap::new();
4116            let mut duplicates: Vec<String> = Vec::new();
4117
4118            for binding in &keymap.bindings {
4119                let when = binding.when.clone().unwrap_or_default();
4120                let key_id = (binding.key.clone(), binding.modifiers.clone(), when.clone());
4121
4122                if let Some(existing_action) = seen.get(&key_id) {
4123                    duplicates.push(format!(
4124                        "Duplicate in '{}': key='{}', modifiers={:?}, when='{}' -> '{}' vs '{}'",
4125                        keymap_name,
4126                        binding.key,
4127                        binding.modifiers,
4128                        when,
4129                        existing_action,
4130                        binding.action
4131                    ));
4132                } else {
4133                    seen.insert(key_id, binding.action.clone());
4134                }
4135            }
4136
4137            assert!(
4138                duplicates.is_empty(),
4139                "Found duplicate keybindings:\n{}",
4140                duplicates.join("\n")
4141            );
4142        }
4143    }
4144
4145    /// `reload_from_config` rebuilds config-derived bindings but must preserve
4146    /// plugin-contributed runtime state — single keys, chords, and the
4147    /// inheriting-modes set — that does not live in config (issue #2307).
4148    #[test]
4149    fn test_reload_from_config_preserves_plugin_state() {
4150        let config = Config::default();
4151        let mut resolver = KeybindingResolver::new(&config);
4152
4153        let mode_ctx = KeyContext::Mode("test-plugin-mode".to_string());
4154        let single_action = Action::PluginAction("test-plugin.single".to_string());
4155        let chord_action = Action::PluginAction("test-plugin.chord".to_string());
4156
4157        // Simulate a plugin's defineMode(): a single key, a chord, and a
4158        // request to inherit Normal bindings.
4159        resolver.load_plugin_default(
4160            mode_ctx.clone(),
4161            KeyCode::Char('z'),
4162            KeyModifiers::NONE,
4163            single_action.clone(),
4164        );
4165        resolver.load_plugin_chord_default(
4166            mode_ctx.clone(),
4167            vec![
4168                (KeyCode::Char('g'), KeyModifiers::NONE),
4169                (KeyCode::Char('g'), KeyModifiers::NONE),
4170            ],
4171            chord_action.clone(),
4172        );
4173        resolver.set_mode_inherits_normal_bindings("test-plugin-mode", true);
4174
4175        // A config-triggered rebuild (e.g. switching the active keybinding map).
4176        resolver.reload_from_config(&config);
4177
4178        // Single-key plugin binding survives.
4179        let single_event = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE);
4180        assert_eq!(
4181            resolver.resolve(&single_event, mode_ctx.clone()),
4182            single_action,
4183            "single-key plugin binding must survive reload_from_config"
4184        );
4185
4186        // Chord plugin binding survives: feed the first `g` as chord state and
4187        // the second `g` as the new event to complete the `g g` sequence.
4188        let chord_prefix = [(KeyCode::Char('g'), KeyModifiers::NONE)];
4189        let second_g = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
4190        assert_eq!(
4191            resolver.resolve_chord(&chord_prefix, &second_g, mode_ctx.clone()),
4192            ChordResolution::Complete(chord_action),
4193            "chord plugin binding must survive reload_from_config"
4194        );
4195
4196        // The inheriting-modes set survives: an unbound key falls through to a
4197        // Normal binding (Left → MoveLeft) only when the mode inherits.
4198        let left = KeyEvent::new(KeyCode::Left, KeyModifiers::empty());
4199        assert_eq!(
4200            resolver.resolve(&left, mode_ctx),
4201            Action::MoveLeft,
4202            "inheriting-modes membership must survive reload_from_config"
4203        );
4204    }
4205}