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