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.clone());
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                // Re-open Live Grep with the cached query and the
1319                // suggestions snapshot — does NOT re-run ripgrep
1320                // (issue #1796: "restore / re-show without re-running
1321                // the search"). If no cache exists, fall through to a
1322                // fresh Live Grep invocation.
1323                let cached = self.active_window_mut().live_grep_last_state.clone();
1324                match cached {
1325                    Some(state) if state.cached_results.as_ref().is_some_and(|r| !r.is_empty()) => {
1326                        let results = state.cached_results.unwrap_or_default();
1327                        // Map cached GrepMatch records back into prompt
1328                        // Suggestions. The text is "file:line", the
1329                        // value carries "file:line:column" for the
1330                        // PromptType::LiveGrep confirm handler.
1331                        let suggestions: Vec<crate::input::commands::Suggestion> = results
1332                            .into_iter()
1333                            .map(|m| {
1334                                let label = format!("{}:{}", m.file, m.line);
1335                                let value = format!("{}:{}:{}", m.file, m.line, m.column);
1336                                let mut s = crate::input::commands::Suggestion::new(label);
1337                                s.description = Some(m.content);
1338                                s.value = Some(value);
1339                                s
1340                            })
1341                            .collect();
1342                        // Build the prompt directly so we can seed
1343                        // input + selection + suggestions in one shot.
1344                        // Label string mirrors the live_grep plugin's
1345                        // i18n bundle. Resume is core-driven (no
1346                        // plugin), so we hardcode rather than route
1347                        // through plugin-scoped translations.
1348                        let mut prompt = crate::view::prompt::Prompt::with_suggestions(
1349                            "Live grep: ".to_string(),
1350                            PromptType::LiveGrep,
1351                            suggestions,
1352                        );
1353                        prompt.input = state.query;
1354                        prompt.cursor_pos = prompt.input.len();
1355                        if let Some(idx) = state.selected_index {
1356                            if idx < prompt.suggestions.len() {
1357                                prompt.selected_suggestion = Some(idx);
1358                            }
1359                        }
1360                        prompt.suggestions_set_for_input = Some(prompt.input.clone());
1361                        // Render Resume in the floating overlay too.
1362                        prompt.overlay = true;
1363                        self.active_window_mut().prompt = Some(prompt);
1364                    }
1365                    _ => {
1366                        // No cache — kick off a fresh Live Grep.
1367                        #[cfg(feature = "plugins")]
1368                        {
1369                            let result = self
1370                                .plugin_manager
1371                                .read()
1372                                .unwrap()
1373                                .execute_action_async("start_live_grep");
1374                            if let Some(result) = result {
1375                                match result {
1376                                    Ok(receiver) => {
1377                                        self.pending_plugin_actions
1378                                            .push(("start_live_grep".to_string(), receiver));
1379                                    }
1380                                    Err(e) => {
1381                                        self.set_status_message(format!(
1382                                            "Live Grep unavailable: {}",
1383                                            e
1384                                        ));
1385                                    }
1386                                }
1387                            }
1388                        }
1389                    }
1390                }
1391            }
1392            Action::LiveGrepExportQuickfix => {
1393                // Snapshot the current Live Grep prompt's suggestions
1394                // into a virtual buffer parked in the Utility Dock.
1395                // Active when the prompt is either PromptType::LiveGrep
1396                // or the live_grep plugin's Plugin{custom_type}.
1397                let is_grep = self
1398                    .active_window()
1399                    .prompt
1400                    .as_ref()
1401                    .map(|p| match &p.prompt_type {
1402                        PromptType::LiveGrep => true,
1403                        PromptType::Plugin { custom_type } => custom_type == "live-grep",
1404                        _ => false,
1405                    })
1406                    .unwrap_or(false);
1407                if !is_grep {
1408                    self.set_status_message(
1409                        "Quickfix export is only available inside Live Grep".to_string(),
1410                    );
1411                    return Ok(());
1412                }
1413                let (query, matches) = {
1414                    let prompt = self.active_window().prompt.as_ref().unwrap();
1415                    (
1416                        prompt.input.clone(),
1417                        self.snapshot_prompt_results_for_grep(prompt),
1418                    )
1419                };
1420                if matches.is_empty() {
1421                    self.set_status_message("No Live Grep results to export".to_string());
1422                    return Ok(());
1423                }
1424                // Dismiss the prompt before mutating split tree.
1425                self.cancel_prompt();
1426                // Hand off to the dock-installer.
1427                self.install_quickfix_in_dock(query, matches);
1428            }
1429            Action::ToggleUtilityDock => {
1430                use crate::view::split::SplitRole;
1431                if let Some(dock_leaf) = self
1432                    .windows
1433                    .get(&self.active_window)
1434                    .and_then(|w| w.buffers.splits())
1435                    .map(|(mgr, _)| mgr)
1436                    .expect("active window must have a populated split layout")
1437                    .find_leaf_by_role(SplitRole::UtilityDock)
1438                {
1439                    let active = self
1440                        .windows
1441                        .get(&self.active_window)
1442                        .and_then(|w| w.buffers.splits())
1443                        .map(|(mgr, _)| mgr)
1444                        .expect("active window must have a populated split layout")
1445                        .active_split();
1446                    if active == dock_leaf {
1447                        // Already focused — no editor-leaf history yet,
1448                        // so just cycle to the next leaf via the
1449                        // existing Alt+] command. Phase 7 will track a
1450                        // proper "previous editor split" pointer.
1451                        self.next_split();
1452                    } else {
1453                        self.windows
1454                            .get_mut(&self.active_window)
1455                            .and_then(|w| w.split_manager_mut())
1456                            .expect("active window must have a populated split layout")
1457                            .set_active_split(dock_leaf);
1458                    }
1459                } else {
1460                    self.set_status_message(
1461                        "No Utility Dock open — invoke a dock-aware utility (Diagnostics, Search/Replace, …)"
1462                            .to_string(),
1463                    );
1464                }
1465            }
1466            Action::CycleLiveGrepProvider => {
1467                // Only meaningful while the Live Grep overlay is
1468                // open. Detect via prompt state — both
1469                // `PromptType::LiveGrep` (Resume's pre-seeded
1470                // overlay) and `Plugin{custom_type:"live-grep"}`
1471                // (the live-running plugin's prompt) qualify.
1472                let in_live_grep = self
1473                    .active_window()
1474                    .prompt
1475                    .as_ref()
1476                    .map(|p| match &p.prompt_type {
1477                        PromptType::LiveGrep => true,
1478                        PromptType::Plugin { custom_type } => custom_type == "live-grep",
1479                        _ => false,
1480                    })
1481                    .unwrap_or(false);
1482                if !in_live_grep {
1483                    self.set_status_message(
1484                        "Cycle Live Grep provider only works inside Live Grep".to_string(),
1485                    );
1486                    return Ok(());
1487                }
1488                #[cfg(feature = "plugins")]
1489                {
1490                    let result = self
1491                        .plugin_manager
1492                        .read()
1493                        .unwrap()
1494                        .execute_action_async("live_grep_cycle_provider");
1495                    if let Some(result) = result {
1496                        match result {
1497                            Ok(receiver) => {
1498                                self.pending_plugin_actions
1499                                    .push(("live_grep_cycle_provider".to_string(), receiver));
1500                            }
1501                            Err(e) => {
1502                                self.set_status_message(format!("Live Grep cycle failed: {}", e));
1503                            }
1504                        }
1505                    } else {
1506                        self.set_status_message("Live Grep plugin not loaded".to_string());
1507                    }
1508                }
1509                #[cfg(not(feature = "plugins"))]
1510                {
1511                    self.set_status_message(
1512                        "Live Grep cycle requires the plugins feature".to_string(),
1513                    );
1514                }
1515            }
1516            Action::OpenTerminalInDock => {
1517                use crate::model::event::SplitDirection;
1518                use crate::view::split::SplitRole;
1519                if let Some(dock_leaf) = self
1520                    .windows
1521                    .get(&self.active_window)
1522                    .and_then(|w| w.buffers.splits())
1523                    .map(|(mgr, _)| mgr)
1524                    .expect("active window must have a populated split layout")
1525                    .find_leaf_by_role(SplitRole::UtilityDock)
1526                {
1527                    // Existing dock — focus it and let the regular
1528                    // open_terminal path attach a new terminal tab.
1529                    self.windows
1530                        .get_mut(&self.active_window)
1531                        .and_then(|w| w.split_manager_mut())
1532                        .expect("active window must have a populated split layout")
1533                        .set_active_split(dock_leaf);
1534                    self.open_terminal();
1535                } else {
1536                    // No dock yet. Spawn the PTY first so we have a
1537                    // real terminal buffer to seed the new dock leaf
1538                    // with — otherwise the leaf would carry the
1539                    // user's previously-active buffer as a placeholder
1540                    // and that buffer would linger as a phantom tab in
1541                    // the dock alongside the terminal.
1542                    let Some(terminal_id) = self.spawn_terminal_session() else {
1543                        return Ok(());
1544                    };
1545                    let buffer_id = self.create_terminal_buffer_detached(terminal_id);
1546                    // Split at the root so the dock spans the full
1547                    // width below any pre-existing side-by-side panes.
1548                    match self
1549                        .windows
1550                        .get_mut(&self.active_window)
1551                        .and_then(|w| w.split_manager_mut())
1552                        .expect("active window must have a populated split layout")
1553                        .split_root_positioned(SplitDirection::Horizontal, buffer_id, 0.7, false)
1554                    {
1555                        Ok(new_leaf) => {
1556                            let mut view_state = crate::view::split::SplitViewState::with_buffer(
1557                                self.terminal_width,
1558                                self.terminal_height,
1559                                buffer_id,
1560                            );
1561                            // Terminal-dedicated splits never show line
1562                            // numbers or current-line highlight — the
1563                            // buffer is a PTY scrollback view, not source
1564                            // code. (Mirrors the plugin-terminal split
1565                            // setup in `create_plugin_terminal`.)
1566                            view_state.apply_config_defaults(
1567                                false,
1568                                false,
1569                                self.active_window().resolve_line_wrap_for_buffer(buffer_id),
1570                                self.config.editor.wrap_indent,
1571                                self.active_window()
1572                                    .resolve_wrap_column_for_buffer(buffer_id),
1573                                self.config.editor.rulers.clone(),
1574                            );
1575                            // Terminals don't wrap — keep escape
1576                            // sequences intact, mirroring the regular
1577                            // open_terminal path.
1578                            view_state.viewport.line_wrap_enabled = false;
1579                            self.windows
1580                                .get_mut(&self.active_window)
1581                                .and_then(|w| w.split_view_states_mut())
1582                                .expect("active window must have a populated split layout")
1583                                .insert(new_leaf, view_state);
1584                            self.windows
1585                                .get_mut(&self.active_window)
1586                                .and_then(|w| w.split_manager_mut())
1587                                .expect("active window must have a populated split layout")
1588                                .set_leaf_role(new_leaf, Some(SplitRole::UtilityDock));
1589                            self.windows
1590                                .get_mut(&self.active_window)
1591                                .and_then(|w| w.split_manager_mut())
1592                                .expect("active window must have a populated split layout")
1593                                .set_active_split(new_leaf);
1594                            // Mirror open_terminal's post-attach
1595                            // bookkeeping. Skip set_active_buffer —
1596                            // the leaf already shows the terminal and
1597                            // its tab list contains only the terminal,
1598                            // exactly the desired final state.
1599                            self.active_window_mut().terminal_mode = true;
1600                            self.active_window_mut().key_context =
1601                                crate::input::keybindings::KeyContext::Terminal;
1602                            self.active_window_mut().resize_visible_terminals();
1603                            let exit_key = self
1604                                .keybindings
1605                                .read()
1606                                .unwrap()
1607                                .find_keybinding_for_action(
1608                                    "terminal_escape",
1609                                    crate::input::keybindings::KeyContext::Terminal,
1610                                )
1611                                .unwrap_or_else(|| "Ctrl+Space".to_string());
1612                            self.set_status_message(
1613                                rust_i18n::t!(
1614                                    "terminal.opened",
1615                                    id = terminal_id.0,
1616                                    exit_key = exit_key
1617                                )
1618                                .to_string(),
1619                            );
1620                            tracing::info!(
1621                                "Opened terminal {:?} into new dock leaf {:?} (buffer {:?})",
1622                                terminal_id,
1623                                new_leaf,
1624                                buffer_id
1625                            );
1626                        }
1627                        Err(e) => {
1628                            self.set_status_message(format!(
1629                                "Failed to create dock for terminal: {}",
1630                                e
1631                            ));
1632                            return Ok(());
1633                        }
1634                    }
1635                }
1636            }
1637            Action::ToggleLineWrap => {
1638                let new_value = !self.config.editor.line_wrap;
1639                self.config_mut().editor.line_wrap = new_value;
1640                // `resolve_line_wrap_for_buffer` below reads
1641                // `Window::config()`, which holds a *separate* `Arc<Config>`
1642                // clone from the Editor's. Without this sync the resolve
1643                // would return the pre-toggle value and we'd write the
1644                // *old* line-wrap state back into the viewport — silently
1645                // no-op'ing the toggle while still flipping the status
1646                // message. See `Editor::config_mut` for the broader rule.
1647                self.sync_windows_config();
1648
1649                // Update all viewports to reflect the new line wrap setting,
1650                // respecting per-language overrides
1651                let leaf_ids: Vec<_> = self
1652                    .windows
1653                    .get(&self.active_window)
1654                    .and_then(|w| w.buffers.splits())
1655                    .map(|(_, vs)| vs)
1656                    .expect("active window must have a populated split layout")
1657                    .keys()
1658                    .copied()
1659                    .collect();
1660                for leaf_id in leaf_ids {
1661                    let buffer_id = self
1662                        .split_manager_mut()
1663                        .get_buffer_id(leaf_id.into())
1664                        .unwrap_or(BufferId(0));
1665                    let effective_wrap =
1666                        self.active_window().resolve_line_wrap_for_buffer(buffer_id);
1667                    let wrap_column = self
1668                        .active_window()
1669                        .resolve_wrap_column_for_buffer(buffer_id);
1670                    if let Some(view_state) = self
1671                        .windows
1672                        .get_mut(&self.active_window)
1673                        .and_then(|w| w.split_view_states_mut())
1674                        .expect("active window must have a populated split layout")
1675                        .get_mut(&leaf_id)
1676                    {
1677                        view_state.viewport.line_wrap_enabled = effective_wrap;
1678                        view_state.viewport.wrap_indent = self.config.editor.wrap_indent;
1679                        view_state.viewport.wrap_column = wrap_column;
1680                    }
1681                }
1682
1683                let state = if self.config.editor.line_wrap {
1684                    t!("view.state_enabled").to_string()
1685                } else {
1686                    t!("view.state_disabled").to_string()
1687                };
1688                self.set_status_message(t!("view.line_wrap_state", state = state).to_string());
1689            }
1690            Action::ToggleCurrentLineHighlight => {
1691                let new_value = !self.config.editor.highlight_current_line;
1692                self.config_mut().editor.highlight_current_line = new_value;
1693
1694                // Update all splits
1695                let leaf_ids: Vec<_> = self
1696                    .windows
1697                    .get(&self.active_window)
1698                    .and_then(|w| w.buffers.splits())
1699                    .map(|(_, vs)| vs)
1700                    .expect("active window must have a populated split layout")
1701                    .keys()
1702                    .copied()
1703                    .collect();
1704                for leaf_id in leaf_ids {
1705                    if let Some(view_state) = self
1706                        .windows
1707                        .get_mut(&self.active_window)
1708                        .and_then(|w| w.split_view_states_mut())
1709                        .expect("active window must have a populated split layout")
1710                        .get_mut(&leaf_id)
1711                    {
1712                        view_state.highlight_current_line =
1713                            self.config.editor.highlight_current_line;
1714                    }
1715                }
1716
1717                let state = if self.config.editor.highlight_current_line {
1718                    t!("view.state_enabled").to_string()
1719                } else {
1720                    t!("view.state_disabled").to_string()
1721                };
1722                self.set_status_message(
1723                    t!("view.current_line_highlight_state", state = state).to_string(),
1724                );
1725            }
1726            Action::ToggleReadOnly => {
1727                let buffer_id = self.active_buffer();
1728                let is_now_read_only = self
1729                    .active_window()
1730                    .buffer_metadata
1731                    .get(&buffer_id)
1732                    .map(|m| !m.read_only)
1733                    .unwrap_or(false);
1734                self.active_window_mut()
1735                    .mark_buffer_read_only(buffer_id, is_now_read_only);
1736
1737                let state_str = if is_now_read_only {
1738                    t!("view.state_enabled").to_string()
1739                } else {
1740                    t!("view.state_disabled").to_string()
1741                };
1742                self.set_status_message(t!("view.read_only_state", state = state_str).to_string());
1743            }
1744            Action::TogglePageView => {
1745                self.active_window_mut().handle_toggle_page_view();
1746            }
1747            Action::SetPageWidth => {
1748                let active_split = self
1749                    .windows
1750                    .get(&self.active_window)
1751                    .and_then(|w| w.buffers.splits())
1752                    .map(|(mgr, _)| mgr)
1753                    .expect("active window must have a populated split layout")
1754                    .active_split();
1755                let current = self
1756                    .windows
1757                    .get(&self.active_window)
1758                    .and_then(|w| w.buffers.splits())
1759                    .map(|(_, vs)| vs)
1760                    .expect("active window must have a populated split layout")
1761                    .get(&active_split)
1762                    .and_then(|v| v.compose_width.map(|w| w.to_string()))
1763                    .unwrap_or_default();
1764                self.start_prompt_with_initial_text(
1765                    "Page width (empty = viewport): ".to_string(),
1766                    PromptType::SetPageWidth,
1767                    current,
1768                );
1769            }
1770            Action::SetBackground => {
1771                let default_path = self
1772                    .ansi_background_path
1773                    .as_ref()
1774                    .and_then(|p| {
1775                        p.strip_prefix(&self.working_dir)
1776                            .ok()
1777                            .map(|rel| rel.to_string_lossy().to_string())
1778                    })
1779                    .unwrap_or_else(|| DEFAULT_BACKGROUND_FILE.to_string());
1780
1781                self.start_prompt_with_initial_text(
1782                    "Background file: ".to_string(),
1783                    PromptType::SetBackgroundFile,
1784                    default_path,
1785                );
1786            }
1787            Action::SetBackgroundBlend => {
1788                let default_amount = format!("{:.2}", self.background_fade);
1789                self.start_prompt_with_initial_text(
1790                    "Background blend (0-1): ".to_string(),
1791                    PromptType::SetBackgroundBlend,
1792                    default_amount,
1793                );
1794            }
1795            Action::LspCompletion => {
1796                self.request_completion();
1797            }
1798            Action::DabbrevExpand => {
1799                self.dabbrev_expand();
1800            }
1801            Action::LspGotoDefinition => {
1802                self.request_goto_definition()?;
1803            }
1804            Action::LspRename => {
1805                self.start_rename()?;
1806            }
1807            Action::LspHover => {
1808                self.request_hover()?;
1809            }
1810            Action::LspReferences => {
1811                self.request_references()?;
1812            }
1813            Action::LspSignatureHelp => {
1814                self.request_signature_help();
1815            }
1816            Action::LspCodeActions => {
1817                self.request_code_actions()?;
1818            }
1819            Action::LspRestart => {
1820                self.handle_lsp_restart();
1821            }
1822            Action::LspStop => {
1823                self.handle_lsp_stop();
1824            }
1825            Action::LspToggleForBuffer => {
1826                self.handle_lsp_toggle_for_buffer();
1827            }
1828            Action::ToggleInlayHints => {
1829                self.toggle_inlay_hints();
1830            }
1831            Action::DumpConfig => {
1832                self.dump_config();
1833            }
1834            Action::RedrawScreen => {
1835                self.request_full_redraw();
1836            }
1837            Action::SelectTheme => {
1838                self.start_select_theme_prompt();
1839            }
1840            Action::InspectThemeAtCursor => {
1841                self.inspect_theme_at_cursor();
1842            }
1843            Action::SelectKeybindingMap => {
1844                self.start_select_keybinding_map_prompt();
1845            }
1846            Action::SelectCursorStyle => {
1847                self.start_select_cursor_style_prompt();
1848            }
1849            Action::SelectLocale => {
1850                self.start_select_locale_prompt();
1851            }
1852            Action::Search => {
1853                // If already in a search-related prompt, Ctrl+F acts like Enter (confirm search)
1854                let is_search_prompt = self.active_window().prompt.as_ref().is_some_and(|p| {
1855                    matches!(
1856                        p.prompt_type,
1857                        PromptType::Search
1858                            | PromptType::ReplaceSearch
1859                            | PromptType::QueryReplaceSearch
1860                    )
1861                });
1862
1863                if is_search_prompt {
1864                    self.confirm_prompt();
1865                } else {
1866                    self.start_search_prompt(
1867                        t!("file.search_prompt").to_string(),
1868                        PromptType::Search,
1869                        false,
1870                    );
1871                }
1872            }
1873            Action::Replace => {
1874                // Use same flow as query-replace, just with confirm_each defaulting to false
1875                self.start_search_prompt(
1876                    t!("file.replace_prompt").to_string(),
1877                    PromptType::ReplaceSearch,
1878                    false,
1879                );
1880            }
1881            Action::QueryReplace => {
1882                // Enable confirm mode by default for query-replace
1883                self.active_window_mut().search_confirm_each = true;
1884                self.start_search_prompt(
1885                    "Query replace: ".to_string(),
1886                    PromptType::QueryReplaceSearch,
1887                    false,
1888                );
1889            }
1890            Action::FindInSelection => {
1891                self.start_search_prompt(
1892                    t!("file.search_prompt").to_string(),
1893                    PromptType::Search,
1894                    true,
1895                );
1896            }
1897            Action::FindNext => {
1898                self.find_next();
1899            }
1900            Action::FindPrevious => {
1901                self.find_previous();
1902            }
1903            Action::FindSelectionNext => {
1904                self.find_selection_next();
1905            }
1906            Action::FindSelectionPrevious => {
1907                self.find_selection_previous();
1908            }
1909            Action::AddCursorNextMatch => self.add_cursor_at_next_match(),
1910            Action::AddCursorAbove => self.add_cursor_above(),
1911            Action::AddCursorBelow => self.add_cursor_below(),
1912            Action::AddCursorsToLineEnds => self.add_cursors_to_line_ends(),
1913            Action::NextBuffer => self.next_buffer(),
1914            Action::PrevBuffer => self.prev_buffer(),
1915            Action::SwitchToPreviousTab => self.switch_to_previous_tab(),
1916            Action::SwitchToTabByName => self.start_switch_to_tab_prompt(),
1917
1918            // Tab scrolling (manual scroll - don't auto-adjust)
1919            Action::ScrollTabsLeft => {
1920                let active_split_id = self
1921                    .windows
1922                    .get(&self.active_window)
1923                    .and_then(|w| w.buffers.splits())
1924                    .map(|(mgr, _)| mgr)
1925                    .expect("active window must have a populated split layout")
1926                    .active_split();
1927                if let Some(view_state) = self
1928                    .windows
1929                    .get_mut(&self.active_window)
1930                    .and_then(|w| w.split_view_states_mut())
1931                    .expect("active window must have a populated split layout")
1932                    .get_mut(&active_split_id)
1933                {
1934                    view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_sub(5);
1935                    self.set_status_message(t!("status.scrolled_tabs_left").to_string());
1936                }
1937            }
1938            Action::ScrollTabsRight => {
1939                let active_split_id = self
1940                    .windows
1941                    .get(&self.active_window)
1942                    .and_then(|w| w.buffers.splits())
1943                    .map(|(mgr, _)| mgr)
1944                    .expect("active window must have a populated split layout")
1945                    .active_split();
1946                if let Some(view_state) = self
1947                    .windows
1948                    .get_mut(&self.active_window)
1949                    .and_then(|w| w.split_view_states_mut())
1950                    .expect("active window must have a populated split layout")
1951                    .get_mut(&active_split_id)
1952                {
1953                    view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_add(5);
1954                    self.set_status_message(t!("status.scrolled_tabs_right").to_string());
1955                }
1956            }
1957            Action::NavigateBack => self.navigate_back(),
1958            Action::NavigateForward => self.navigate_forward(),
1959            Action::SplitHorizontal => self.split_pane_horizontal(),
1960            Action::SplitVertical => self.split_pane_vertical(),
1961            Action::CloseSplit => self.close_active_split(),
1962            Action::NextSplit => self.next_split(),
1963            Action::PrevSplit => self.prev_split(),
1964            Action::NextWindow => self.next_window(),
1965            Action::PrevWindow => self.prev_window(),
1966            Action::IncreaseSplitSize => self.adjust_split_size(0.05),
1967            Action::DecreaseSplitSize => self.adjust_split_size(-0.05),
1968            Action::ToggleMaximizeSplit => self.toggle_maximize_split(),
1969            Action::ToggleFileExplorer => self.toggle_file_explorer(),
1970            Action::ToggleFileExplorerSide => self.toggle_file_explorer_side(),
1971            Action::ToggleMenuBar => self.toggle_menu_bar(),
1972            Action::ToggleTabBar => self.active_window_mut().toggle_tab_bar(),
1973            Action::ToggleStatusBar => self.active_window_mut().toggle_status_bar(),
1974            Action::TogglePromptLine => self.active_window_mut().toggle_prompt_line(),
1975            Action::ToggleVerticalScrollbar => self.toggle_vertical_scrollbar(),
1976            Action::ToggleHorizontalScrollbar => self.toggle_horizontal_scrollbar(),
1977            Action::ToggleLineNumbers => self.toggle_line_numbers(),
1978            Action::ToggleScrollSync => self.active_window_mut().toggle_scroll_sync(),
1979            Action::ToggleMouseCapture => self.toggle_mouse_capture(),
1980            Action::ToggleMouseHover => self.toggle_mouse_hover(),
1981            Action::ToggleDebugHighlights => self.active_window_mut().toggle_debug_highlights(),
1982            // Rulers
1983            Action::AddRuler => {
1984                self.start_prompt(t!("rulers.add_prompt").to_string(), PromptType::AddRuler);
1985            }
1986            Action::RemoveRuler => {
1987                self.start_remove_ruler_prompt();
1988            }
1989            // Buffer settings
1990            Action::SetTabSize => {
1991                let current = self
1992                    .buffers()
1993                    .get(&self.active_buffer())
1994                    .map(|s| s.buffer_settings.tab_size.to_string())
1995                    .unwrap_or_else(|| "4".to_string());
1996                self.start_prompt_with_initial_text(
1997                    "Tab size: ".to_string(),
1998                    PromptType::SetTabSize,
1999                    current,
2000                );
2001            }
2002            Action::SetLineEnding => {
2003                self.start_set_line_ending_prompt();
2004            }
2005            Action::SetEncoding => {
2006                self.start_set_encoding_prompt();
2007            }
2008            Action::ReloadWithEncoding => {
2009                self.start_reload_with_encoding_prompt();
2010            }
2011            Action::SetLanguage => {
2012                self.start_set_language_prompt();
2013            }
2014            Action::ToggleIndentationStyle => {
2015                let __buffer_id = self.active_buffer();
2016                if let Some(state) = self
2017                    .windows
2018                    .get_mut(&self.active_window)
2019                    .map(|w| &mut w.buffers)
2020                    .expect("active window present")
2021                    .get_mut(&__buffer_id)
2022                {
2023                    state.buffer_settings.use_tabs = !state.buffer_settings.use_tabs;
2024                    let status = if state.buffer_settings.use_tabs {
2025                        "Indentation: Tabs"
2026                    } else {
2027                        "Indentation: Spaces"
2028                    };
2029                    self.set_status_message(status.to_string());
2030                }
2031            }
2032            Action::ToggleTabIndicators | Action::ToggleWhitespaceIndicators => {
2033                let __buffer_id = self.active_buffer();
2034                if let Some(state) = self
2035                    .windows
2036                    .get_mut(&self.active_window)
2037                    .map(|w| &mut w.buffers)
2038                    .expect("active window present")
2039                    .get_mut(&__buffer_id)
2040                {
2041                    state.buffer_settings.whitespace.toggle_all();
2042                    let status = if state.buffer_settings.whitespace.any_visible() {
2043                        t!("toggle.whitespace_indicators_shown")
2044                    } else {
2045                        t!("toggle.whitespace_indicators_hidden")
2046                    };
2047                    self.set_status_message(status.to_string());
2048                }
2049            }
2050            Action::ResetBufferSettings => self.reset_buffer_settings(),
2051            Action::FocusFileExplorer => self.focus_file_explorer(),
2052            Action::FocusEditor => self.active_window_mut().focus_editor(),
2053            Action::FileExplorerUp => self.file_explorer_navigate_up(),
2054            Action::FileExplorerDown => self.file_explorer_navigate_down(),
2055            Action::FileExplorerPageUp => self.file_explorer_page_up(),
2056            Action::FileExplorerPageDown => self.file_explorer_page_down(),
2057            Action::FileExplorerExpand => self.file_explorer_toggle_expand(),
2058            Action::FileExplorerCollapse => self.file_explorer_collapse(),
2059            Action::FileExplorerOpen => self.file_explorer_open_file()?,
2060            Action::FileExplorerRefresh => self.file_explorer_refresh(),
2061            Action::FileExplorerNewFile => self.file_explorer_new_file(),
2062            Action::FileExplorerNewDirectory => self.file_explorer_new_directory(),
2063            Action::FileExplorerDelete => self.file_explorer_delete(),
2064            Action::FileExplorerRename => self.file_explorer_rename(),
2065            Action::FileExplorerToggleHidden => self.file_explorer_toggle_hidden(),
2066            Action::FileExplorerToggleGitignored => self.file_explorer_toggle_gitignored(),
2067            Action::FileExplorerSearchClear => {
2068                self.active_window_mut().file_explorer_search_clear()
2069            }
2070            Action::FileExplorerSearchBackspace => {
2071                self.active_window_mut().file_explorer_search_pop_char()
2072            }
2073            Action::FileExplorerCopy => self.active_window_mut().file_explorer_copy(),
2074            Action::FileExplorerCut => self.active_window_mut().file_explorer_cut(),
2075            Action::FileExplorerPaste => self.file_explorer_paste(),
2076            Action::FileExplorerDuplicate => self.file_explorer_duplicate(),
2077            Action::FileExplorerCopyFullPath => self.file_explorer_copy_path(false),
2078            Action::FileExplorerCopyRelativePath => self.file_explorer_copy_path(true),
2079            Action::FileExplorerExtendSelectionUp => {
2080                self.active_window_mut().file_explorer_extend_selection_up()
2081            }
2082            Action::FileExplorerExtendSelectionDown => self
2083                .active_window_mut()
2084                .file_explorer_extend_selection_down(),
2085            Action::FileExplorerToggleSelect => {
2086                self.active_window_mut().file_explorer_toggle_select()
2087            }
2088            Action::FileExplorerSelectAll => self.active_window_mut().file_explorer_select_all(),
2089            Action::RemoveSecondaryCursors => {
2090                // Convert action to events and apply them
2091                if let Some(events) = self
2092                    .active_window_mut()
2093                    .action_to_events(Action::RemoveSecondaryCursors)
2094                {
2095                    // Wrap in batch for atomic undo
2096                    let batch = Event::Batch {
2097                        events: events.clone(),
2098                        description: "Remove secondary cursors".to_string(),
2099                    };
2100                    self.active_event_log_mut().append(batch.clone());
2101                    self.apply_event_to_active_buffer(&batch);
2102
2103                    // Ensure the primary cursor is visible after removing secondary cursors
2104                    let active_split = self
2105                        .windows
2106                        .get(&self.active_window)
2107                        .and_then(|w| w.buffers.splits())
2108                        .map(|(mgr, _)| mgr)
2109                        .expect("active window must have a populated split layout")
2110                        .active_split();
2111                    let active_buffer = self.active_buffer();
2112                    self.active_window_mut()
2113                        .ensure_cursor_visible_for_split(active_buffer, active_split);
2114                }
2115            }
2116
2117            // Menu navigation actions
2118            Action::MenuActivate => {
2119                self.handle_menu_activate();
2120            }
2121            Action::MenuClose => {
2122                self.handle_menu_close();
2123            }
2124            Action::MenuLeft => {
2125                self.handle_menu_left();
2126            }
2127            Action::MenuRight => {
2128                self.handle_menu_right();
2129            }
2130            Action::MenuUp => {
2131                self.handle_menu_up();
2132            }
2133            Action::MenuDown => {
2134                self.handle_menu_down();
2135            }
2136            Action::MenuExecute => {
2137                if let Some(action) = self.handle_menu_execute() {
2138                    return self.handle_action(action);
2139                }
2140            }
2141            Action::MenuOpen(menu_name) => {
2142                if self.config.editor.menu_bar_mnemonics {
2143                    self.handle_menu_open(&menu_name);
2144                }
2145            }
2146
2147            Action::SwitchKeybindingMap(map_name) => {
2148                // Check if the map exists (either built-in or user-defined)
2149                let is_builtin =
2150                    matches!(map_name.as_str(), "default" | "emacs" | "vscode" | "macos");
2151                let is_user_defined = self.config.keybinding_maps.contains_key(&map_name);
2152
2153                if is_builtin || is_user_defined {
2154                    // Update the active keybinding map in config
2155                    self.config_mut().active_keybinding_map = map_name.clone().into();
2156
2157                    // Reload the keybinding resolver with the new map
2158                    *self.keybindings.write().unwrap() =
2159                        crate::input::keybindings::KeybindingResolver::new(&self.config);
2160
2161                    self.set_status_message(
2162                        t!("view.keybindings_switched", map = map_name).to_string(),
2163                    );
2164                } else {
2165                    self.set_status_message(
2166                        t!("view.keybindings_unknown", map = map_name).to_string(),
2167                    );
2168                }
2169            }
2170
2171            Action::SmartHome => {
2172                // In composite (diff) views, use LineStart movement
2173                let buffer_id = self.active_buffer();
2174                if self.active_window().is_composite_buffer(buffer_id) {
2175                    if let Some(_handled) =
2176                        self.handle_composite_action(buffer_id, &Action::SmartHome)
2177                    {
2178                        return Ok(());
2179                    }
2180                }
2181                self.smart_home();
2182            }
2183            Action::ToggleComment => {
2184                self.toggle_comment();
2185            }
2186            Action::ToggleFold => {
2187                self.active_window_mut().toggle_fold_at_cursor();
2188            }
2189            Action::GoToMatchingBracket => {
2190                self.goto_matching_bracket();
2191            }
2192            Action::JumpToNextError => {
2193                self.jump_to_next_error();
2194            }
2195            Action::JumpToPreviousError => {
2196                self.jump_to_previous_error();
2197            }
2198            Action::SetBookmark(key) => {
2199                self.active_window_mut().set_bookmark(key);
2200            }
2201            Action::JumpToBookmark(key) => {
2202                self.jump_to_bookmark(key);
2203            }
2204            Action::ClearBookmark(key) => {
2205                self.active_window_mut().clear_bookmark(key);
2206            }
2207            Action::ListBookmarks => {
2208                self.active_window_mut().list_bookmarks();
2209            }
2210            Action::ToggleSearchCaseSensitive => {
2211                self.active_window_mut().search_case_sensitive =
2212                    !self.active_window().search_case_sensitive;
2213                let state = if self.active_window().search_case_sensitive {
2214                    "enabled"
2215                } else {
2216                    "disabled"
2217                };
2218                self.set_status_message(
2219                    t!("search.case_sensitive_state", state = state).to_string(),
2220                );
2221                // Update incremental highlights if in search prompt, otherwise re-run completed search
2222                // Check prompt FIRST since we want to use current prompt input, not stale search_state
2223                if let Some(prompt) = &self.active_window_mut().prompt {
2224                    if matches!(
2225                        prompt.prompt_type,
2226                        PromptType::Search
2227                            | PromptType::ReplaceSearch
2228                            | PromptType::QueryReplaceSearch
2229                    ) {
2230                        let query = prompt.input.clone();
2231                        self.update_search_highlights(&query);
2232                    }
2233                } else if let Some(search_state) = &self.active_window().search_state {
2234                    let query = search_state.query.clone();
2235                    self.perform_search(&query);
2236                }
2237            }
2238            Action::ToggleSearchWholeWord => {
2239                self.active_window_mut().search_whole_word =
2240                    !self.active_window().search_whole_word;
2241                let state = if self.active_window().search_whole_word {
2242                    "enabled"
2243                } else {
2244                    "disabled"
2245                };
2246                self.set_status_message(t!("search.whole_word_state", state = state).to_string());
2247                // Update incremental highlights if in search prompt, otherwise re-run completed search
2248                // Check prompt FIRST since we want to use current prompt input, not stale search_state
2249                if let Some(prompt) = &self.active_window_mut().prompt {
2250                    if matches!(
2251                        prompt.prompt_type,
2252                        PromptType::Search
2253                            | PromptType::ReplaceSearch
2254                            | PromptType::QueryReplaceSearch
2255                    ) {
2256                        let query = prompt.input.clone();
2257                        self.update_search_highlights(&query);
2258                    }
2259                } else if let Some(search_state) = &self.active_window().search_state {
2260                    let query = search_state.query.clone();
2261                    self.perform_search(&query);
2262                }
2263            }
2264            Action::ToggleSearchRegex => {
2265                self.active_window_mut().search_use_regex = !self.active_window().search_use_regex;
2266                let state = if self.active_window().search_use_regex {
2267                    "enabled"
2268                } else {
2269                    "disabled"
2270                };
2271                self.set_status_message(t!("search.regex_state", state = state).to_string());
2272                // Update incremental highlights if in search prompt, otherwise re-run completed search
2273                // Check prompt FIRST since we want to use current prompt input, not stale search_state
2274                if let Some(prompt) = &self.active_window_mut().prompt {
2275                    if matches!(
2276                        prompt.prompt_type,
2277                        PromptType::Search
2278                            | PromptType::ReplaceSearch
2279                            | PromptType::QueryReplaceSearch
2280                    ) {
2281                        let query = prompt.input.clone();
2282                        self.update_search_highlights(&query);
2283                    }
2284                } else if let Some(search_state) = &self.active_window().search_state {
2285                    let query = search_state.query.clone();
2286                    self.perform_search(&query);
2287                }
2288            }
2289            Action::ToggleSearchConfirmEach => {
2290                self.active_window_mut().search_confirm_each =
2291                    !self.active_window().search_confirm_each;
2292                let state = if self.active_window().search_confirm_each {
2293                    "enabled"
2294                } else {
2295                    "disabled"
2296                };
2297                self.set_status_message(t!("search.confirm_each_state", state = state).to_string());
2298            }
2299            Action::FileBrowserToggleHidden => {
2300                // Toggle hidden files in file browser (handled via file_open_toggle_hidden)
2301                self.file_open_toggle_hidden();
2302            }
2303            Action::StartMacroRecording => {
2304                // This is a no-op; use ToggleMacroRecording instead
2305                self.set_status_message(
2306                    "Use Ctrl+Shift+R to start recording (will prompt for register)".to_string(),
2307                );
2308            }
2309            Action::StopMacroRecording => {
2310                self.stop_macro_recording();
2311            }
2312            Action::PlayMacro(key) => {
2313                self.play_macro(key);
2314            }
2315            Action::ToggleMacroRecording(key) => {
2316                self.toggle_macro_recording(key);
2317            }
2318            Action::ShowMacro(key) => {
2319                self.show_macro_in_buffer(key);
2320            }
2321            Action::ListMacros => {
2322                self.list_macros_in_buffer();
2323            }
2324            Action::PromptRecordMacro => {
2325                self.start_prompt("Record macro (0-9): ".to_string(), PromptType::RecordMacro);
2326            }
2327            Action::PromptPlayMacro => {
2328                self.start_prompt("Play macro (0-9): ".to_string(), PromptType::PlayMacro);
2329            }
2330            Action::PlayLastMacro => {
2331                if let Some(key) = self.active_window_mut().macros.last_register() {
2332                    self.play_macro(key);
2333                } else {
2334                    self.set_status_message(t!("status.no_macro_recorded").to_string());
2335                }
2336            }
2337            Action::PromptSetBookmark => {
2338                self.start_prompt("Set bookmark (0-9): ".to_string(), PromptType::SetBookmark);
2339            }
2340            Action::PromptJumpToBookmark => {
2341                self.start_prompt(
2342                    "Jump to bookmark (0-9): ".to_string(),
2343                    PromptType::JumpToBookmark,
2344                );
2345            }
2346            Action::CompositeNextHunk => {
2347                let buf = self.active_buffer();
2348                self.active_window_mut().composite_next_hunk_active(buf);
2349            }
2350            Action::CompositePrevHunk => {
2351                let buf = self.active_buffer();
2352                self.active_window_mut().composite_prev_hunk_active(buf);
2353            }
2354            Action::None => {}
2355            Action::DeleteBackward => {
2356                if self.active_window().is_editing_disabled() {
2357                    self.set_status_message(t!("buffer.editing_disabled").to_string());
2358                    return Ok(());
2359                }
2360                // Normal backspace handling
2361                if let Some(events) = self
2362                    .active_window_mut()
2363                    .action_to_events(Action::DeleteBackward)
2364                {
2365                    if events.len() > 1 {
2366                        // Multi-cursor: use optimized bulk edit (O(n) instead of O(n²))
2367                        let description = "Delete backward".to_string();
2368                        if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description)
2369                        {
2370                            self.active_event_log_mut().append(bulk_edit);
2371                        }
2372                    } else {
2373                        for event in events {
2374                            self.active_event_log_mut().append(event.clone());
2375                            self.apply_event_to_active_buffer(&event);
2376                        }
2377                    }
2378                }
2379            }
2380            Action::PluginAction(action_name) => {
2381                tracing::debug!("handle_action: PluginAction('{}')", action_name);
2382                // Execute the plugin callback via TypeScript plugin thread
2383                // Use non-blocking version to avoid deadlock with async plugin ops
2384                #[cfg(feature = "plugins")]
2385                {
2386                    let result = self
2387                        .plugin_manager
2388                        .read()
2389                        .unwrap()
2390                        .execute_action_async(&action_name);
2391                    if let Some(result) = result {
2392                        match result {
2393                            Ok(receiver) => {
2394                                // Store pending action for processing in main loop
2395                                self.pending_plugin_actions
2396                                    .push((action_name.clone(), receiver));
2397                            }
2398                            Err(e) => {
2399                                self.set_status_message(
2400                                    t!("view.plugin_error", error = e.to_string()).to_string(),
2401                                );
2402                                tracing::error!("Plugin action error: {}", e);
2403                            }
2404                        }
2405                    } else {
2406                        self.set_status_message(
2407                            t!("status.plugin_manager_unavailable").to_string(),
2408                        );
2409                    }
2410                }
2411                #[cfg(not(feature = "plugins"))]
2412                {
2413                    let _ = action_name;
2414                    self.set_status_message(
2415                        "Plugins not available (compiled without plugin support)".to_string(),
2416                    );
2417                }
2418            }
2419            Action::LoadPluginFromBuffer => {
2420                #[cfg(feature = "plugins")]
2421                {
2422                    let buffer_id = self.active_buffer();
2423                    let state = self.active_state();
2424                    let buffer = &state.buffer;
2425                    let total = buffer.total_bytes();
2426                    let content =
2427                        String::from_utf8_lossy(&buffer.slice_bytes(0..total)).to_string();
2428
2429                    // Determine if TypeScript from file extension, default to TS
2430                    let is_ts = buffer
2431                        .file_path()
2432                        .and_then(|p| p.extension())
2433                        .and_then(|e| e.to_str())
2434                        .map(|e| e == "ts" || e == "tsx")
2435                        .unwrap_or(true);
2436
2437                    // Derive plugin name from buffer filename
2438                    let name = buffer
2439                        .file_path()
2440                        .and_then(|p| p.file_name())
2441                        .and_then(|s| s.to_str())
2442                        .map(|s| s.to_string())
2443                        .unwrap_or_else(|| "buffer-plugin".to_string());
2444
2445                    let load_result = self
2446                        .plugin_manager
2447                        .read()
2448                        .unwrap()
2449                        .load_plugin_from_source(&content, &name, is_ts);
2450                    match load_result {
2451                        Ok(()) => {
2452                            self.set_status_message(format!(
2453                                "Plugin '{}' loaded from buffer",
2454                                name
2455                            ));
2456                        }
2457                        Err(e) => {
2458                            self.set_status_message(format!("Failed to load plugin: {}", e));
2459                            tracing::error!("LoadPluginFromBuffer error: {}", e);
2460                        }
2461                    }
2462
2463                    // Set up plugin dev workspace for LSP support
2464                    self.setup_plugin_dev_lsp(buffer_id, &content);
2465                }
2466                #[cfg(not(feature = "plugins"))]
2467                {
2468                    self.set_status_message(
2469                        "Plugins not available (compiled without plugin support)".to_string(),
2470                    );
2471                }
2472            }
2473            Action::InitReload => {
2474                // Same code path as auto-load: read init.ts and push it
2475                // through the existing plugin pipeline. The runtime's
2476                // hot-reload semantics drop prior commands / handlers /
2477                // event subs / settings before the new source runs.
2478                self.load_init_script(true);
2479                // Re-fire plugins_loaded so handlers expecting a "fresh"
2480                // post-load environment (M2) see it.
2481                self.fire_plugins_loaded_hook();
2482            }
2483            Action::InitEdit => {
2484                // Ensure the file exists (create from template if absent),
2485                // then open it in the editor so users can edit + reload.
2486                let config_dir = self.dir_context.config_dir.clone();
2487                match crate::init_script::ensure_starter(&config_dir) {
2488                    Ok(path) => {
2489                        // Regenerate `types/plugins.d.ts` from the live plugin
2490                        // set. It's written once at editor startup, but any
2491                        // plugin loaded/reloaded/unloaded since then would
2492                        // leave the aggregate stale (or missing, in builds
2493                        // where the plugins feature was off at boot but the
2494                        // user has since enabled a plugin). The user's
2495                        // tsconfig.json lists this file in `files`, so a
2496                        // stale copy is exactly when `getPluginApi("foo")`
2497                        // loses its typed overload.
2498                        let declarations =
2499                            self.plugin_manager.read().unwrap().plugin_declarations();
2500                        crate::init_script::write_plugin_declarations(&config_dir, &declarations);
2501                        match self.open_file(&path) {
2502                            Ok(_) => {
2503                                self.set_status_message(format!("init.ts: {}", path.display()));
2504                            }
2505                            Err(e) => {
2506                                self.set_status_message(format!("init.ts: open failed: {e}"));
2507                            }
2508                        }
2509                    }
2510                    Err(e) => {
2511                        self.set_status_message(format!("init.ts: create failed: {e}"));
2512                    }
2513                }
2514            }
2515            Action::InitCheck => {
2516                // Run the same parse check as `fresh --cmd init check` but
2517                // surface results in the status bar.
2518                let report = crate::init_script::check(&self.dir_context.config_dir);
2519                if report.ok && report.diagnostics.is_empty() {
2520                    self.set_status_message("init.ts: ok".into());
2521                } else if !report.ok {
2522                    let first = report
2523                        .diagnostics
2524                        .first()
2525                        .map(|d| format!("{}:{}: {}", d.line, d.column, d.message))
2526                        .unwrap_or_else(|| "unknown error".into());
2527                    self.set_status_message(format!(
2528                        "init.ts: {} error(s) — first: {first}",
2529                        report.diagnostics.len()
2530                    ));
2531                } else {
2532                    self.set_status_message(format!(
2533                        "init.ts: {} warning(s)",
2534                        report.diagnostics.len()
2535                    ));
2536                }
2537            }
2538            Action::OpenTerminal => {
2539                self.open_terminal();
2540            }
2541            Action::CloseTerminal => {
2542                self.close_terminal();
2543            }
2544            Action::FocusTerminal => {
2545                // If viewing a terminal buffer, switch to terminal mode
2546                if self
2547                    .active_window()
2548                    .is_terminal_buffer(self.active_buffer())
2549                {
2550                    self.active_window_mut().terminal_mode = true;
2551                    self.active_window_mut().key_context = KeyContext::Terminal;
2552                    self.set_status_message(t!("status.terminal_mode_enabled").to_string());
2553                }
2554            }
2555            Action::TerminalEscape => {
2556                // Exit terminal mode back to editor
2557                if self.active_window().terminal_mode {
2558                    self.active_window_mut().terminal_mode = false;
2559                    self.active_window_mut().key_context = KeyContext::Normal;
2560                    self.set_status_message(t!("status.terminal_mode_disabled").to_string());
2561                }
2562            }
2563            Action::ToggleKeyboardCapture => {
2564                // Toggle keyboard capture mode in terminal
2565                if self.active_window().terminal_mode {
2566                    self.active_window_mut().keyboard_capture =
2567                        !self.active_window_mut().keyboard_capture;
2568                    if self.active_window_mut().keyboard_capture {
2569                        self.set_status_message(
2570                            "Keyboard capture ON - all keys go to terminal (F9 to toggle)"
2571                                .to_string(),
2572                        );
2573                    } else {
2574                        self.set_status_message(
2575                            "Keyboard capture OFF - UI bindings active (F9 to toggle)".to_string(),
2576                        );
2577                    }
2578                }
2579            }
2580            Action::TerminalPaste => {
2581                // Paste clipboard contents into terminal as a single batch
2582                if self.active_window().terminal_mode {
2583                    if let Some(text) = self.clipboard.paste() {
2584                        self.active_window_mut()
2585                            .send_terminal_input(text.as_bytes());
2586                    }
2587                }
2588            }
2589            Action::ShellCommand => {
2590                // Run shell command on buffer/selection, output to new buffer
2591                self.start_shell_command_prompt(false);
2592            }
2593            Action::ShellCommandReplace => {
2594                // Run shell command on buffer/selection, replace content
2595                self.start_shell_command_prompt(true);
2596            }
2597            Action::OpenSettings => {
2598                self.open_settings();
2599            }
2600            Action::CloseSettings => {
2601                // Check if there are unsaved changes
2602                let has_changes = self
2603                    .settings_state
2604                    .as_ref()
2605                    .is_some_and(|s| s.has_changes());
2606                if has_changes {
2607                    // Show confirmation dialog
2608                    if let Some(ref mut state) = self.settings_state {
2609                        state.show_confirm_dialog();
2610                    }
2611                } else {
2612                    self.close_settings(false);
2613                }
2614            }
2615            Action::SettingsSave => {
2616                self.save_settings();
2617            }
2618            Action::SettingsReset => {
2619                if let Some(ref mut state) = self.settings_state {
2620                    state.reset_current_to_default();
2621                }
2622            }
2623            Action::SettingsInherit => {
2624                if let Some(ref mut state) = self.settings_state {
2625                    state.set_current_to_null();
2626                }
2627            }
2628            Action::SettingsToggleFocus => {
2629                if let Some(ref mut state) = self.settings_state {
2630                    state.toggle_focus();
2631                }
2632            }
2633            Action::SettingsActivate => {
2634                self.settings_activate_current();
2635            }
2636            Action::SettingsSearch => {
2637                if let Some(ref mut state) = self.settings_state {
2638                    state.start_search();
2639                }
2640            }
2641            Action::SettingsHelp => {
2642                if let Some(ref mut state) = self.settings_state {
2643                    state.toggle_help();
2644                }
2645            }
2646            Action::SettingsIncrement => {
2647                self.settings_increment_current();
2648            }
2649            Action::SettingsDecrement => {
2650                self.settings_decrement_current();
2651            }
2652            Action::CalibrateInput => {
2653                self.open_calibration_wizard();
2654            }
2655            Action::EventDebug => {
2656                self.active_window_mut().open_event_debug();
2657            }
2658            Action::SuspendProcess => {
2659                self.request_suspend();
2660            }
2661            Action::OpenKeybindingEditor => {
2662                self.open_keybinding_editor();
2663            }
2664            Action::PromptConfirm => {
2665                if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
2666                    use super::prompt_actions::PromptResult;
2667                    match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
2668                        PromptResult::ExecuteAction(action) => {
2669                            return self.handle_action(action);
2670                        }
2671                        PromptResult::EarlyReturn => {
2672                            return Ok(());
2673                        }
2674                        PromptResult::Done => {}
2675                    }
2676                }
2677            }
2678            Action::PromptConfirmWithText(ref text) => {
2679                // For macro playback: set the prompt text before confirming
2680                if let Some(ref mut prompt) = self.active_window_mut().prompt {
2681                    prompt.set_input(text.clone());
2682                    self.update_prompt_suggestions();
2683                }
2684                if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
2685                    use super::prompt_actions::PromptResult;
2686                    match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
2687                        PromptResult::ExecuteAction(action) => {
2688                            return self.handle_action(action);
2689                        }
2690                        PromptResult::EarlyReturn => {
2691                            return Ok(());
2692                        }
2693                        PromptResult::Done => {}
2694                    }
2695                }
2696            }
2697            Action::PopupConfirm => {
2698                use super::popup_actions::PopupConfirmResult;
2699                if let PopupConfirmResult::EarlyReturn = self.handle_popup_confirm() {
2700                    return Ok(());
2701                }
2702            }
2703            Action::PopupCancel => {
2704                self.handle_popup_cancel();
2705            }
2706            Action::PopupFocus => {
2707                self.handle_popup_focus();
2708            }
2709            Action::CompletionAccept => {
2710                use super::popup_actions::PopupConfirmResult;
2711                if let PopupConfirmResult::EarlyReturn = self.handle_popup_confirm() {
2712                    return Ok(());
2713                }
2714            }
2715            Action::CompletionDismiss => {
2716                self.handle_popup_cancel();
2717            }
2718            Action::InsertChar(c) => {
2719                if self.is_prompting() {
2720                    return self.handle_insert_char_prompt(c);
2721                } else if self.active_window_mut().key_context == KeyContext::FileExplorer {
2722                    self.active_window_mut().file_explorer_search_push_char(c);
2723                } else {
2724                    self.handle_insert_char_editor(c)?;
2725                }
2726            }
2727            // Prompt clipboard actions
2728            Action::PromptCopy => {
2729                if let Some(prompt) = &self.active_window_mut().prompt {
2730                    let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
2731                    if !text.is_empty() {
2732                        self.clipboard.copy(text);
2733                        self.set_status_message(t!("clipboard.copied").to_string());
2734                    }
2735                }
2736            }
2737            Action::PromptCut => {
2738                if let Some(prompt) = &self.active_window_mut().prompt {
2739                    let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
2740                    if !text.is_empty() {
2741                        self.clipboard.copy(text);
2742                    }
2743                }
2744                if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
2745                    if prompt.has_selection() {
2746                        prompt.delete_selection();
2747                    } else {
2748                        prompt.clear();
2749                    }
2750                }
2751                self.set_status_message(t!("clipboard.cut").to_string());
2752                self.update_prompt_suggestions();
2753            }
2754            Action::PromptPaste => {
2755                if let Some(text) = self.clipboard.paste() {
2756                    if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
2757                        prompt.insert_str(&text);
2758                    }
2759                    self.update_prompt_suggestions();
2760                }
2761            }
2762            _ => {
2763                // TODO: Why do we have this catch-all? It seems like actions should either:
2764                // 1. Be handled explicitly above (like InsertChar, PopupConfirm, etc.)
2765                // 2. Or be converted to events consistently
2766                // This catch-all makes it unclear which actions go through event conversion
2767                // vs. direct handling. Consider making this explicit or removing the pattern.
2768                self.apply_action_as_events(action)?;
2769            }
2770        }
2771
2772        Ok(())
2773    }
2774
2775    /// Route a keystroke to the floating widget panel when one is
2776    /// mounted. Returns `true` if the key was consumed.
2777    ///
2778    /// Esc unmounts the panel and fires a `widget_event` `cancel`
2779    /// so the plugin can clean up its own state (clear mode, drop
2780    /// form state, etc.). Tab / S-Tab / Return / Space / Backspace /
2781    /// Delete / Home / End / Left / Right / Up / Down route through
2782    /// the same smart-key dispatch the bound mode handlers would
2783    /// use. Printable characters feed `textInputChar` to the
2784    /// currently focused TextInput.
2785    fn dispatch_floating_widget_key(
2786        &mut self,
2787        code: crossterm::event::KeyCode,
2788        modifiers: crossterm::event::KeyModifiers,
2789    ) -> bool {
2790        use crossterm::event::{KeyCode, KeyModifiers};
2791        let panel_id = match self.floating_widget_panel.as_ref() {
2792            Some(fwp) => fwp.panel_id,
2793            None => return false,
2794        };
2795        let key_name: Option<&str> = match code {
2796            KeyCode::Esc => {
2797                // Mode-binding precedence: a plugin's `defineMode`
2798                // entry for Escape wins over the default
2799                // "Esc closes the modal" behaviour. Mirrors the
2800                // same has_explicit_binding check the named-key
2801                // and Ctrl/Alt-char branches below already run.
2802                // Lets a plugin claim Esc for a nested
2803                // dismiss-the-dropdown gesture before the
2804                // outermost cancel fires.
2805                let mode_has_binding = self
2806                    .active_window()
2807                    .editor_mode
2808                    .as_ref()
2809                    .map(|mode_name| {
2810                        let key_event = crossterm::event::KeyEvent::new(code, modifiers);
2811                        let mode_ctx =
2812                            crate::input::keybindings::KeyContext::Mode(mode_name.to_string());
2813                        let keybindings = self.keybindings.read().unwrap();
2814                        keybindings.has_explicit_binding(&key_event, &mode_ctx)
2815                    })
2816                    .unwrap_or(false);
2817                if mode_has_binding {
2818                    return false;
2819                }
2820                let widget_key = self
2821                    .widget_registry
2822                    .get(panel_id)
2823                    .map(|p| p.focus_key.clone())
2824                    .unwrap_or_default();
2825                if self
2826                    .plugin_manager
2827                    .read()
2828                    .unwrap()
2829                    .has_hook_handlers("widget_event")
2830                {
2831                    self.plugin_manager.read().unwrap().run_hook(
2832                        "widget_event",
2833                        crate::services::plugins::hooks::HookArgs::WidgetEvent {
2834                            panel_id,
2835                            widget_key,
2836                            event_type: "cancel".to_string(),
2837                            payload: serde_json::json!({}),
2838                        },
2839                    );
2840                }
2841                self.floating_widget_panel = None;
2842                let _ = self.widget_registry.unmount(panel_id);
2843                return true;
2844            }
2845            KeyCode::Tab => Some(if modifiers.contains(KeyModifiers::SHIFT) {
2846                "Shift+Tab"
2847            } else {
2848                "Tab"
2849            }),
2850            KeyCode::BackTab => Some("Shift+Tab"),
2851            KeyCode::Enter => Some("Enter"),
2852            KeyCode::Backspace => Some("Backspace"),
2853            KeyCode::Delete => Some("Delete"),
2854            KeyCode::Home => Some("Home"),
2855            KeyCode::End => Some("End"),
2856            KeyCode::Left => Some("Left"),
2857            KeyCode::Right => Some("Right"),
2858            KeyCode::Up => Some("Up"),
2859            KeyCode::Down => Some("Down"),
2860            KeyCode::PageUp => Some("PageUp"),
2861            KeyCode::PageDown => Some("PageDown"),
2862            _ => None,
2863        };
2864        if let Some(name) = key_name {
2865            // Mode-binding precedence: if the active editor mode has a
2866            // plugin-defined binding for this key, let it win instead
2867            // of applying the floating panel's default smart-key
2868            // behaviour. This is what `defineMode` exists for — a
2869            // plugin saying "in MY mode, Enter does X" must be
2870            // authoritative, not silently overridden by the host's
2871            // generic "Enter = focus-advance" default. The orchestrator
2872            // New-Session form relies on this so Enter submits the
2873            // form regardless of which field is focused (matching the
2874            // dialog's `Enter: submit` hint).
2875            //
2876            // Important: only count bindings that are *explicitly* set
2877            // for the mode (user / default / plugin defaults). The
2878            // resolver's full `resolve()` falls back to Normal-context
2879            // bindings for any mode, which would falsely report Enter
2880            // as bound everywhere (Normal's Enter inserts a newline).
2881            // We check the three context-scoped maps directly so the
2882            // Normal-fallback path doesn't taint the precedence check.
2883            let mode_has_binding = self
2884                .active_window()
2885                .editor_mode
2886                .as_ref()
2887                .map(|mode_name| {
2888                    let key_event = crossterm::event::KeyEvent::new(code, modifiers);
2889                    let mode_ctx =
2890                        crate::input::keybindings::KeyContext::Mode(mode_name.to_string());
2891                    let keybindings = self.keybindings.read().unwrap();
2892                    keybindings.has_explicit_binding(&key_event, &mode_ctx)
2893                })
2894                .unwrap_or(false);
2895            if mode_has_binding {
2896                return false;
2897            }
2898            self.handle_widget_command(
2899                panel_id,
2900                fresh_core::api::WidgetAction::Key {
2901                    key: name.to_string(),
2902                },
2903            );
2904            return true;
2905        }
2906        if let KeyCode::Char(c) = code {
2907            // The active editor mode may have explicitly claimed this
2908            // char via `defineMode` — e.g. the Orchestrator picker
2909            // binds `Alt+N` (new session), `Alt+P` (scope), and `/`
2910            // (focus filter). Defer to that path so plugin-declared
2911            // modal shortcuts work. This now covers *plain* chars too
2912            // (not just Ctrl/Alt chords): a plugin that binds a bare
2913            // key like `/` gets it before the text-input fast path.
2914            // The trade-off is that a bound bare key can't also be
2915            // typed as text in that mode, which is what the plugin
2916            // asked for by binding it.
2917            {
2918                let mode_has_binding = self
2919                    .active_window()
2920                    .editor_mode
2921                    .as_ref()
2922                    .map(|mode_name| {
2923                        let key_event = crossterm::event::KeyEvent::new(code, modifiers);
2924                        let mode_ctx =
2925                            crate::input::keybindings::KeyContext::Mode(mode_name.to_string());
2926                        let keybindings = self.keybindings.read().unwrap();
2927                        keybindings.has_explicit_binding(&key_event, &mode_ctx)
2928                    })
2929                    .unwrap_or(false);
2930                if mode_has_binding {
2931                    return false;
2932                }
2933            }
2934            // Ctrl/Alt-modified chords with no mode binding are
2935            // swallowed by the floating panel without further action —
2936            // a modal dialog must not leak keys to global bindings
2937            // like Ctrl-P or Alt-F. Plain (or Shift-only) chars feed
2938            // printable text into the focused TextInput.
2939            if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) {
2940                return true;
2941            }
2942            let ch = if modifiers.contains(KeyModifiers::SHIFT) {
2943                c.to_uppercase().next().unwrap_or(c)
2944            } else {
2945                c
2946            };
2947            // Space is a special case on a focused Toggle / Button:
2948            // the convention is "Space activates the focused
2949            // control", not "insert a literal space". Route it
2950            // through the smart-key dispatcher (which fires
2951            // `widget_event { event_type: "toggle" }` on a Toggle,
2952            // `activate` on a Button) instead of the text-input
2953            // fast path. For a focused Text widget the smart-key
2954            // dispatcher still inserts " " as a char, so typing
2955            // spaces into Project Path / Agent Command keeps
2956            // working.
2957            if ch == ' ' {
2958                self.handle_widget_command(
2959                    panel_id,
2960                    fresh_core::api::WidgetAction::Key {
2961                        key: "Space".to_string(),
2962                    },
2963                );
2964                return true;
2965            }
2966            self.handle_widget_command(
2967                panel_id,
2968                fresh_core::api::WidgetAction::TextInputChar {
2969                    text: ch.to_string(),
2970                },
2971            );
2972            return true;
2973        }
2974        // Any other keystroke that reaches here (function keys,
2975        // unhandled keycodes, etc.) is swallowed too — the modal
2976        // is the exclusive owner of the input channel until it
2977        // unmounts.
2978        true
2979    }
2980}