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