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