Skip to main content

fresh/input/
keybindings.rs

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