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