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