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