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