Skip to main content

fresh/input/
keybindings.rs

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