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