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