Skip to main content

fresh/app/
input.rs

1use super::*;
2use anyhow::Result as AnyhowResult;
3use rust_i18n::t;
4
5/// Convert a crossterm `KeyEvent` into the `KeyEventPayload` shape
6/// delivered to plugin `editor.getNextKey()` callers.
7///
8/// `key` matches the naming used by `defineMode` bindings:
9///   - named keys are lowercase (`"escape"`, `"enter"`, `"tab"`,
10///     `"space"`, `"backspace"`, arrows, `"f1"`–`"f12"`, …)
11///   - printable characters are returned as-is (`"a"`, `"!"`, `" "`)
12///   - unsupported / unknown keys yield an empty `key` string
13fn key_event_to_payload(ev: &crossterm::event::KeyEvent) -> fresh_core::api::KeyEventPayload {
14    use crossterm::event::{KeyCode, KeyModifiers};
15    let key = match ev.code {
16        KeyCode::Char(c) => c.to_string(),
17        KeyCode::Esc => "escape".to_string(),
18        KeyCode::Enter => "enter".to_string(),
19        KeyCode::Tab => "tab".to_string(),
20        KeyCode::BackTab => "backtab".to_string(),
21        KeyCode::Backspace => "backspace".to_string(),
22        KeyCode::Delete => "delete".to_string(),
23        KeyCode::Left => "left".to_string(),
24        KeyCode::Right => "right".to_string(),
25        KeyCode::Up => "up".to_string(),
26        KeyCode::Down => "down".to_string(),
27        KeyCode::Home => "home".to_string(),
28        KeyCode::End => "end".to_string(),
29        KeyCode::PageUp => "pageup".to_string(),
30        KeyCode::PageDown => "pagedown".to_string(),
31        KeyCode::Insert => "insert".to_string(),
32        KeyCode::F(n) => format!("f{}", n),
33        _ => String::new(),
34    };
35    fresh_core::api::KeyEventPayload {
36        key,
37        ctrl: ev.modifiers.contains(KeyModifiers::CONTROL),
38        alt: ev.modifiers.contains(KeyModifiers::ALT),
39        shift: ev.modifiers.contains(KeyModifiers::SHIFT),
40        meta: ev.modifiers.contains(KeyModifiers::SUPER),
41    }
42}
43
44impl Editor {
45    /// If a plugin is awaiting the next keypress (via
46    /// `editor.getNextKey()`), resolve the front-most pending
47    /// callback with this key and return `true` so the caller can
48    /// short-circuit further dispatch. The key is consumed by the
49    /// resolution; mode bindings and editor actions do not see it.
50    ///
51    /// If no callback is pending but the plugin has declared key
52    /// capture active (`editor.beginKeyCapture()`), buffer the key
53    /// instead of dispatching it. The next `AwaitNextKey` will pop
54    /// from the buffer immediately. This closes the race between
55    /// fast typing/paste and the plugin re-arming `getNextKey`
56    /// between iterations.
57    fn try_resolve_next_key_callback(&mut self, key_event: &crossterm::event::KeyEvent) -> bool {
58        let payload = key_event_to_payload(key_event);
59        if let Some(callback_id) = self.pending_next_key_callbacks.pop_front() {
60            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
61            self.plugin_manager.resolve_callback(callback_id, json);
62            return true;
63        }
64        if self.key_capture_active {
65            self.pending_key_capture_buffer.push_back(payload);
66            return true;
67        }
68        false
69    }
70}
71
72impl Editor {
73    /// Install (or update) a Quickfix list inside the Utility Dock.
74    ///
75    /// One global Quickfix buffer per workspace, keyed by
76    /// `panel_id = "quickfix"`. Subsequent exports replace its
77    /// content. The buffer is parked in the dock leaf via
78    /// `role = "utility_dock"` so it shares the dock with diagnostics,
79    /// search-replace, etc. — exactly one bottom-strip leaf for all
80    /// dock-aware utilities.
81    fn install_quickfix_in_dock(
82        &mut self,
83        query: String,
84        matches: Vec<crate::services::live_grep_state::GrepMatch>,
85    ) {
86        use crate::model::event::SplitDirection;
87        use crate::primitives::text_property::TextPropertyEntry;
88        use crate::view::split::SplitRole;
89
90        // Build the buffer's text content. One match per line:
91        //   path:line:col  ⎯  context
92        let mut entries = Vec::with_capacity(matches.len() + 2);
93        let header = format!("Quickfix: {} ({} matches)\n", query, matches.len());
94        entries.push(TextPropertyEntry::text(header));
95        for m in &matches {
96            let line = format!("{}:{}:{}  {}\n", m.file, m.line, m.column, m.content.trim());
97            entries.push(TextPropertyEntry::text(line));
98        }
99
100        // If a Quickfix buffer already exists (panel_id "quickfix"),
101        // update its content in place. Otherwise create one.
102        let panel_key = "quickfix".to_string();
103        if let Some(&existing) = self.panel_ids.get(&panel_key) {
104            if self.buffers.contains_key(&existing) {
105                if let Err(e) = self.set_virtual_buffer_content(existing, entries) {
106                    tracing::error!("Failed to update quickfix buffer: {}", e);
107                    return;
108                }
109                // Make sure the dock displays the quickfix buffer.
110                if let Some(dock_leaf) =
111                    self.split_manager.find_leaf_by_role(SplitRole::UtilityDock)
112                {
113                    self.split_manager.set_active_split(dock_leaf);
114                    self.set_pane_buffer(dock_leaf, existing);
115                }
116                self.set_status_message(format!("Quickfix updated: {} matches", matches.len()));
117                return;
118            }
119            // Stale entry — remove and fall through to create.
120            self.panel_ids.remove(&panel_key);
121        }
122
123        // Create the virtual buffer detached — `create_virtual_buffer`
124        // would add the buffer as a tab to whatever the currently
125        // active split is (the user's editor pane), and we'd then
126        // have to clean up that phantom tab after moving the buffer
127        // to the dock. Detached creation skips the phantom entirely.
128        let buffer_id = self.create_virtual_buffer_detached(
129            "*Quickfix*".to_string(),
130            "quickfix-list".to_string(),
131            true,
132        );
133        if let Some(state) = self.buffers.get_mut(&buffer_id) {
134            state.margins.configure_for_line_numbers(false);
135            state.show_cursors = true;
136            state.editing_disabled = true;
137        }
138        self.panel_ids.insert(panel_key, buffer_id);
139        if let Err(e) = self.set_virtual_buffer_content(buffer_id, entries) {
140            tracing::error!("Failed to set quickfix buffer content: {}", e);
141            return;
142        }
143
144        // Place the buffer in the dock — reuse the existing dock leaf
145        // if any; otherwise create one at the bottom (horizontal,
146        // ratio 0.3) and tag it as the dock.
147        if let Some(dock_leaf) = self.split_manager.find_leaf_by_role(SplitRole::UtilityDock) {
148            self.split_manager.set_active_split(dock_leaf);
149            self.set_pane_buffer(dock_leaf, buffer_id);
150            // The buffer was created detached, so its per-split view
151            // state hasn't been initialized in the dock's view_state
152            // yet. set_pane_buffer adds the buffer as a tab and sets
153            // it active; we still need to apply defaults for line
154            // numbers / wrap / rulers / etc. to its keyed state.
155            // Resolve config values up front so the &self lookups
156            // don't conflict with the &mut view_state borrow.
157            let line_numbers = self.config.editor.line_numbers;
158            let highlight_current_line = self.config.editor.highlight_current_line;
159            let line_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
160            let wrap_indent = self.config.editor.wrap_indent;
161            let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
162            let rulers = self.config.editor.rulers.clone();
163            if let Some(view_state) = self.split_view_states.get_mut(&dock_leaf) {
164                let buf_state = view_state.ensure_buffer_state(buffer_id);
165                buf_state.apply_config_defaults(
166                    line_numbers,
167                    highlight_current_line,
168                    line_wrap,
169                    wrap_indent,
170                    wrap_column,
171                    rulers,
172                );
173                buf_state.show_line_numbers = false;
174            }
175        } else {
176            // Split at the root so the dock spans the full width
177            // below any pre-existing side-by-side panes.
178            match self.split_manager.split_root_positioned(
179                SplitDirection::Horizontal,
180                buffer_id,
181                0.7,
182                false, /* place dock after = bottom */
183            ) {
184                Ok(new_leaf) => {
185                    let mut view_state = crate::view::split::SplitViewState::with_buffer(
186                        self.terminal_width,
187                        self.terminal_height,
188                        buffer_id,
189                    );
190                    view_state.apply_config_defaults(
191                        self.config.editor.line_numbers,
192                        self.config.editor.highlight_current_line,
193                        self.resolve_line_wrap_for_buffer(buffer_id),
194                        self.config.editor.wrap_indent,
195                        self.resolve_wrap_column_for_buffer(buffer_id),
196                        self.config.editor.rulers.clone(),
197                    );
198                    view_state.ensure_buffer_state(buffer_id).show_line_numbers = false;
199                    self.split_view_states.insert(new_leaf, view_state);
200                    self.split_manager
201                        .set_leaf_role(new_leaf, Some(SplitRole::UtilityDock));
202                    self.split_manager.set_active_split(new_leaf);
203                }
204                Err(e) => {
205                    tracing::error!("Failed to create dock split for quickfix: {}", e);
206                    return;
207                }
208            }
209        }
210
211        self.set_status_message(format!(
212            "Quickfix exported: {} matches in dock",
213            matches.len()
214        ));
215    }
216
217    /// Whether editor-pane popups (LSP completion, hover, signature help,
218    /// global plugin popups, …) should intercept keyboard input.
219    ///
220    /// Returns `false` when:
221    ///   - the user has focus on the file explorer pane (popups belong
222    ///     to the editor pane, and the explorer must own its own
223    ///     keystrokes), or
224    ///   - the topmost visible popup is unfocused (LSP popups appear
225    ///     unfocused so they don't silently swallow the next keystroke;
226    ///     the user grabs focus explicitly with `popup_focus`,
227    ///     default `Alt+T`).
228    ///
229    /// Buffer-switch handlers (e.g. `open_file_preview`) clear stale
230    /// popups so a popup tied to the previous preview doesn't follow the
231    /// user across buffers.
232    ///
233    /// Single source of truth for both `get_key_context` (binding resolution)
234    /// and `dispatch_modal_input` (handler routing) so the two cannot drift.
235    pub(crate) fn popups_capture_keys(&self) -> bool {
236        use crate::input::keybindings::KeyContext;
237        if matches!(self.key_context, KeyContext::FileExplorer) {
238            return false;
239        }
240        self.topmost_popup_focused()
241    }
242
243    /// Whether the topmost visible popup (global stack first, then the
244    /// active buffer's stack) has been marked focused. Returns `false`
245    /// when no popup is visible — the caller is responsible for
246    /// short-circuiting that case.
247    pub(crate) fn topmost_popup_focused(&self) -> bool {
248        if let Some(popup) = self.global_popups.top() {
249            return popup.focused;
250        }
251        if let Some(popup) = self.active_state().popups.top() {
252            return popup.focused;
253        }
254        // No popup → no capture. Returning `false` here is safe because
255        // every caller gates on visibility before reaching this path.
256        false
257    }
258
259    /// When an *unfocused* popup is on screen, resolve the key event
260    /// against `KeyContext::Popup`/`Global` so the user's bound
261    /// `popup_cancel` (default Esc) and `popup_focus` (default Alt+T)
262    /// keys still take effect even though the popup isn't claiming the
263    /// keyboard. Without this, dismissing an LSP auto-prompt with Esc
264    /// would silently fall through to the buffer.
265    ///
266    /// Returns `None` for any other action so type-to-filter, cursor
267    /// motion, etc. continue to drive the buffer.
268    pub(crate) fn resolve_unfocused_popup_action(
269        &self,
270        event: &crossterm::event::KeyEvent,
271    ) -> Option<crate::input::keybindings::Action> {
272        use crate::input::keybindings::{Action, KeyContext};
273
274        let popup_visible =
275            self.global_popups.is_visible() || self.active_state().popups.is_visible();
276        if !popup_visible || self.topmost_popup_focused() {
277            return None;
278        }
279
280        // Higher-priority modal contexts (Settings, Menu, Prompt) own the
281        // keyboard regardless of whether a buffer popup happens to be
282        // visible underneath. Skip the unfocused-popup interception so
283        // pressing Esc in a settings dialog still closes the dialog
284        // rather than reaching past it to dismiss a stale popup.
285        if self.settings_state.as_ref().is_some_and(|s| s.visible)
286            || self.menu_state.active_menu.is_some()
287            || self.is_prompting()
288        {
289            return None;
290        }
291
292        let kb = self.keybindings.read().ok()?;
293
294        // `popup_focus` lives in the Normal/FileExplorer context defaults
295        // (not Global) so a user's own binding for the same key in those
296        // contexts wins at the same precedence level. If the resolution
297        // here returns anything other than `PopupFocus`, it's the user's
298        // override — let the normal dispatcher handle it. Don't claim
299        // `popup_cancel` from Normal because Normal's default `Esc`
300        // resolves to `remove_secondary_cursors`, which would shadow the
301        // popup-dismiss intent here.
302        let popup_focus_match = matches!(
303            kb.resolve_in_context_only(event, self.key_context.clone()),
304            Some(Action::PopupFocus),
305        );
306        if popup_focus_match {
307            return Some(Action::PopupFocus);
308        }
309
310        // Fall back to the Popup context for `popup_cancel`. Esc
311        // (the default `popup_cancel` binding) should still dismiss
312        // an unfocused popup even though the popup itself isn't
313        // claiming the keyboard — that matches every other popup-
314        // dismissal affordance in the editor.
315        let resolved_popup = kb.resolve_in_context_only(event, KeyContext::Popup);
316        match resolved_popup {
317            Some(action @ (Action::PopupCancel | Action::PopupFocus)) => Some(action),
318            _ => None,
319        }
320    }
321
322    /// Resolve a key event against `KeyContext::Completion` when the topmost
323    /// visible popup is a completion popup. Only `CompletionAccept` and
324    /// `CompletionDismiss` are recognised here — every other key falls
325    /// through to the popup's own handler so type-to-filter, navigation, and
326    /// the "any other key dismisses + passthrough" behaviours stay intact.
327    pub(crate) fn resolve_completion_popup_action(
328        &self,
329        event: &crossterm::event::KeyEvent,
330    ) -> Option<crate::input::keybindings::Action> {
331        use crate::input::keybindings::{Action, KeyContext};
332        use crate::view::popup::PopupKind;
333
334        let topmost_kind = if self.global_popups.is_visible() {
335            self.global_popups.top().map(|p| p.kind)
336        } else if self.active_state().popups.is_visible() {
337            self.active_state().popups.top().map(|p| p.kind)
338        } else {
339            None
340        };
341
342        if topmost_kind != Some(PopupKind::Completion) {
343            return None;
344        }
345
346        match self
347            .keybindings
348            .read()
349            .unwrap()
350            .resolve_in_context_only(event, KeyContext::Completion)
351        {
352            Some(action @ (Action::CompletionAccept | Action::CompletionDismiss)) => Some(action),
353            _ => None,
354        }
355    }
356
357    /// Determine the current keybinding context based on UI state
358    pub fn get_key_context(&self) -> crate::input::keybindings::KeyContext {
359        use crate::input::keybindings::KeyContext;
360
361        // Priority order: Settings > Menu > Prompt > Popup (only when
362        // editor-pane focused) > CompositeBuffer > Current context
363        // (FileExplorer or Normal).
364        if self.settings_state.as_ref().is_some_and(|s| s.visible) {
365            KeyContext::Settings
366        } else if self.menu_state.active_menu.is_some() {
367            KeyContext::Menu
368        } else if self.is_prompting() {
369            KeyContext::Prompt
370        } else if self.popups_capture_keys()
371            && (self.global_popups.is_visible() || self.active_state().popups.is_visible())
372        {
373            KeyContext::Popup
374        } else if self.is_composite_buffer(self.active_buffer()) {
375            KeyContext::CompositeBuffer
376        } else {
377            // Use the current context (can be FileExplorer or Normal)
378            self.key_context.clone()
379        }
380    }
381
382    /// Handle a key event and return whether it was handled
383    /// This is the central key handling logic used by both main.rs and tests
384    pub fn handle_key(
385        &mut self,
386        code: crossterm::event::KeyCode,
387        modifiers: crossterm::event::KeyModifiers,
388    ) -> AnyhowResult<()> {
389        use crate::input::keybindings::Action;
390
391        let _t_total = std::time::Instant::now();
392
393        tracing::trace!(
394            "Editor.handle_key: code={:?}, modifiers={:?}",
395            code,
396            modifiers
397        );
398
399        // Create key event for dispatch methods
400        let key_event = crossterm::event::KeyEvent::new(code, modifiers);
401
402        // Event debug dialog intercepts ALL key events before any other processing.
403        // This must be checked here (not just in main.rs/gui) so it works in
404        // client/server mode where handle_key is called directly.
405        if self.is_event_debug_active() {
406            self.handle_event_debug_input(&key_event);
407            return Ok(());
408        }
409
410        // Try terminal input dispatch first (handles terminal mode and re-entry)
411        if self.dispatch_terminal_input(&key_event).is_some() {
412            return Ok(());
413        }
414
415        // If a plugin is awaiting the next keypress (`editor.getNextKey()`),
416        // hand this key to the front-most pending callback and consume it.
417        // This must run before any other dispatch so the awaiting plugin —
418        // typically running a short input loop (flash labels, vi
419        // find-char/replace-char) — can drive its own state machine
420        // without binding every printable key in `defineMode`.
421        if self.try_resolve_next_key_callback(&key_event) {
422            return Ok(());
423        }
424
425        // Clear skip_ensure_visible flag so cursor becomes visible after key press
426        // (scroll actions will set it again if needed). Use the *effective*
427        // active split so this clears the flag on a focused buffer-group
428        // panel's own view state, not the group host's — without this, a
429        // scroll action in the panel (mouse scrollbar click, plugin
430        // scrollBufferToLine, etc.) sets `skip_ensure_visible` on the panel
431        // and subsequent key presses never clear it, so cursor motion stops
432        // scrolling the viewport.
433        let active_split = self.effective_active_split();
434        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
435            view_state.viewport.clear_skip_ensure_visible();
436        }
437
438        // Dismiss theme info popup on any key press
439        if self.theme_info_popup.is_some() {
440            self.theme_info_popup = None;
441        }
442
443        if self.file_explorer_context_menu.is_some() {
444            if let Some(result) = self.handle_file_explorer_context_menu_key(code, modifiers) {
445                return result;
446            }
447        }
448
449        // Determine the current context first
450        let mut context = self.get_key_context();
451
452        // Special case: Hover and Signature Help popups should be dismissed on any key press
453        // EXCEPT for Ctrl+C when the popup has a text selection (allow copy first).
454        //
455        // Fires for both focused and unfocused popups: an unfocused
456        // hover popup that floats over the buffer must still vanish when
457        // the user starts typing — otherwise it lingers indefinitely
458        // because no key event reaches it. The focused-popup path also
459        // covers the legacy case where a transient popup was given
460        // focus (e.g. via the focus-popup keybinding).
461        let popup_visible_on_screen =
462            self.global_popups.is_visible() || self.active_state().popups.is_visible();
463        if popup_visible_on_screen {
464            // Check if the current popup is transient (hover, signature help).
465            // Editor-level popups always take precedence over buffer popups
466            // when both are visible — they're effectively modal overlays.
467            let (is_transient_popup, has_selection) = {
468                let popup = self
469                    .global_popups
470                    .top()
471                    .or_else(|| self.active_state().popups.top());
472                (
473                    popup.is_some_and(|p| p.transient),
474                    popup.is_some_and(|p| p.has_selection()),
475                )
476            };
477
478            // Don't dismiss if popup has selection and user is pressing Ctrl+C (let them copy first)
479            let is_copy_key = key_event.code == crossterm::event::KeyCode::Char('c')
480                && key_event
481                    .modifiers
482                    .contains(crossterm::event::KeyModifiers::CONTROL);
483
484            // Skip the dismiss when the user is *transferring* focus to
485            // the popup — otherwise pressing the focus-popup key while
486            // a transient popup is on screen would close the popup
487            // before its handler ever sees the focus action.
488            let resolved_action = self
489                .keybindings
490                .read()
491                .ok()
492                .map(|kb| kb.resolve(&key_event, context.clone()));
493            let is_focus_popup_key = matches!(
494                resolved_action,
495                Some(crate::input::keybindings::Action::PopupFocus)
496            );
497
498            if is_transient_popup && !(has_selection && is_copy_key) && !is_focus_popup_key {
499                // Dismiss the popup on any key press (except Ctrl+C with selection)
500                self.hide_popup();
501                tracing::debug!("Dismissed transient popup on key press");
502                // Recalculate context now that popup is gone
503                context = self.get_key_context();
504            }
505        }
506
507        // Unfocused popup control: even though an unfocused popup
508        // doesn't claim the keyboard, the user's bound popup-cancel
509        // (default Esc) and popup-focus (default Alt+T) keys must
510        // still affect it. Resolved here, *before* the modal
511        // dispatcher routes the key to the buffer/explorer/etc.
512        if let Some(action) = self.resolve_unfocused_popup_action(&key_event) {
513            self.handle_action(action)?;
514            return Ok(());
515        }
516
517        // Try hierarchical modal input dispatch first (Settings, Menu, Prompt, Popup)
518        if self.dispatch_modal_input(&key_event).is_some() {
519            return Ok(());
520        }
521
522        // If a modal was dismissed (e.g., completion popup closed and returned Ignored),
523        // recalculate the context so the key is processed in the correct context.
524        if context != self.get_key_context() {
525            context = self.get_key_context();
526        }
527
528        // Only check buffer mode keybindings when the editor buffer has focus.
529        // FileExplorer, Menu, Prompt, Popup contexts should not trigger mode bindings
530        // (e.g. markdown-source's Enter handler should not fire while the explorer is focused).
531        let should_check_mode_bindings =
532            matches!(context, crate::input::keybindings::KeyContext::Normal);
533
534        if should_check_mode_bindings {
535            // effective_mode() returns buffer-local mode if present, else global mode.
536            // This ensures virtual buffer modes aren't hijacked by global modes.
537            let effective_mode = self.effective_mode().map(|s| s.to_owned());
538
539            if let Some(ref mode_name) = effective_mode {
540                let mode_ctx = crate::input::keybindings::KeyContext::Mode(mode_name.to_string());
541                let key_event = crossterm::event::KeyEvent::new(code, modifiers);
542
543                // Mode chord resolution (via KeybindingResolver)
544                let (chord_result, resolved_action) = {
545                    let keybindings = self.keybindings.read().unwrap();
546                    let chord_result =
547                        keybindings.resolve_chord(&self.chord_state, &key_event, mode_ctx.clone());
548                    let resolved = keybindings.resolve(&key_event, mode_ctx);
549                    (chord_result, resolved)
550                };
551                match chord_result {
552                    crate::input::keybindings::ChordResolution::Complete(action) => {
553                        tracing::debug!("Mode chord resolved to action: {:?}", action);
554                        self.chord_state.clear();
555                        return self.handle_action(action);
556                    }
557                    crate::input::keybindings::ChordResolution::Partial => {
558                        tracing::debug!("Potential chord prefix in mode '{}'", mode_name);
559                        self.chord_state.push((code, modifiers));
560                        return Ok(());
561                    }
562                    crate::input::keybindings::ChordResolution::NoMatch => {
563                        if !self.chord_state.is_empty() {
564                            tracing::debug!("Chord sequence abandoned in mode, clearing state");
565                            self.chord_state.clear();
566                        }
567                    }
568                }
569
570                // Mode single-key resolution (custom > keymap > plugin defaults)
571                if resolved_action != Action::None {
572                    return self.handle_action(resolved_action);
573                }
574            }
575
576            // Handle unbound keys for modes that want to capture input.
577            //
578            // Buffer-local modes with allow_text_input (e.g. search-replace-list)
579            // capture character keys and block other unbound keys.
580            //
581            // Buffer-local modes WITHOUT allow_text_input (e.g. diff-view) let
582            // unbound keys fall through to normal keybinding handling so that
583            // Ctrl+C, arrows, etc. still work.
584            //
585            // Global editor modes (e.g. vi-normal) block all unbound keys when
586            // read-only.
587            if let Some(ref mode_name) = effective_mode {
588                if self.mode_registry.allows_text_input(mode_name) {
589                    if let KeyCode::Char(c) = code {
590                        let ch = if modifiers.contains(KeyModifiers::SHIFT) {
591                            c.to_uppercase().next().unwrap_or(c)
592                        } else {
593                            c
594                        };
595                        if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) {
596                            let action_name = format!("mode_text_input:{}", ch);
597                            return self.handle_action(Action::PluginAction(action_name));
598                        }
599                    }
600                    tracing::debug!("Blocking unbound key in text-input mode '{}'", mode_name);
601                    return Ok(());
602                }
603            }
604            if let Some(ref mode_name) = self.editor_mode {
605                if self.mode_registry.is_read_only(mode_name) {
606                    tracing::debug!("Ignoring unbound key in read-only mode '{}'", mode_name);
607                    return Ok(());
608                }
609                tracing::debug!(
610                    "Mode '{}' is not read-only, allowing key through",
611                    mode_name
612                );
613            }
614        }
615
616        // --- Composite buffer input routing ---
617        // If the active buffer is a composite buffer (side-by-side diff),
618        // route remaining composite-specific keys (scroll, pane switch, close)
619        // through CompositeInputRouter before falling through to regular
620        // keybinding resolution. Hunk navigation (n/p/]/[) is handled by the
621        // Action system via CompositeBuffer context bindings.
622        {
623            let active_buf = self.active_buffer();
624            let active_split = self.effective_active_split();
625            if self.is_composite_buffer(active_buf) {
626                if let Some(handled) =
627                    self.try_route_composite_key(active_split, active_buf, &key_event)
628                {
629                    return handled;
630                }
631            }
632        }
633
634        // Check for chord sequence matches first
635        let key_event = crossterm::event::KeyEvent::new(code, modifiers);
636        let (chord_result, action) = {
637            let keybindings = self.keybindings.read().unwrap();
638            let chord_result =
639                keybindings.resolve_chord(&self.chord_state, &key_event, context.clone());
640            let action = keybindings.resolve(&key_event, context.clone());
641            (chord_result, action)
642        };
643
644        match chord_result {
645            crate::input::keybindings::ChordResolution::Complete(action) => {
646                // Complete chord match - execute action and clear chord state
647                tracing::debug!("Complete chord match -> Action: {:?}", action);
648                self.chord_state.clear();
649                return self.handle_action(action);
650            }
651            crate::input::keybindings::ChordResolution::Partial => {
652                // Partial match - add to chord state and wait for more keys
653                tracing::debug!("Partial chord match - waiting for next key");
654                self.chord_state.push((code, modifiers));
655                return Ok(());
656            }
657            crate::input::keybindings::ChordResolution::NoMatch => {
658                // No chord match - clear state and try regular resolution
659                if !self.chord_state.is_empty() {
660                    tracing::debug!("Chord sequence abandoned, clearing state");
661                    self.chord_state.clear();
662                }
663            }
664        }
665
666        // Regular single-key resolution (already resolved above)
667        tracing::trace!("Context: {:?} -> Action: {:?}", context, action);
668
669        // Cancel pending LSP requests on user actions (except LSP actions themselves)
670        // This ensures stale completions don't show up after the user has moved on
671        match action {
672            Action::LspCompletion
673            | Action::LspGotoDefinition
674            | Action::LspReferences
675            | Action::LspHover
676            | Action::None => {
677                // Don't cancel for LSP actions or no-op
678            }
679            _ => {
680                // Cancel any pending LSP requests
681                self.cancel_pending_lsp_requests();
682            }
683        }
684
685        // Note: Modal components (Settings, Menu, Prompt, Popup, File Browser) are now
686        // handled by dispatch_modal_input using the InputHandler system.
687        // All remaining actions delegate to handle_action.
688        self.handle_action(action)
689    }
690
691    /// Handle an action (for normal mode and command execution).
692    /// Used by the app module internally and by the GUI module for native menu dispatch.
693    pub(crate) fn handle_action(&mut self, action: Action) -> AnyhowResult<()> {
694        use crate::input::keybindings::Action;
695
696        // Record action to macro if recording
697        self.record_macro_action(&action);
698
699        // Reset dabbrev cycling session on any non-dabbrev action.
700        if !matches!(action, Action::DabbrevExpand) {
701            self.reset_dabbrev_state();
702        }
703
704        match action {
705            Action::Quit => self.quit(),
706            Action::ForceQuit => {
707                self.should_quit = true;
708            }
709            Action::Detach => {
710                self.should_detach = true;
711            }
712            Action::Save => {
713                // Check if buffer has a file path - if not, redirect to SaveAs
714                if self.active_state().buffer.file_path().is_none() {
715                    self.start_prompt_with_initial_text(
716                        t!("file.save_as_prompt").to_string(),
717                        PromptType::SaveFileAs,
718                        String::new(),
719                    );
720                    self.init_file_open_state();
721                } else if self.check_save_conflict().is_some() {
722                    // Check if file was modified externally since we opened/saved it
723                    self.start_prompt(
724                        t!("file.file_changed_prompt").to_string(),
725                        PromptType::ConfirmSaveConflict,
726                    );
727                } else if let Err(e) = self.save() {
728                    let msg = format!("{}", e);
729                    self.status_message = Some(t!("file.save_failed", error = &msg).to_string());
730                }
731            }
732            Action::SaveAs => {
733                // Get current filename as default suggestion
734                let current_path = self
735                    .active_state()
736                    .buffer
737                    .file_path()
738                    .map(|p| {
739                        // Make path relative to working_dir if possible
740                        p.strip_prefix(&self.working_dir)
741                            .unwrap_or(p)
742                            .to_string_lossy()
743                            .to_string()
744                    })
745                    .unwrap_or_default();
746                self.start_prompt_with_initial_text(
747                    t!("file.save_as_prompt").to_string(),
748                    PromptType::SaveFileAs,
749                    current_path,
750                );
751                self.init_file_open_state();
752            }
753            Action::Open => {
754                self.start_prompt(t!("file.open_prompt").to_string(), PromptType::OpenFile);
755                self.prefill_open_file_prompt();
756                self.init_file_open_state();
757            }
758            Action::SwitchProject => {
759                self.start_prompt(
760                    t!("file.switch_project_prompt").to_string(),
761                    PromptType::SwitchProject,
762                );
763                self.init_folder_open_state();
764            }
765            Action::GotoLine => {
766                let has_line_index = self
767                    .buffers
768                    .get(&self.active_buffer())
769                    .is_none_or(|s| s.buffer.line_count().is_some());
770                if has_line_index {
771                    self.start_prompt(
772                        t!("file.goto_line_prompt").to_string(),
773                        PromptType::GotoLine,
774                    );
775                } else {
776                    self.start_prompt(
777                        t!("goto.scan_confirm_prompt", yes = "y", no = "N").to_string(),
778                        PromptType::GotoLineScanConfirm,
779                    );
780                }
781            }
782            Action::ScanLineIndex => {
783                self.start_incremental_line_scan(false);
784            }
785            Action::New => {
786                self.new_buffer();
787            }
788            Action::Close | Action::CloseTab => {
789                // Both Close and CloseTab use close_tab() which handles:
790                // - Closing the split if this is the last buffer and there are other splits
791                // - Prompting for unsaved changes
792                // - Properly closing the buffer
793                self.close_tab();
794            }
795            Action::Revert => {
796                // Check if buffer has unsaved changes - prompt for confirmation
797                if self.active_state().buffer.is_modified() {
798                    let revert_key = t!("prompt.key.revert").to_string();
799                    let cancel_key = t!("prompt.key.cancel").to_string();
800                    self.start_prompt(
801                        t!(
802                            "prompt.revert_confirm",
803                            revert_key = revert_key,
804                            cancel_key = cancel_key
805                        )
806                        .to_string(),
807                        PromptType::ConfirmRevert,
808                    );
809                } else {
810                    // No local changes, just revert
811                    if let Err(e) = self.revert_file() {
812                        self.set_status_message(
813                            t!("error.failed_to_revert", error = e.to_string()).to_string(),
814                        );
815                    }
816                }
817            }
818            Action::ToggleAutoRevert => {
819                self.toggle_auto_revert();
820            }
821            Action::FormatBuffer => {
822                if let Err(e) = self.format_buffer() {
823                    self.set_status_message(
824                        t!("error.format_failed", error = e.to_string()).to_string(),
825                    );
826                }
827            }
828            Action::TrimTrailingWhitespace => match self.trim_trailing_whitespace() {
829                Ok(true) => {
830                    self.set_status_message(t!("whitespace.trimmed").to_string());
831                }
832                Ok(false) => {
833                    self.set_status_message(t!("whitespace.no_trailing").to_string());
834                }
835                Err(e) => {
836                    self.set_status_message(
837                        t!("error.trim_whitespace_failed", error = e).to_string(),
838                    );
839                }
840            },
841            Action::EnsureFinalNewline => match self.ensure_final_newline() {
842                Ok(true) => {
843                    self.set_status_message(t!("whitespace.newline_added").to_string());
844                }
845                Ok(false) => {
846                    self.set_status_message(t!("whitespace.already_has_newline").to_string());
847                }
848                Err(e) => {
849                    self.set_status_message(
850                        t!("error.ensure_newline_failed", error = e).to_string(),
851                    );
852                }
853            },
854            Action::Copy => {
855                // Editor-level popups take precedence over everything, including the file explorer.
856                let popup = self
857                    .global_popups
858                    .top()
859                    .or_else(|| self.active_state().popups.top());
860                if let Some(popup) = popup {
861                    if popup.has_selection() {
862                        if let Some(text) = popup.get_selected_text() {
863                            self.clipboard.copy(text);
864                            self.set_status_message(t!("clipboard.copied").to_string());
865                            return Ok(());
866                        }
867                    }
868                }
869                if self.key_context == crate::input::keybindings::KeyContext::FileExplorer {
870                    self.file_explorer_copy();
871                    return Ok(());
872                }
873                // Check if active buffer is a composite buffer
874                let buffer_id = self.active_buffer();
875                if self.is_composite_buffer(buffer_id) {
876                    if let Some(_handled) = self.handle_composite_action(buffer_id, &Action::Copy) {
877                        return Ok(());
878                    }
879                }
880                self.copy_selection()
881            }
882            Action::CopyWithTheme(theme) => self.copy_selection_with_theme(&theme),
883            Action::CopyFilePath => self.copy_active_buffer_path(false),
884            Action::CopyRelativeFilePath => self.copy_active_buffer_path(true),
885            Action::Cut => {
886                if self.key_context == crate::input::keybindings::KeyContext::FileExplorer {
887                    self.file_explorer_cut();
888                    return Ok(());
889                }
890                if self.is_editing_disabled() {
891                    self.set_status_message(t!("buffer.editing_disabled").to_string());
892                    return Ok(());
893                }
894                self.cut_selection()
895            }
896            Action::Paste => {
897                if self.key_context == crate::input::keybindings::KeyContext::FileExplorer {
898                    self.file_explorer_paste();
899                    return Ok(());
900                }
901                if self.is_editing_disabled() {
902                    self.set_status_message(t!("buffer.editing_disabled").to_string());
903                    return Ok(());
904                }
905                self.paste()
906            }
907            Action::YankWordForward => self.yank_word_forward(),
908            Action::YankWordBackward => self.yank_word_backward(),
909            Action::YankToLineEnd => self.yank_to_line_end(),
910            Action::YankToLineStart => self.yank_to_line_start(),
911            Action::YankViWordEnd => self.yank_vi_word_end(),
912            Action::Undo => {
913                self.handle_undo();
914            }
915            Action::Redo => {
916                self.handle_redo();
917            }
918            Action::ShowHelp => {
919                self.open_help_manual();
920            }
921            Action::ShowKeyboardShortcuts => {
922                self.open_keyboard_shortcuts();
923            }
924            Action::ShowWarnings => {
925                self.show_warnings_popup();
926            }
927            Action::ShowStatusLog => {
928                self.open_status_log();
929            }
930            Action::ShowLspStatus => {
931                self.show_lsp_status_popup();
932            }
933            Action::ShowRemoteIndicatorMenu => {
934                self.show_remote_indicator_popup();
935            }
936            Action::ClearWarnings => {
937                self.clear_warnings();
938            }
939            Action::CommandPalette => {
940                // CommandPalette now delegates to QuickOpen (which starts with ">" prefix
941                // for command mode). Toggle if already open.
942                if let Some(prompt) = &self.prompt {
943                    if prompt.prompt_type == PromptType::QuickOpen {
944                        self.cancel_prompt();
945                        return Ok(());
946                    }
947                }
948                self.start_quick_open();
949            }
950            Action::QuickOpen => {
951                // Toggle Quick Open: close if already open, otherwise open it
952                if let Some(prompt) = &self.prompt {
953                    if prompt.prompt_type == PromptType::QuickOpen {
954                        self.cancel_prompt();
955                        return Ok(());
956                    }
957                }
958
959                // Start Quick Open with file suggestions (default mode)
960                self.start_quick_open();
961            }
962            Action::QuickOpenBuffers => {
963                if let Some(prompt) = &self.prompt {
964                    if prompt.prompt_type == PromptType::QuickOpen {
965                        self.cancel_prompt();
966                        return Ok(());
967                    }
968                }
969                self.start_quick_open_with_prefix("#");
970            }
971            Action::QuickOpenFiles => {
972                if let Some(prompt) = &self.prompt {
973                    if prompt.prompt_type == PromptType::QuickOpen {
974                        self.cancel_prompt();
975                        return Ok(());
976                    }
977                }
978                self.start_quick_open_with_prefix("");
979            }
980            Action::OpenLiveGrep => {
981                // Invoke the live_grep plugin's start_live_grep handler.
982                // This still produces the bottom-anchored Finder UI today
983                // — Phase 2/3 of issue #1796 will swap in the floating
984                // overlay rendering. The Action exists now so users get a
985                // direct keybinding (Alt+/) instead of palette-only access.
986                #[cfg(feature = "plugins")]
987                {
988                    if let Some(result) =
989                        self.plugin_manager.execute_action_async("start_live_grep")
990                    {
991                        match result {
992                            Ok(receiver) => {
993                                self.pending_plugin_actions
994                                    .push(("start_live_grep".to_string(), receiver));
995                            }
996                            Err(e) => {
997                                self.set_status_message(format!("Live Grep unavailable: {}", e));
998                            }
999                        }
1000                    } else {
1001                        self.set_status_message("Live Grep plugin not loaded".to_string());
1002                    }
1003                }
1004                #[cfg(not(feature = "plugins"))]
1005                {
1006                    self.set_status_message("Live Grep requires the plugins feature".to_string());
1007                }
1008            }
1009            Action::ResumeLiveGrep => {
1010                // Re-open Live Grep with the cached query and the
1011                // suggestions snapshot — does NOT re-run ripgrep
1012                // (issue #1796: "restore / re-show without re-running
1013                // the search"). If no cache exists, fall through to a
1014                // fresh Live Grep invocation.
1015                let cached = self.live_grep_last_state.clone();
1016                match cached {
1017                    Some(state) if state.cached_results.as_ref().is_some_and(|r| !r.is_empty()) => {
1018                        let results = state.cached_results.unwrap_or_default();
1019                        // Map cached GrepMatch records back into prompt
1020                        // Suggestions. The text is "file:line", the
1021                        // value carries "file:line:column" for the
1022                        // PromptType::LiveGrep confirm handler.
1023                        let suggestions: Vec<crate::input::commands::Suggestion> = results
1024                            .into_iter()
1025                            .map(|m| {
1026                                let label = format!("{}:{}", m.file, m.line);
1027                                let value = format!("{}:{}:{}", m.file, m.line, m.column);
1028                                let mut s = crate::input::commands::Suggestion::new(label);
1029                                s.description = Some(m.content);
1030                                s.value = Some(value);
1031                                s
1032                            })
1033                            .collect();
1034                        // Build the prompt directly so we can seed
1035                        // input + selection + suggestions in one shot.
1036                        // Label string mirrors the live_grep plugin's
1037                        // i18n bundle. Resume is core-driven (no
1038                        // plugin), so we hardcode rather than route
1039                        // through plugin-scoped translations.
1040                        let mut prompt = crate::view::prompt::Prompt::with_suggestions(
1041                            "Live grep: ".to_string(),
1042                            PromptType::LiveGrep,
1043                            suggestions,
1044                        );
1045                        prompt.input = state.query;
1046                        prompt.cursor_pos = prompt.input.len();
1047                        if let Some(idx) = state.selected_index {
1048                            if idx < prompt.suggestions.len() {
1049                                prompt.selected_suggestion = Some(idx);
1050                            }
1051                        }
1052                        prompt.suggestions_set_for_input = Some(prompt.input.clone());
1053                        // Render Resume in the floating overlay too.
1054                        prompt.overlay = true;
1055                        self.prompt = Some(prompt);
1056                    }
1057                    _ => {
1058                        // No cache — kick off a fresh Live Grep.
1059                        #[cfg(feature = "plugins")]
1060                        if let Some(result) =
1061                            self.plugin_manager.execute_action_async("start_live_grep")
1062                        {
1063                            match result {
1064                                Ok(receiver) => {
1065                                    self.pending_plugin_actions
1066                                        .push(("start_live_grep".to_string(), receiver));
1067                                }
1068                                Err(e) => {
1069                                    self.set_status_message(format!(
1070                                        "Live Grep unavailable: {}",
1071                                        e
1072                                    ));
1073                                }
1074                            }
1075                        }
1076                    }
1077                }
1078            }
1079            Action::LiveGrepExportQuickfix => {
1080                // Snapshot the current Live Grep prompt's suggestions
1081                // into a virtual buffer parked in the Utility Dock.
1082                // Active when the prompt is either PromptType::LiveGrep
1083                // or the live_grep plugin's Plugin{custom_type}.
1084                let is_grep = self
1085                    .prompt
1086                    .as_ref()
1087                    .map(|p| match &p.prompt_type {
1088                        PromptType::LiveGrep => true,
1089                        PromptType::Plugin { custom_type } => custom_type == "live-grep",
1090                        _ => false,
1091                    })
1092                    .unwrap_or(false);
1093                if !is_grep {
1094                    self.set_status_message(
1095                        "Quickfix export is only available inside Live Grep".to_string(),
1096                    );
1097                    return Ok(());
1098                }
1099                let (query, matches) = {
1100                    let prompt = self.prompt.as_ref().unwrap();
1101                    (
1102                        prompt.input.clone(),
1103                        self.snapshot_prompt_results_for_grep(prompt),
1104                    )
1105                };
1106                if matches.is_empty() {
1107                    self.set_status_message("No Live Grep results to export".to_string());
1108                    return Ok(());
1109                }
1110                // Dismiss the prompt before mutating split tree.
1111                self.cancel_prompt();
1112                // Hand off to the dock-installer.
1113                self.install_quickfix_in_dock(query, matches);
1114            }
1115            Action::ToggleUtilityDock => {
1116                use crate::view::split::SplitRole;
1117                if let Some(dock_leaf) =
1118                    self.split_manager.find_leaf_by_role(SplitRole::UtilityDock)
1119                {
1120                    let active = self.split_manager.active_split();
1121                    if active == dock_leaf {
1122                        // Already focused — no editor-leaf history yet,
1123                        // so just cycle to the next leaf via the
1124                        // existing Alt+] command. Phase 7 will track a
1125                        // proper "previous editor split" pointer.
1126                        self.next_split();
1127                    } else {
1128                        self.split_manager.set_active_split(dock_leaf);
1129                    }
1130                } else {
1131                    self.set_status_message(
1132                        "No Utility Dock open — invoke a dock-aware utility (Diagnostics, Search/Replace, …)"
1133                            .to_string(),
1134                    );
1135                }
1136            }
1137            Action::CycleLiveGrepProvider => {
1138                // Only meaningful while the Live Grep overlay is
1139                // open. Detect via prompt state — both
1140                // `PromptType::LiveGrep` (Resume's pre-seeded
1141                // overlay) and `Plugin{custom_type:"live-grep"}`
1142                // (the live-running plugin's prompt) qualify.
1143                let in_live_grep = self
1144                    .prompt
1145                    .as_ref()
1146                    .map(|p| match &p.prompt_type {
1147                        PromptType::LiveGrep => true,
1148                        PromptType::Plugin { custom_type } => custom_type == "live-grep",
1149                        _ => false,
1150                    })
1151                    .unwrap_or(false);
1152                if !in_live_grep {
1153                    self.set_status_message(
1154                        "Cycle Live Grep provider only works inside Live Grep".to_string(),
1155                    );
1156                    return Ok(());
1157                }
1158                #[cfg(feature = "plugins")]
1159                {
1160                    if let Some(result) = self
1161                        .plugin_manager
1162                        .execute_action_async("live_grep_cycle_provider")
1163                    {
1164                        match result {
1165                            Ok(receiver) => {
1166                                self.pending_plugin_actions
1167                                    .push(("live_grep_cycle_provider".to_string(), receiver));
1168                            }
1169                            Err(e) => {
1170                                self.set_status_message(format!("Live Grep cycle failed: {}", e));
1171                            }
1172                        }
1173                    } else {
1174                        self.set_status_message("Live Grep plugin not loaded".to_string());
1175                    }
1176                }
1177                #[cfg(not(feature = "plugins"))]
1178                {
1179                    self.set_status_message(
1180                        "Live Grep cycle requires the plugins feature".to_string(),
1181                    );
1182                }
1183            }
1184            Action::OpenTerminalInDock => {
1185                use crate::model::event::SplitDirection;
1186                use crate::view::split::SplitRole;
1187                if let Some(dock_leaf) =
1188                    self.split_manager.find_leaf_by_role(SplitRole::UtilityDock)
1189                {
1190                    // Existing dock — focus it and let the regular
1191                    // open_terminal path attach a new terminal tab.
1192                    self.split_manager.set_active_split(dock_leaf);
1193                    self.open_terminal();
1194                } else {
1195                    // No dock yet. Spawn the PTY first so we have a
1196                    // real terminal buffer to seed the new dock leaf
1197                    // with — otherwise the leaf would carry the
1198                    // user's previously-active buffer as a placeholder
1199                    // and that buffer would linger as a phantom tab in
1200                    // the dock alongside the terminal.
1201                    let Some(terminal_id) = self.spawn_terminal_session() else {
1202                        return Ok(());
1203                    };
1204                    let buffer_id = self.create_terminal_buffer_detached(terminal_id);
1205                    // Split at the root so the dock spans the full
1206                    // width below any pre-existing side-by-side panes.
1207                    match self.split_manager.split_root_positioned(
1208                        SplitDirection::Horizontal,
1209                        buffer_id,
1210                        0.7,
1211                        false,
1212                    ) {
1213                        Ok(new_leaf) => {
1214                            let mut view_state = crate::view::split::SplitViewState::with_buffer(
1215                                self.terminal_width,
1216                                self.terminal_height,
1217                                buffer_id,
1218                            );
1219                            view_state.apply_config_defaults(
1220                                self.config.editor.line_numbers,
1221                                self.config.editor.highlight_current_line,
1222                                self.resolve_line_wrap_for_buffer(buffer_id),
1223                                self.config.editor.wrap_indent,
1224                                self.resolve_wrap_column_for_buffer(buffer_id),
1225                                self.config.editor.rulers.clone(),
1226                            );
1227                            // Terminals don't wrap — keep escape
1228                            // sequences intact, mirroring the regular
1229                            // open_terminal path.
1230                            view_state.viewport.line_wrap_enabled = false;
1231                            self.split_view_states.insert(new_leaf, view_state);
1232                            self.split_manager
1233                                .set_leaf_role(new_leaf, Some(SplitRole::UtilityDock));
1234                            self.split_manager.set_active_split(new_leaf);
1235                            // Mirror open_terminal's post-attach
1236                            // bookkeeping. Skip set_active_buffer —
1237                            // the leaf already shows the terminal and
1238                            // its tab list contains only the terminal,
1239                            // exactly the desired final state.
1240                            self.terminal_mode = true;
1241                            self.key_context = crate::input::keybindings::KeyContext::Terminal;
1242                            self.resize_visible_terminals();
1243                            let exit_key = self
1244                                .keybindings
1245                                .read()
1246                                .unwrap()
1247                                .find_keybinding_for_action(
1248                                    "terminal_escape",
1249                                    crate::input::keybindings::KeyContext::Terminal,
1250                                )
1251                                .unwrap_or_else(|| "Ctrl+Space".to_string());
1252                            self.set_status_message(
1253                                rust_i18n::t!(
1254                                    "terminal.opened",
1255                                    id = terminal_id.0,
1256                                    exit_key = exit_key
1257                                )
1258                                .to_string(),
1259                            );
1260                            tracing::info!(
1261                                "Opened terminal {:?} into new dock leaf {:?} (buffer {:?})",
1262                                terminal_id,
1263                                new_leaf,
1264                                buffer_id
1265                            );
1266                        }
1267                        Err(e) => {
1268                            self.set_status_message(format!(
1269                                "Failed to create dock for terminal: {}",
1270                                e
1271                            ));
1272                            return Ok(());
1273                        }
1274                    }
1275                }
1276            }
1277            Action::ToggleLineWrap => {
1278                let new_value = !self.config.editor.line_wrap;
1279                self.config_mut().editor.line_wrap = new_value;
1280
1281                // Update all viewports to reflect the new line wrap setting,
1282                // respecting per-language overrides
1283                let leaf_ids: Vec<_> = self.split_view_states.keys().copied().collect();
1284                for leaf_id in leaf_ids {
1285                    let buffer_id = self
1286                        .split_manager
1287                        .get_buffer_id(leaf_id.into())
1288                        .unwrap_or(BufferId(0));
1289                    let effective_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
1290                    let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
1291                    if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
1292                        view_state.viewport.line_wrap_enabled = effective_wrap;
1293                        view_state.viewport.wrap_indent = self.config.editor.wrap_indent;
1294                        view_state.viewport.wrap_column = wrap_column;
1295                    }
1296                }
1297
1298                let state = if self.config.editor.line_wrap {
1299                    t!("view.state_enabled").to_string()
1300                } else {
1301                    t!("view.state_disabled").to_string()
1302                };
1303                self.set_status_message(t!("view.line_wrap_state", state = state).to_string());
1304            }
1305            Action::ToggleCurrentLineHighlight => {
1306                let new_value = !self.config.editor.highlight_current_line;
1307                self.config_mut().editor.highlight_current_line = new_value;
1308
1309                // Update all splits
1310                let leaf_ids: Vec<_> = self.split_view_states.keys().copied().collect();
1311                for leaf_id in leaf_ids {
1312                    if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
1313                        view_state.highlight_current_line =
1314                            self.config.editor.highlight_current_line;
1315                    }
1316                }
1317
1318                let state = if self.config.editor.highlight_current_line {
1319                    t!("view.state_enabled").to_string()
1320                } else {
1321                    t!("view.state_disabled").to_string()
1322                };
1323                self.set_status_message(
1324                    t!("view.current_line_highlight_state", state = state).to_string(),
1325                );
1326            }
1327            Action::ToggleReadOnly => {
1328                let buffer_id = self.active_buffer();
1329                let is_now_read_only = self
1330                    .buffer_metadata
1331                    .get(&buffer_id)
1332                    .map(|m| !m.read_only)
1333                    .unwrap_or(false);
1334                self.mark_buffer_read_only(buffer_id, is_now_read_only);
1335
1336                let state_str = if is_now_read_only {
1337                    t!("view.state_enabled").to_string()
1338                } else {
1339                    t!("view.state_disabled").to_string()
1340                };
1341                self.set_status_message(t!("view.read_only_state", state = state_str).to_string());
1342            }
1343            Action::TogglePageView => {
1344                self.handle_toggle_page_view();
1345            }
1346            Action::SetPageWidth => {
1347                let active_split = self.split_manager.active_split();
1348                let current = self
1349                    .split_view_states
1350                    .get(&active_split)
1351                    .and_then(|v| v.compose_width.map(|w| w.to_string()))
1352                    .unwrap_or_default();
1353                self.start_prompt_with_initial_text(
1354                    "Page width (empty = viewport): ".to_string(),
1355                    PromptType::SetPageWidth,
1356                    current,
1357                );
1358            }
1359            Action::SetBackground => {
1360                let default_path = self
1361                    .ansi_background_path
1362                    .as_ref()
1363                    .and_then(|p| {
1364                        p.strip_prefix(&self.working_dir)
1365                            .ok()
1366                            .map(|rel| rel.to_string_lossy().to_string())
1367                    })
1368                    .unwrap_or_else(|| DEFAULT_BACKGROUND_FILE.to_string());
1369
1370                self.start_prompt_with_initial_text(
1371                    "Background file: ".to_string(),
1372                    PromptType::SetBackgroundFile,
1373                    default_path,
1374                );
1375            }
1376            Action::SetBackgroundBlend => {
1377                let default_amount = format!("{:.2}", self.background_fade);
1378                self.start_prompt_with_initial_text(
1379                    "Background blend (0-1): ".to_string(),
1380                    PromptType::SetBackgroundBlend,
1381                    default_amount,
1382                );
1383            }
1384            Action::LspCompletion => {
1385                self.request_completion();
1386            }
1387            Action::DabbrevExpand => {
1388                self.dabbrev_expand();
1389            }
1390            Action::LspGotoDefinition => {
1391                self.request_goto_definition()?;
1392            }
1393            Action::LspRename => {
1394                self.start_rename()?;
1395            }
1396            Action::LspHover => {
1397                self.request_hover()?;
1398            }
1399            Action::LspReferences => {
1400                self.request_references()?;
1401            }
1402            Action::LspSignatureHelp => {
1403                self.request_signature_help();
1404            }
1405            Action::LspCodeActions => {
1406                self.request_code_actions()?;
1407            }
1408            Action::LspRestart => {
1409                self.handle_lsp_restart();
1410            }
1411            Action::LspStop => {
1412                self.handle_lsp_stop();
1413            }
1414            Action::LspToggleForBuffer => {
1415                self.handle_lsp_toggle_for_buffer();
1416            }
1417            Action::ToggleInlayHints => {
1418                self.toggle_inlay_hints();
1419            }
1420            Action::DumpConfig => {
1421                self.dump_config();
1422            }
1423            Action::RedrawScreen => {
1424                self.request_full_redraw();
1425            }
1426            Action::SelectTheme => {
1427                self.start_select_theme_prompt();
1428            }
1429            Action::InspectThemeAtCursor => {
1430                self.inspect_theme_at_cursor();
1431            }
1432            Action::SelectKeybindingMap => {
1433                self.start_select_keybinding_map_prompt();
1434            }
1435            Action::SelectCursorStyle => {
1436                self.start_select_cursor_style_prompt();
1437            }
1438            Action::SelectLocale => {
1439                self.start_select_locale_prompt();
1440            }
1441            Action::Search => {
1442                // If already in a search-related prompt, Ctrl+F acts like Enter (confirm search)
1443                let is_search_prompt = self.prompt.as_ref().is_some_and(|p| {
1444                    matches!(
1445                        p.prompt_type,
1446                        PromptType::Search
1447                            | PromptType::ReplaceSearch
1448                            | PromptType::QueryReplaceSearch
1449                    )
1450                });
1451
1452                if is_search_prompt {
1453                    self.confirm_prompt();
1454                } else {
1455                    self.start_search_prompt(
1456                        t!("file.search_prompt").to_string(),
1457                        PromptType::Search,
1458                        false,
1459                    );
1460                }
1461            }
1462            Action::Replace => {
1463                // Use same flow as query-replace, just with confirm_each defaulting to false
1464                self.start_search_prompt(
1465                    t!("file.replace_prompt").to_string(),
1466                    PromptType::ReplaceSearch,
1467                    false,
1468                );
1469            }
1470            Action::QueryReplace => {
1471                // Enable confirm mode by default for query-replace
1472                self.search_confirm_each = true;
1473                self.start_search_prompt(
1474                    "Query replace: ".to_string(),
1475                    PromptType::QueryReplaceSearch,
1476                    false,
1477                );
1478            }
1479            Action::FindInSelection => {
1480                self.start_search_prompt(
1481                    t!("file.search_prompt").to_string(),
1482                    PromptType::Search,
1483                    true,
1484                );
1485            }
1486            Action::FindNext => {
1487                self.find_next();
1488            }
1489            Action::FindPrevious => {
1490                self.find_previous();
1491            }
1492            Action::FindSelectionNext => {
1493                self.find_selection_next();
1494            }
1495            Action::FindSelectionPrevious => {
1496                self.find_selection_previous();
1497            }
1498            Action::AddCursorNextMatch => self.add_cursor_at_next_match(),
1499            Action::AddCursorAbove => self.add_cursor_above(),
1500            Action::AddCursorBelow => self.add_cursor_below(),
1501            Action::NextBuffer => self.next_buffer(),
1502            Action::PrevBuffer => self.prev_buffer(),
1503            Action::SwitchToPreviousTab => self.switch_to_previous_tab(),
1504            Action::SwitchToTabByName => self.start_switch_to_tab_prompt(),
1505
1506            // Tab scrolling (manual scroll - don't auto-adjust)
1507            Action::ScrollTabsLeft => {
1508                let active_split_id = self.split_manager.active_split();
1509                if let Some(view_state) = self.split_view_states.get_mut(&active_split_id) {
1510                    view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_sub(5);
1511                    self.set_status_message(t!("status.scrolled_tabs_left").to_string());
1512                }
1513            }
1514            Action::ScrollTabsRight => {
1515                let active_split_id = self.split_manager.active_split();
1516                if let Some(view_state) = self.split_view_states.get_mut(&active_split_id) {
1517                    view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_add(5);
1518                    self.set_status_message(t!("status.scrolled_tabs_right").to_string());
1519                }
1520            }
1521            Action::NavigateBack => self.navigate_back(),
1522            Action::NavigateForward => self.navigate_forward(),
1523            Action::SplitHorizontal => self.split_pane_horizontal(),
1524            Action::SplitVertical => self.split_pane_vertical(),
1525            Action::CloseSplit => self.close_active_split(),
1526            Action::NextSplit => self.next_split(),
1527            Action::PrevSplit => self.prev_split(),
1528            Action::IncreaseSplitSize => self.adjust_split_size(0.05),
1529            Action::DecreaseSplitSize => self.adjust_split_size(-0.05),
1530            Action::ToggleMaximizeSplit => self.toggle_maximize_split(),
1531            Action::ToggleFileExplorer => self.toggle_file_explorer(),
1532            Action::ToggleMenuBar => self.toggle_menu_bar(),
1533            Action::ToggleTabBar => self.toggle_tab_bar(),
1534            Action::ToggleStatusBar => self.toggle_status_bar(),
1535            Action::TogglePromptLine => self.toggle_prompt_line(),
1536            Action::ToggleVerticalScrollbar => self.toggle_vertical_scrollbar(),
1537            Action::ToggleHorizontalScrollbar => self.toggle_horizontal_scrollbar(),
1538            Action::ToggleLineNumbers => self.toggle_line_numbers(),
1539            Action::ToggleScrollSync => self.toggle_scroll_sync(),
1540            Action::ToggleMouseCapture => self.toggle_mouse_capture(),
1541            Action::ToggleMouseHover => self.toggle_mouse_hover(),
1542            Action::ToggleDebugHighlights => self.toggle_debug_highlights(),
1543            // Rulers
1544            Action::AddRuler => {
1545                self.start_prompt(t!("rulers.add_prompt").to_string(), PromptType::AddRuler);
1546            }
1547            Action::RemoveRuler => {
1548                self.start_remove_ruler_prompt();
1549            }
1550            // Buffer settings
1551            Action::SetTabSize => {
1552                let current = self
1553                    .buffers
1554                    .get(&self.active_buffer())
1555                    .map(|s| s.buffer_settings.tab_size.to_string())
1556                    .unwrap_or_else(|| "4".to_string());
1557                self.start_prompt_with_initial_text(
1558                    "Tab size: ".to_string(),
1559                    PromptType::SetTabSize,
1560                    current,
1561                );
1562            }
1563            Action::SetLineEnding => {
1564                self.start_set_line_ending_prompt();
1565            }
1566            Action::SetEncoding => {
1567                self.start_set_encoding_prompt();
1568            }
1569            Action::ReloadWithEncoding => {
1570                self.start_reload_with_encoding_prompt();
1571            }
1572            Action::SetLanguage => {
1573                self.start_set_language_prompt();
1574            }
1575            Action::ToggleIndentationStyle => {
1576                if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
1577                    state.buffer_settings.use_tabs = !state.buffer_settings.use_tabs;
1578                    let status = if state.buffer_settings.use_tabs {
1579                        "Indentation: Tabs"
1580                    } else {
1581                        "Indentation: Spaces"
1582                    };
1583                    self.set_status_message(status.to_string());
1584                }
1585            }
1586            Action::ToggleTabIndicators | Action::ToggleWhitespaceIndicators => {
1587                if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
1588                    state.buffer_settings.whitespace.toggle_all();
1589                    let status = if state.buffer_settings.whitespace.any_visible() {
1590                        t!("toggle.whitespace_indicators_shown")
1591                    } else {
1592                        t!("toggle.whitespace_indicators_hidden")
1593                    };
1594                    self.set_status_message(status.to_string());
1595                }
1596            }
1597            Action::ResetBufferSettings => self.reset_buffer_settings(),
1598            Action::FocusFileExplorer => self.focus_file_explorer(),
1599            Action::FocusEditor => self.focus_editor(),
1600            Action::FileExplorerUp => self.file_explorer_navigate_up(),
1601            Action::FileExplorerDown => self.file_explorer_navigate_down(),
1602            Action::FileExplorerPageUp => self.file_explorer_page_up(),
1603            Action::FileExplorerPageDown => self.file_explorer_page_down(),
1604            Action::FileExplorerExpand => self.file_explorer_toggle_expand(),
1605            Action::FileExplorerCollapse => self.file_explorer_collapse(),
1606            Action::FileExplorerOpen => self.file_explorer_open_file()?,
1607            Action::FileExplorerRefresh => self.file_explorer_refresh(),
1608            Action::FileExplorerNewFile => self.file_explorer_new_file(),
1609            Action::FileExplorerNewDirectory => self.file_explorer_new_directory(),
1610            Action::FileExplorerDelete => self.file_explorer_delete(),
1611            Action::FileExplorerRename => self.file_explorer_rename(),
1612            Action::FileExplorerToggleHidden => self.file_explorer_toggle_hidden(),
1613            Action::FileExplorerToggleGitignored => self.file_explorer_toggle_gitignored(),
1614            Action::FileExplorerSearchClear => self.file_explorer_search_clear(),
1615            Action::FileExplorerSearchBackspace => self.file_explorer_search_pop_char(),
1616            Action::FileExplorerCopy => self.file_explorer_copy(),
1617            Action::FileExplorerCut => self.file_explorer_cut(),
1618            Action::FileExplorerPaste => self.file_explorer_paste(),
1619            Action::FileExplorerDuplicate => self.file_explorer_duplicate(),
1620            Action::FileExplorerCopyFullPath => self.file_explorer_copy_path(false),
1621            Action::FileExplorerCopyRelativePath => self.file_explorer_copy_path(true),
1622            Action::FileExplorerExtendSelectionUp => self.file_explorer_extend_selection_up(),
1623            Action::FileExplorerExtendSelectionDown => self.file_explorer_extend_selection_down(),
1624            Action::FileExplorerToggleSelect => self.file_explorer_toggle_select(),
1625            Action::FileExplorerSelectAll => self.file_explorer_select_all(),
1626            Action::RemoveSecondaryCursors => {
1627                // Convert action to events and apply them
1628                if let Some(events) = self.action_to_events(Action::RemoveSecondaryCursors) {
1629                    // Wrap in batch for atomic undo
1630                    let batch = Event::Batch {
1631                        events: events.clone(),
1632                        description: "Remove secondary cursors".to_string(),
1633                    };
1634                    self.active_event_log_mut().append(batch.clone());
1635                    self.apply_event_to_active_buffer(&batch);
1636
1637                    // Ensure the primary cursor is visible after removing secondary cursors
1638                    let active_split = self.split_manager.active_split();
1639                    let active_buffer = self.active_buffer();
1640                    if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
1641                        let state = self.buffers.get_mut(&active_buffer).unwrap();
1642                        view_state.ensure_cursor_visible(&mut state.buffer, &state.marker_list);
1643                    }
1644                }
1645            }
1646
1647            // Menu navigation actions
1648            Action::MenuActivate => {
1649                self.handle_menu_activate();
1650            }
1651            Action::MenuClose => {
1652                self.handle_menu_close();
1653            }
1654            Action::MenuLeft => {
1655                self.handle_menu_left();
1656            }
1657            Action::MenuRight => {
1658                self.handle_menu_right();
1659            }
1660            Action::MenuUp => {
1661                self.handle_menu_up();
1662            }
1663            Action::MenuDown => {
1664                self.handle_menu_down();
1665            }
1666            Action::MenuExecute => {
1667                if let Some(action) = self.handle_menu_execute() {
1668                    return self.handle_action(action);
1669                }
1670            }
1671            Action::MenuOpen(menu_name) => {
1672                if self.config.editor.menu_bar_mnemonics {
1673                    self.handle_menu_open(&menu_name);
1674                }
1675            }
1676
1677            Action::SwitchKeybindingMap(map_name) => {
1678                // Check if the map exists (either built-in or user-defined)
1679                let is_builtin =
1680                    matches!(map_name.as_str(), "default" | "emacs" | "vscode" | "macos");
1681                let is_user_defined = self.config.keybinding_maps.contains_key(&map_name);
1682
1683                if is_builtin || is_user_defined {
1684                    // Update the active keybinding map in config
1685                    self.config_mut().active_keybinding_map = map_name.clone().into();
1686
1687                    // Reload the keybinding resolver with the new map
1688                    *self.keybindings.write().unwrap() =
1689                        crate::input::keybindings::KeybindingResolver::new(&self.config);
1690
1691                    self.set_status_message(
1692                        t!("view.keybindings_switched", map = map_name).to_string(),
1693                    );
1694                } else {
1695                    self.set_status_message(
1696                        t!("view.keybindings_unknown", map = map_name).to_string(),
1697                    );
1698                }
1699            }
1700
1701            Action::SmartHome => {
1702                // In composite (diff) views, use LineStart movement
1703                let buffer_id = self.active_buffer();
1704                if self.is_composite_buffer(buffer_id) {
1705                    if let Some(_handled) =
1706                        self.handle_composite_action(buffer_id, &Action::SmartHome)
1707                    {
1708                        return Ok(());
1709                    }
1710                }
1711                self.smart_home();
1712            }
1713            Action::ToggleComment => {
1714                self.toggle_comment();
1715            }
1716            Action::ToggleFold => {
1717                self.toggle_fold_at_cursor();
1718            }
1719            Action::GoToMatchingBracket => {
1720                self.goto_matching_bracket();
1721            }
1722            Action::JumpToNextError => {
1723                self.jump_to_next_error();
1724            }
1725            Action::JumpToPreviousError => {
1726                self.jump_to_previous_error();
1727            }
1728            Action::SetBookmark(key) => {
1729                self.set_bookmark(key);
1730            }
1731            Action::JumpToBookmark(key) => {
1732                self.jump_to_bookmark(key);
1733            }
1734            Action::ClearBookmark(key) => {
1735                self.clear_bookmark(key);
1736            }
1737            Action::ListBookmarks => {
1738                self.list_bookmarks();
1739            }
1740            Action::ToggleSearchCaseSensitive => {
1741                self.search_case_sensitive = !self.search_case_sensitive;
1742                let state = if self.search_case_sensitive {
1743                    "enabled"
1744                } else {
1745                    "disabled"
1746                };
1747                self.set_status_message(
1748                    t!("search.case_sensitive_state", state = state).to_string(),
1749                );
1750                // Update incremental highlights if in search prompt, otherwise re-run completed search
1751                // Check prompt FIRST since we want to use current prompt input, not stale search_state
1752                if let Some(prompt) = &self.prompt {
1753                    if matches!(
1754                        prompt.prompt_type,
1755                        PromptType::Search
1756                            | PromptType::ReplaceSearch
1757                            | PromptType::QueryReplaceSearch
1758                    ) {
1759                        let query = prompt.input.clone();
1760                        self.update_search_highlights(&query);
1761                    }
1762                } else if let Some(search_state) = &self.search_state {
1763                    let query = search_state.query.clone();
1764                    self.perform_search(&query);
1765                }
1766            }
1767            Action::ToggleSearchWholeWord => {
1768                self.search_whole_word = !self.search_whole_word;
1769                let state = if self.search_whole_word {
1770                    "enabled"
1771                } else {
1772                    "disabled"
1773                };
1774                self.set_status_message(t!("search.whole_word_state", state = state).to_string());
1775                // Update incremental highlights if in search prompt, otherwise re-run completed search
1776                // Check prompt FIRST since we want to use current prompt input, not stale search_state
1777                if let Some(prompt) = &self.prompt {
1778                    if matches!(
1779                        prompt.prompt_type,
1780                        PromptType::Search
1781                            | PromptType::ReplaceSearch
1782                            | PromptType::QueryReplaceSearch
1783                    ) {
1784                        let query = prompt.input.clone();
1785                        self.update_search_highlights(&query);
1786                    }
1787                } else if let Some(search_state) = &self.search_state {
1788                    let query = search_state.query.clone();
1789                    self.perform_search(&query);
1790                }
1791            }
1792            Action::ToggleSearchRegex => {
1793                self.search_use_regex = !self.search_use_regex;
1794                let state = if self.search_use_regex {
1795                    "enabled"
1796                } else {
1797                    "disabled"
1798                };
1799                self.set_status_message(t!("search.regex_state", state = state).to_string());
1800                // Update incremental highlights if in search prompt, otherwise re-run completed search
1801                // Check prompt FIRST since we want to use current prompt input, not stale search_state
1802                if let Some(prompt) = &self.prompt {
1803                    if matches!(
1804                        prompt.prompt_type,
1805                        PromptType::Search
1806                            | PromptType::ReplaceSearch
1807                            | PromptType::QueryReplaceSearch
1808                    ) {
1809                        let query = prompt.input.clone();
1810                        self.update_search_highlights(&query);
1811                    }
1812                } else if let Some(search_state) = &self.search_state {
1813                    let query = search_state.query.clone();
1814                    self.perform_search(&query);
1815                }
1816            }
1817            Action::ToggleSearchConfirmEach => {
1818                self.search_confirm_each = !self.search_confirm_each;
1819                let state = if self.search_confirm_each {
1820                    "enabled"
1821                } else {
1822                    "disabled"
1823                };
1824                self.set_status_message(t!("search.confirm_each_state", state = state).to_string());
1825            }
1826            Action::FileBrowserToggleHidden => {
1827                // Toggle hidden files in file browser (handled via file_open_toggle_hidden)
1828                self.file_open_toggle_hidden();
1829            }
1830            Action::StartMacroRecording => {
1831                // This is a no-op; use ToggleMacroRecording instead
1832                self.set_status_message(
1833                    "Use Ctrl+Shift+R to start recording (will prompt for register)".to_string(),
1834                );
1835            }
1836            Action::StopMacroRecording => {
1837                self.stop_macro_recording();
1838            }
1839            Action::PlayMacro(key) => {
1840                self.play_macro(key);
1841            }
1842            Action::ToggleMacroRecording(key) => {
1843                self.toggle_macro_recording(key);
1844            }
1845            Action::ShowMacro(key) => {
1846                self.show_macro_in_buffer(key);
1847            }
1848            Action::ListMacros => {
1849                self.list_macros_in_buffer();
1850            }
1851            Action::PromptRecordMacro => {
1852                self.start_prompt("Record macro (0-9): ".to_string(), PromptType::RecordMacro);
1853            }
1854            Action::PromptPlayMacro => {
1855                self.start_prompt("Play macro (0-9): ".to_string(), PromptType::PlayMacro);
1856            }
1857            Action::PlayLastMacro => {
1858                if let Some(key) = self.macros.last_register() {
1859                    self.play_macro(key);
1860                } else {
1861                    self.set_status_message(t!("status.no_macro_recorded").to_string());
1862                }
1863            }
1864            Action::PromptSetBookmark => {
1865                self.start_prompt("Set bookmark (0-9): ".to_string(), PromptType::SetBookmark);
1866            }
1867            Action::PromptJumpToBookmark => {
1868                self.start_prompt(
1869                    "Jump to bookmark (0-9): ".to_string(),
1870                    PromptType::JumpToBookmark,
1871                );
1872            }
1873            Action::CompositeNextHunk => {
1874                let buf = self.active_buffer();
1875                self.composite_next_hunk_active(buf);
1876            }
1877            Action::CompositePrevHunk => {
1878                let buf = self.active_buffer();
1879                self.composite_prev_hunk_active(buf);
1880            }
1881            Action::None => {}
1882            Action::DeleteBackward => {
1883                if self.is_editing_disabled() {
1884                    self.set_status_message(t!("buffer.editing_disabled").to_string());
1885                    return Ok(());
1886                }
1887                // Normal backspace handling
1888                if let Some(events) = self.action_to_events(Action::DeleteBackward) {
1889                    if events.len() > 1 {
1890                        // Multi-cursor: use optimized bulk edit (O(n) instead of O(n²))
1891                        let description = "Delete backward".to_string();
1892                        if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description)
1893                        {
1894                            self.active_event_log_mut().append(bulk_edit);
1895                        }
1896                    } else {
1897                        for event in events {
1898                            self.active_event_log_mut().append(event.clone());
1899                            self.apply_event_to_active_buffer(&event);
1900                        }
1901                    }
1902                }
1903            }
1904            Action::PluginAction(action_name) => {
1905                tracing::debug!("handle_action: PluginAction('{}')", action_name);
1906                // Execute the plugin callback via TypeScript plugin thread
1907                // Use non-blocking version to avoid deadlock with async plugin ops
1908                #[cfg(feature = "plugins")]
1909                if let Some(result) = self.plugin_manager.execute_action_async(&action_name) {
1910                    match result {
1911                        Ok(receiver) => {
1912                            // Store pending action for processing in main loop
1913                            self.pending_plugin_actions
1914                                .push((action_name.clone(), receiver));
1915                        }
1916                        Err(e) => {
1917                            self.set_status_message(
1918                                t!("view.plugin_error", error = e.to_string()).to_string(),
1919                            );
1920                            tracing::error!("Plugin action error: {}", e);
1921                        }
1922                    }
1923                } else {
1924                    self.set_status_message(t!("status.plugin_manager_unavailable").to_string());
1925                }
1926                #[cfg(not(feature = "plugins"))]
1927                {
1928                    let _ = action_name;
1929                    self.set_status_message(
1930                        "Plugins not available (compiled without plugin support)".to_string(),
1931                    );
1932                }
1933            }
1934            Action::LoadPluginFromBuffer => {
1935                #[cfg(feature = "plugins")]
1936                {
1937                    let buffer_id = self.active_buffer();
1938                    let state = self.active_state();
1939                    let buffer = &state.buffer;
1940                    let total = buffer.total_bytes();
1941                    let content =
1942                        String::from_utf8_lossy(&buffer.slice_bytes(0..total)).to_string();
1943
1944                    // Determine if TypeScript from file extension, default to TS
1945                    let is_ts = buffer
1946                        .file_path()
1947                        .and_then(|p| p.extension())
1948                        .and_then(|e| e.to_str())
1949                        .map(|e| e == "ts" || e == "tsx")
1950                        .unwrap_or(true);
1951
1952                    // Derive plugin name from buffer filename
1953                    let name = buffer
1954                        .file_path()
1955                        .and_then(|p| p.file_name())
1956                        .and_then(|s| s.to_str())
1957                        .map(|s| s.to_string())
1958                        .unwrap_or_else(|| "buffer-plugin".to_string());
1959
1960                    match self
1961                        .plugin_manager
1962                        .load_plugin_from_source(&content, &name, is_ts)
1963                    {
1964                        Ok(()) => {
1965                            self.set_status_message(format!(
1966                                "Plugin '{}' loaded from buffer",
1967                                name
1968                            ));
1969                        }
1970                        Err(e) => {
1971                            self.set_status_message(format!("Failed to load plugin: {}", e));
1972                            tracing::error!("LoadPluginFromBuffer error: {}", e);
1973                        }
1974                    }
1975
1976                    // Set up plugin dev workspace for LSP support
1977                    self.setup_plugin_dev_lsp(buffer_id, &content);
1978                }
1979                #[cfg(not(feature = "plugins"))]
1980                {
1981                    self.set_status_message(
1982                        "Plugins not available (compiled without plugin support)".to_string(),
1983                    );
1984                }
1985            }
1986            Action::InitReload => {
1987                // Same code path as auto-load: read init.ts and push it
1988                // through the existing plugin pipeline. The runtime's
1989                // hot-reload semantics drop prior commands / handlers /
1990                // event subs / settings before the new source runs.
1991                self.load_init_script(true);
1992                // Re-fire plugins_loaded so handlers expecting a "fresh"
1993                // post-load environment (M2) see it.
1994                self.fire_plugins_loaded_hook();
1995            }
1996            Action::InitEdit => {
1997                // Ensure the file exists (create from template if absent),
1998                // then open it in the editor so users can edit + reload.
1999                let config_dir = self.dir_context.config_dir.clone();
2000                match crate::init_script::ensure_starter(&config_dir) {
2001                    Ok(path) => {
2002                        // Regenerate `types/plugins.d.ts` from the live plugin
2003                        // set. It's written once at editor startup, but any
2004                        // plugin loaded/reloaded/unloaded since then would
2005                        // leave the aggregate stale (or missing, in builds
2006                        // where the plugins feature was off at boot but the
2007                        // user has since enabled a plugin). The user's
2008                        // tsconfig.json lists this file in `files`, so a
2009                        // stale copy is exactly when `getPluginApi("foo")`
2010                        // loses its typed overload.
2011                        let declarations = self.plugin_manager.plugin_declarations();
2012                        crate::init_script::write_plugin_declarations(&config_dir, &declarations);
2013                        match self.open_file(&path) {
2014                            Ok(_) => {
2015                                self.set_status_message(format!("init.ts: {}", path.display()));
2016                            }
2017                            Err(e) => {
2018                                self.set_status_message(format!("init.ts: open failed: {e}"));
2019                            }
2020                        }
2021                    }
2022                    Err(e) => {
2023                        self.set_status_message(format!("init.ts: create failed: {e}"));
2024                    }
2025                }
2026            }
2027            Action::InitCheck => {
2028                // Run the same parse check as `fresh --cmd init check` but
2029                // surface results in the status bar.
2030                let report = crate::init_script::check(&self.dir_context.config_dir);
2031                if report.ok && report.diagnostics.is_empty() {
2032                    self.set_status_message("init.ts: ok".into());
2033                } else if !report.ok {
2034                    let first = report
2035                        .diagnostics
2036                        .first()
2037                        .map(|d| format!("{}:{}: {}", d.line, d.column, d.message))
2038                        .unwrap_or_else(|| "unknown error".into());
2039                    self.set_status_message(format!(
2040                        "init.ts: {} error(s) — first: {first}",
2041                        report.diagnostics.len()
2042                    ));
2043                } else {
2044                    self.set_status_message(format!(
2045                        "init.ts: {} warning(s)",
2046                        report.diagnostics.len()
2047                    ));
2048                }
2049            }
2050            Action::OpenTerminal => {
2051                self.open_terminal();
2052            }
2053            Action::CloseTerminal => {
2054                self.close_terminal();
2055            }
2056            Action::FocusTerminal => {
2057                // If viewing a terminal buffer, switch to terminal mode
2058                if self.is_terminal_buffer(self.active_buffer()) {
2059                    self.terminal_mode = true;
2060                    self.key_context = KeyContext::Terminal;
2061                    self.set_status_message(t!("status.terminal_mode_enabled").to_string());
2062                }
2063            }
2064            Action::TerminalEscape => {
2065                // Exit terminal mode back to editor
2066                if self.terminal_mode {
2067                    self.terminal_mode = false;
2068                    self.key_context = KeyContext::Normal;
2069                    self.set_status_message(t!("status.terminal_mode_disabled").to_string());
2070                }
2071            }
2072            Action::ToggleKeyboardCapture => {
2073                // Toggle keyboard capture mode in terminal
2074                if self.terminal_mode {
2075                    self.keyboard_capture = !self.keyboard_capture;
2076                    if self.keyboard_capture {
2077                        self.set_status_message(
2078                            "Keyboard capture ON - all keys go to terminal (F9 to toggle)"
2079                                .to_string(),
2080                        );
2081                    } else {
2082                        self.set_status_message(
2083                            "Keyboard capture OFF - UI bindings active (F9 to toggle)".to_string(),
2084                        );
2085                    }
2086                }
2087            }
2088            Action::TerminalPaste => {
2089                // Paste clipboard contents into terminal as a single batch
2090                if self.terminal_mode {
2091                    if let Some(text) = self.clipboard.paste() {
2092                        self.send_terminal_input(text.as_bytes());
2093                    }
2094                }
2095            }
2096            Action::ShellCommand => {
2097                // Run shell command on buffer/selection, output to new buffer
2098                self.start_shell_command_prompt(false);
2099            }
2100            Action::ShellCommandReplace => {
2101                // Run shell command on buffer/selection, replace content
2102                self.start_shell_command_prompt(true);
2103            }
2104            Action::OpenSettings => {
2105                self.open_settings();
2106            }
2107            Action::CloseSettings => {
2108                // Check if there are unsaved changes
2109                let has_changes = self
2110                    .settings_state
2111                    .as_ref()
2112                    .is_some_and(|s| s.has_changes());
2113                if has_changes {
2114                    // Show confirmation dialog
2115                    if let Some(ref mut state) = self.settings_state {
2116                        state.show_confirm_dialog();
2117                    }
2118                } else {
2119                    self.close_settings(false);
2120                }
2121            }
2122            Action::SettingsSave => {
2123                self.save_settings();
2124            }
2125            Action::SettingsReset => {
2126                if let Some(ref mut state) = self.settings_state {
2127                    state.reset_current_to_default();
2128                }
2129            }
2130            Action::SettingsInherit => {
2131                if let Some(ref mut state) = self.settings_state {
2132                    state.set_current_to_null();
2133                }
2134            }
2135            Action::SettingsToggleFocus => {
2136                if let Some(ref mut state) = self.settings_state {
2137                    state.toggle_focus();
2138                }
2139            }
2140            Action::SettingsActivate => {
2141                self.settings_activate_current();
2142            }
2143            Action::SettingsSearch => {
2144                if let Some(ref mut state) = self.settings_state {
2145                    state.start_search();
2146                }
2147            }
2148            Action::SettingsHelp => {
2149                if let Some(ref mut state) = self.settings_state {
2150                    state.toggle_help();
2151                }
2152            }
2153            Action::SettingsIncrement => {
2154                self.settings_increment_current();
2155            }
2156            Action::SettingsDecrement => {
2157                self.settings_decrement_current();
2158            }
2159            Action::CalibrateInput => {
2160                self.open_calibration_wizard();
2161            }
2162            Action::EventDebug => {
2163                self.open_event_debug();
2164            }
2165            Action::SuspendProcess => {
2166                self.request_suspend();
2167            }
2168            Action::OpenKeybindingEditor => {
2169                self.open_keybinding_editor();
2170            }
2171            Action::PromptConfirm => {
2172                if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
2173                    use super::prompt_actions::PromptResult;
2174                    match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
2175                        PromptResult::ExecuteAction(action) => {
2176                            return self.handle_action(action);
2177                        }
2178                        PromptResult::EarlyReturn => {
2179                            return Ok(());
2180                        }
2181                        PromptResult::Done => {}
2182                    }
2183                }
2184            }
2185            Action::PromptConfirmWithText(ref text) => {
2186                // For macro playback: set the prompt text before confirming
2187                if let Some(ref mut prompt) = self.prompt {
2188                    prompt.set_input(text.clone());
2189                    self.update_prompt_suggestions();
2190                }
2191                if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
2192                    use super::prompt_actions::PromptResult;
2193                    match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
2194                        PromptResult::ExecuteAction(action) => {
2195                            return self.handle_action(action);
2196                        }
2197                        PromptResult::EarlyReturn => {
2198                            return Ok(());
2199                        }
2200                        PromptResult::Done => {}
2201                    }
2202                }
2203            }
2204            Action::PopupConfirm => {
2205                use super::popup_actions::PopupConfirmResult;
2206                if let PopupConfirmResult::EarlyReturn = self.handle_popup_confirm() {
2207                    return Ok(());
2208                }
2209            }
2210            Action::PopupCancel => {
2211                self.handle_popup_cancel();
2212            }
2213            Action::PopupFocus => {
2214                self.handle_popup_focus();
2215            }
2216            Action::CompletionAccept => {
2217                use super::popup_actions::PopupConfirmResult;
2218                if let PopupConfirmResult::EarlyReturn = self.handle_popup_confirm() {
2219                    return Ok(());
2220                }
2221            }
2222            Action::CompletionDismiss => {
2223                self.handle_popup_cancel();
2224            }
2225            Action::InsertChar(c) => {
2226                if self.is_prompting() {
2227                    return self.handle_insert_char_prompt(c);
2228                } else if self.key_context == KeyContext::FileExplorer {
2229                    self.file_explorer_search_push_char(c);
2230                } else {
2231                    self.handle_insert_char_editor(c)?;
2232                }
2233            }
2234            // Prompt clipboard actions
2235            Action::PromptCopy => {
2236                if let Some(prompt) = &self.prompt {
2237                    let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
2238                    if !text.is_empty() {
2239                        self.clipboard.copy(text);
2240                        self.set_status_message(t!("clipboard.copied").to_string());
2241                    }
2242                }
2243            }
2244            Action::PromptCut => {
2245                if let Some(prompt) = &self.prompt {
2246                    let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
2247                    if !text.is_empty() {
2248                        self.clipboard.copy(text);
2249                    }
2250                }
2251                if let Some(prompt) = self.prompt.as_mut() {
2252                    if prompt.has_selection() {
2253                        prompt.delete_selection();
2254                    } else {
2255                        prompt.clear();
2256                    }
2257                }
2258                self.set_status_message(t!("clipboard.cut").to_string());
2259                self.update_prompt_suggestions();
2260            }
2261            Action::PromptPaste => {
2262                if let Some(text) = self.clipboard.paste() {
2263                    if let Some(prompt) = self.prompt.as_mut() {
2264                        prompt.insert_str(&text);
2265                    }
2266                    self.update_prompt_suggestions();
2267                }
2268            }
2269            _ => {
2270                // TODO: Why do we have this catch-all? It seems like actions should either:
2271                // 1. Be handled explicitly above (like InsertChar, PopupConfirm, etc.)
2272                // 2. Or be converted to events consistently
2273                // This catch-all makes it unclear which actions go through event conversion
2274                // vs. direct handling. Consider making this explicit or removing the pattern.
2275                self.apply_action_as_events(action)?;
2276            }
2277        }
2278
2279        Ok(())
2280    }
2281}