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