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