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