Skip to main content

fresh/input/
keybindings.rs

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