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.pending_next_key_callbacks.pop_front() {
60            let json = serde_json::to_string(&payload).unwrap_or_else(|_| "null".to_string());
61            self.plugin_manager.resolve_callback(callback_id, json);
62            return true;
63        }
64        if self.key_capture_active {
65            self.pending_key_capture_buffer.push_back(payload);
66            return true;
67        }
68        false
69    }
70}
71
72impl Editor {
73    /// Whether editor-pane popups (LSP completion, hover, signature help,
74    /// global plugin popups, …) should intercept keyboard input.
75    ///
76    /// Returns `false` when the user has focus on the file explorer pane:
77    /// popups belong to the editor pane, and the explorer must own its own
78    /// keystrokes — otherwise an LSP completion popup that happened to be
79    /// open would silently swallow Down/Up while the user is browsing the
80    /// tree. Buffer-switch handlers (e.g. `open_file_preview`) clear stale
81    /// popups so a popup tied to the previous preview doesn't follow the
82    /// user across buffers.
83    ///
84    /// Single source of truth for both `get_key_context` (binding resolution)
85    /// and `dispatch_modal_input` (handler routing) so the two cannot drift.
86    pub(crate) fn popups_capture_keys(&self) -> bool {
87        use crate::input::keybindings::KeyContext;
88        !matches!(self.key_context, KeyContext::FileExplorer)
89    }
90
91    /// Resolve a key event against `KeyContext::Completion` when the topmost
92    /// visible popup is a completion popup. Only `CompletionAccept` and
93    /// `CompletionDismiss` are recognised here — every other key falls
94    /// through to the popup's own handler so type-to-filter, navigation, and
95    /// the "any other key dismisses + passthrough" behaviours stay intact.
96    pub(crate) fn resolve_completion_popup_action(
97        &self,
98        event: &crossterm::event::KeyEvent,
99    ) -> Option<crate::input::keybindings::Action> {
100        use crate::input::keybindings::{Action, KeyContext};
101        use crate::view::popup::PopupKind;
102
103        let topmost_kind = if self.global_popups.is_visible() {
104            self.global_popups.top().map(|p| p.kind)
105        } else if self.active_state().popups.is_visible() {
106            self.active_state().popups.top().map(|p| p.kind)
107        } else {
108            None
109        };
110
111        if topmost_kind != Some(PopupKind::Completion) {
112            return None;
113        }
114
115        match self
116            .keybindings
117            .read()
118            .unwrap()
119            .resolve_in_context_only(event, KeyContext::Completion)
120        {
121            Some(action @ (Action::CompletionAccept | Action::CompletionDismiss)) => Some(action),
122            _ => None,
123        }
124    }
125
126    /// Determine the current keybinding context based on UI state
127    pub fn get_key_context(&self) -> crate::input::keybindings::KeyContext {
128        use crate::input::keybindings::KeyContext;
129
130        // Priority order: Settings > Menu > Prompt > Popup (only when
131        // editor-pane focused) > CompositeBuffer > Current context
132        // (FileExplorer or Normal).
133        if self.settings_state.as_ref().is_some_and(|s| s.visible) {
134            KeyContext::Settings
135        } else if self.menu_state.active_menu.is_some() {
136            KeyContext::Menu
137        } else if self.is_prompting() {
138            KeyContext::Prompt
139        } else if self.popups_capture_keys()
140            && (self.global_popups.is_visible() || self.active_state().popups.is_visible())
141        {
142            KeyContext::Popup
143        } else if self.is_composite_buffer(self.active_buffer()) {
144            KeyContext::CompositeBuffer
145        } else {
146            // Use the current context (can be FileExplorer or Normal)
147            self.key_context.clone()
148        }
149    }
150
151    /// Handle a key event and return whether it was handled
152    /// This is the central key handling logic used by both main.rs and tests
153    pub fn handle_key(
154        &mut self,
155        code: crossterm::event::KeyCode,
156        modifiers: crossterm::event::KeyModifiers,
157    ) -> AnyhowResult<()> {
158        use crate::input::keybindings::Action;
159
160        let _t_total = std::time::Instant::now();
161
162        tracing::trace!(
163            "Editor.handle_key: code={:?}, modifiers={:?}",
164            code,
165            modifiers
166        );
167
168        // Create key event for dispatch methods
169        let key_event = crossterm::event::KeyEvent::new(code, modifiers);
170
171        // Event debug dialog intercepts ALL key events before any other processing.
172        // This must be checked here (not just in main.rs/gui) so it works in
173        // client/server mode where handle_key is called directly.
174        if self.is_event_debug_active() {
175            self.handle_event_debug_input(&key_event);
176            return Ok(());
177        }
178
179        // Try terminal input dispatch first (handles terminal mode and re-entry)
180        if self.dispatch_terminal_input(&key_event).is_some() {
181            return Ok(());
182        }
183
184        // If a plugin is awaiting the next keypress (`editor.getNextKey()`),
185        // hand this key to the front-most pending callback and consume it.
186        // This must run before any other dispatch so the awaiting plugin —
187        // typically running a short input loop (flash labels, vi
188        // find-char/replace-char) — can drive its own state machine
189        // without binding every printable key in `defineMode`.
190        if self.try_resolve_next_key_callback(&key_event) {
191            return Ok(());
192        }
193
194        // Clear skip_ensure_visible flag so cursor becomes visible after key press
195        // (scroll actions will set it again if needed). Use the *effective*
196        // active split so this clears the flag on a focused buffer-group
197        // panel's own view state, not the group host's — without this, a
198        // scroll action in the panel (mouse scrollbar click, plugin
199        // scrollBufferToLine, etc.) sets `skip_ensure_visible` on the panel
200        // and subsequent key presses never clear it, so cursor motion stops
201        // scrolling the viewport.
202        let active_split = self.effective_active_split();
203        if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
204            view_state.viewport.clear_skip_ensure_visible();
205        }
206
207        // Dismiss theme info popup on any key press
208        if self.theme_info_popup.is_some() {
209            self.theme_info_popup = None;
210        }
211
212        if self.file_explorer_context_menu.is_some() {
213            if let Some(result) = self.handle_file_explorer_context_menu_key(code, modifiers) {
214                return result;
215            }
216        }
217
218        // Determine the current context first
219        let mut context = self.get_key_context();
220
221        // Special case: Hover and Signature Help popups should be dismissed on any key press
222        // EXCEPT for Ctrl+C when the popup has a text selection (allow copy first)
223        if matches!(context, crate::input::keybindings::KeyContext::Popup) {
224            // Check if the current popup is transient (hover, signature help).
225            // Editor-level popups always take precedence over buffer popups
226            // when both are visible — they're effectively modal overlays.
227            let (is_transient_popup, has_selection) = {
228                let popup = self
229                    .global_popups
230                    .top()
231                    .or_else(|| self.active_state().popups.top());
232                (
233                    popup.is_some_and(|p| p.transient),
234                    popup.is_some_and(|p| p.has_selection()),
235                )
236            };
237
238            // Don't dismiss if popup has selection and user is pressing Ctrl+C (let them copy first)
239            let is_copy_key = key_event.code == crossterm::event::KeyCode::Char('c')
240                && key_event
241                    .modifiers
242                    .contains(crossterm::event::KeyModifiers::CONTROL);
243
244            if is_transient_popup && !(has_selection && is_copy_key) {
245                // Dismiss the popup on any key press (except Ctrl+C with selection)
246                self.hide_popup();
247                tracing::debug!("Dismissed transient popup on key press");
248                // Recalculate context now that popup is gone
249                context = self.get_key_context();
250            }
251        }
252
253        // Try hierarchical modal input dispatch first (Settings, Menu, Prompt, Popup)
254        if self.dispatch_modal_input(&key_event).is_some() {
255            return Ok(());
256        }
257
258        // If a modal was dismissed (e.g., completion popup closed and returned Ignored),
259        // recalculate the context so the key is processed in the correct context.
260        if context != self.get_key_context() {
261            context = self.get_key_context();
262        }
263
264        // Only check buffer mode keybindings when the editor buffer has focus.
265        // FileExplorer, Menu, Prompt, Popup contexts should not trigger mode bindings
266        // (e.g. markdown-source's Enter handler should not fire while the explorer is focused).
267        let should_check_mode_bindings =
268            matches!(context, crate::input::keybindings::KeyContext::Normal);
269
270        if should_check_mode_bindings {
271            // effective_mode() returns buffer-local mode if present, else global mode.
272            // This ensures virtual buffer modes aren't hijacked by global modes.
273            let effective_mode = self.effective_mode().map(|s| s.to_owned());
274
275            if let Some(ref mode_name) = effective_mode {
276                let mode_ctx = crate::input::keybindings::KeyContext::Mode(mode_name.to_string());
277                let key_event = crossterm::event::KeyEvent::new(code, modifiers);
278
279                // Mode chord resolution (via KeybindingResolver)
280                let (chord_result, resolved_action) = {
281                    let keybindings = self.keybindings.read().unwrap();
282                    let chord_result =
283                        keybindings.resolve_chord(&self.chord_state, &key_event, mode_ctx.clone());
284                    let resolved = keybindings.resolve(&key_event, mode_ctx);
285                    (chord_result, resolved)
286                };
287                match chord_result {
288                    crate::input::keybindings::ChordResolution::Complete(action) => {
289                        tracing::debug!("Mode chord resolved to action: {:?}", action);
290                        self.chord_state.clear();
291                        return self.handle_action(action);
292                    }
293                    crate::input::keybindings::ChordResolution::Partial => {
294                        tracing::debug!("Potential chord prefix in mode '{}'", mode_name);
295                        self.chord_state.push((code, modifiers));
296                        return Ok(());
297                    }
298                    crate::input::keybindings::ChordResolution::NoMatch => {
299                        if !self.chord_state.is_empty() {
300                            tracing::debug!("Chord sequence abandoned in mode, clearing state");
301                            self.chord_state.clear();
302                        }
303                    }
304                }
305
306                // Mode single-key resolution (custom > keymap > plugin defaults)
307                if resolved_action != Action::None {
308                    return self.handle_action(resolved_action);
309                }
310            }
311
312            // Handle unbound keys for modes that want to capture input.
313            //
314            // Buffer-local modes with allow_text_input (e.g. search-replace-list)
315            // capture character keys and block other unbound keys.
316            //
317            // Buffer-local modes WITHOUT allow_text_input (e.g. diff-view) let
318            // unbound keys fall through to normal keybinding handling so that
319            // Ctrl+C, arrows, etc. still work.
320            //
321            // Global editor modes (e.g. vi-normal) block all unbound keys when
322            // read-only.
323            if let Some(ref mode_name) = effective_mode {
324                if self.mode_registry.allows_text_input(mode_name) {
325                    if let KeyCode::Char(c) = code {
326                        let ch = if modifiers.contains(KeyModifiers::SHIFT) {
327                            c.to_uppercase().next().unwrap_or(c)
328                        } else {
329                            c
330                        };
331                        if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) {
332                            let action_name = format!("mode_text_input:{}", ch);
333                            return self.handle_action(Action::PluginAction(action_name));
334                        }
335                    }
336                    tracing::debug!("Blocking unbound key in text-input mode '{}'", mode_name);
337                    return Ok(());
338                }
339            }
340            if let Some(ref mode_name) = self.editor_mode {
341                if self.mode_registry.is_read_only(mode_name) {
342                    tracing::debug!("Ignoring unbound key in read-only mode '{}'", mode_name);
343                    return Ok(());
344                }
345                tracing::debug!(
346                    "Mode '{}' is not read-only, allowing key through",
347                    mode_name
348                );
349            }
350        }
351
352        // --- Composite buffer input routing ---
353        // If the active buffer is a composite buffer (side-by-side diff),
354        // route remaining composite-specific keys (scroll, pane switch, close)
355        // through CompositeInputRouter before falling through to regular
356        // keybinding resolution. Hunk navigation (n/p/]/[) is handled by the
357        // Action system via CompositeBuffer context bindings.
358        {
359            let active_buf = self.active_buffer();
360            let active_split = self.effective_active_split();
361            if self.is_composite_buffer(active_buf) {
362                if let Some(handled) =
363                    self.try_route_composite_key(active_split, active_buf, &key_event)
364                {
365                    return handled;
366                }
367            }
368        }
369
370        // Check for chord sequence matches first
371        let key_event = crossterm::event::KeyEvent::new(code, modifiers);
372        let (chord_result, action) = {
373            let keybindings = self.keybindings.read().unwrap();
374            let chord_result =
375                keybindings.resolve_chord(&self.chord_state, &key_event, context.clone());
376            let action = keybindings.resolve(&key_event, context.clone());
377            (chord_result, action)
378        };
379
380        match chord_result {
381            crate::input::keybindings::ChordResolution::Complete(action) => {
382                // Complete chord match - execute action and clear chord state
383                tracing::debug!("Complete chord match -> Action: {:?}", action);
384                self.chord_state.clear();
385                return self.handle_action(action);
386            }
387            crate::input::keybindings::ChordResolution::Partial => {
388                // Partial match - add to chord state and wait for more keys
389                tracing::debug!("Partial chord match - waiting for next key");
390                self.chord_state.push((code, modifiers));
391                return Ok(());
392            }
393            crate::input::keybindings::ChordResolution::NoMatch => {
394                // No chord match - clear state and try regular resolution
395                if !self.chord_state.is_empty() {
396                    tracing::debug!("Chord sequence abandoned, clearing state");
397                    self.chord_state.clear();
398                }
399            }
400        }
401
402        // Regular single-key resolution (already resolved above)
403        tracing::trace!("Context: {:?} -> Action: {:?}", context, action);
404
405        // Cancel pending LSP requests on user actions (except LSP actions themselves)
406        // This ensures stale completions don't show up after the user has moved on
407        match action {
408            Action::LspCompletion
409            | Action::LspGotoDefinition
410            | Action::LspReferences
411            | Action::LspHover
412            | Action::None => {
413                // Don't cancel for LSP actions or no-op
414            }
415            _ => {
416                // Cancel any pending LSP requests
417                self.cancel_pending_lsp_requests();
418            }
419        }
420
421        // Note: Modal components (Settings, Menu, Prompt, Popup, File Browser) are now
422        // handled by dispatch_modal_input using the InputHandler system.
423        // All remaining actions delegate to handle_action.
424        self.handle_action(action)
425    }
426
427    /// Handle an action (for normal mode and command execution).
428    /// Used by the app module internally and by the GUI module for native menu dispatch.
429    pub(crate) fn handle_action(&mut self, action: Action) -> AnyhowResult<()> {
430        use crate::input::keybindings::Action;
431
432        // Record action to macro if recording
433        self.record_macro_action(&action);
434
435        // Reset dabbrev cycling session on any non-dabbrev action.
436        if !matches!(action, Action::DabbrevExpand) {
437            self.reset_dabbrev_state();
438        }
439
440        match action {
441            Action::Quit => self.quit(),
442            Action::ForceQuit => {
443                self.should_quit = true;
444            }
445            Action::Detach => {
446                self.should_detach = true;
447            }
448            Action::Save => {
449                // Check if buffer has a file path - if not, redirect to SaveAs
450                if self.active_state().buffer.file_path().is_none() {
451                    self.start_prompt_with_initial_text(
452                        t!("file.save_as_prompt").to_string(),
453                        PromptType::SaveFileAs,
454                        String::new(),
455                    );
456                    self.init_file_open_state();
457                } else if self.check_save_conflict().is_some() {
458                    // Check if file was modified externally since we opened/saved it
459                    self.start_prompt(
460                        t!("file.file_changed_prompt").to_string(),
461                        PromptType::ConfirmSaveConflict,
462                    );
463                } else if let Err(e) = self.save() {
464                    let msg = format!("{}", e);
465                    self.status_message = Some(t!("file.save_failed", error = &msg).to_string());
466                }
467            }
468            Action::SaveAs => {
469                // Get current filename as default suggestion
470                let current_path = self
471                    .active_state()
472                    .buffer
473                    .file_path()
474                    .map(|p| {
475                        // Make path relative to working_dir if possible
476                        p.strip_prefix(&self.working_dir)
477                            .unwrap_or(p)
478                            .to_string_lossy()
479                            .to_string()
480                    })
481                    .unwrap_or_default();
482                self.start_prompt_with_initial_text(
483                    t!("file.save_as_prompt").to_string(),
484                    PromptType::SaveFileAs,
485                    current_path,
486                );
487                self.init_file_open_state();
488            }
489            Action::Open => {
490                self.start_prompt(t!("file.open_prompt").to_string(), PromptType::OpenFile);
491                self.prefill_open_file_prompt();
492                self.init_file_open_state();
493            }
494            Action::SwitchProject => {
495                self.start_prompt(
496                    t!("file.switch_project_prompt").to_string(),
497                    PromptType::SwitchProject,
498                );
499                self.init_folder_open_state();
500            }
501            Action::GotoLine => {
502                let has_line_index = self
503                    .buffers
504                    .get(&self.active_buffer())
505                    .is_none_or(|s| s.buffer.line_count().is_some());
506                if has_line_index {
507                    self.start_prompt(
508                        t!("file.goto_line_prompt").to_string(),
509                        PromptType::GotoLine,
510                    );
511                } else {
512                    self.start_prompt(
513                        t!("goto.scan_confirm_prompt", yes = "y", no = "N").to_string(),
514                        PromptType::GotoLineScanConfirm,
515                    );
516                }
517            }
518            Action::ScanLineIndex => {
519                self.start_incremental_line_scan(false);
520            }
521            Action::New => {
522                self.new_buffer();
523            }
524            Action::Close | Action::CloseTab => {
525                // Both Close and CloseTab use close_tab() which handles:
526                // - Closing the split if this is the last buffer and there are other splits
527                // - Prompting for unsaved changes
528                // - Properly closing the buffer
529                self.close_tab();
530            }
531            Action::Revert => {
532                // Check if buffer has unsaved changes - prompt for confirmation
533                if self.active_state().buffer.is_modified() {
534                    let revert_key = t!("prompt.key.revert").to_string();
535                    let cancel_key = t!("prompt.key.cancel").to_string();
536                    self.start_prompt(
537                        t!(
538                            "prompt.revert_confirm",
539                            revert_key = revert_key,
540                            cancel_key = cancel_key
541                        )
542                        .to_string(),
543                        PromptType::ConfirmRevert,
544                    );
545                } else {
546                    // No local changes, just revert
547                    if let Err(e) = self.revert_file() {
548                        self.set_status_message(
549                            t!("error.failed_to_revert", error = e.to_string()).to_string(),
550                        );
551                    }
552                }
553            }
554            Action::ToggleAutoRevert => {
555                self.toggle_auto_revert();
556            }
557            Action::FormatBuffer => {
558                if let Err(e) = self.format_buffer() {
559                    self.set_status_message(
560                        t!("error.format_failed", error = e.to_string()).to_string(),
561                    );
562                }
563            }
564            Action::TrimTrailingWhitespace => match self.trim_trailing_whitespace() {
565                Ok(true) => {
566                    self.set_status_message(t!("whitespace.trimmed").to_string());
567                }
568                Ok(false) => {
569                    self.set_status_message(t!("whitespace.no_trailing").to_string());
570                }
571                Err(e) => {
572                    self.set_status_message(
573                        t!("error.trim_whitespace_failed", error = e).to_string(),
574                    );
575                }
576            },
577            Action::EnsureFinalNewline => match self.ensure_final_newline() {
578                Ok(true) => {
579                    self.set_status_message(t!("whitespace.newline_added").to_string());
580                }
581                Ok(false) => {
582                    self.set_status_message(t!("whitespace.already_has_newline").to_string());
583                }
584                Err(e) => {
585                    self.set_status_message(
586                        t!("error.ensure_newline_failed", error = e).to_string(),
587                    );
588                }
589            },
590            Action::Copy => {
591                // Editor-level popups take precedence over everything, including the file explorer.
592                let popup = self
593                    .global_popups
594                    .top()
595                    .or_else(|| self.active_state().popups.top());
596                if let Some(popup) = popup {
597                    if popup.has_selection() {
598                        if let Some(text) = popup.get_selected_text() {
599                            self.clipboard.copy(text);
600                            self.set_status_message(t!("clipboard.copied").to_string());
601                            return Ok(());
602                        }
603                    }
604                }
605                if self.key_context == crate::input::keybindings::KeyContext::FileExplorer {
606                    self.file_explorer_copy();
607                    return Ok(());
608                }
609                // Check if active buffer is a composite buffer
610                let buffer_id = self.active_buffer();
611                if self.is_composite_buffer(buffer_id) {
612                    if let Some(_handled) = self.handle_composite_action(buffer_id, &Action::Copy) {
613                        return Ok(());
614                    }
615                }
616                self.copy_selection()
617            }
618            Action::CopyWithTheme(theme) => self.copy_selection_with_theme(&theme),
619            Action::Cut => {
620                if self.key_context == crate::input::keybindings::KeyContext::FileExplorer {
621                    self.file_explorer_cut();
622                    return Ok(());
623                }
624                if self.is_editing_disabled() {
625                    self.set_status_message(t!("buffer.editing_disabled").to_string());
626                    return Ok(());
627                }
628                self.cut_selection()
629            }
630            Action::Paste => {
631                if self.key_context == crate::input::keybindings::KeyContext::FileExplorer {
632                    self.file_explorer_paste();
633                    return Ok(());
634                }
635                if self.is_editing_disabled() {
636                    self.set_status_message(t!("buffer.editing_disabled").to_string());
637                    return Ok(());
638                }
639                self.paste()
640            }
641            Action::YankWordForward => self.yank_word_forward(),
642            Action::YankWordBackward => self.yank_word_backward(),
643            Action::YankToLineEnd => self.yank_to_line_end(),
644            Action::YankToLineStart => self.yank_to_line_start(),
645            Action::YankViWordEnd => self.yank_vi_word_end(),
646            Action::Undo => {
647                self.handle_undo();
648            }
649            Action::Redo => {
650                self.handle_redo();
651            }
652            Action::ShowHelp => {
653                self.open_help_manual();
654            }
655            Action::ShowKeyboardShortcuts => {
656                self.open_keyboard_shortcuts();
657            }
658            Action::ShowWarnings => {
659                self.show_warnings_popup();
660            }
661            Action::ShowStatusLog => {
662                self.open_status_log();
663            }
664            Action::ShowLspStatus => {
665                self.show_lsp_status_popup();
666            }
667            Action::ShowRemoteIndicatorMenu => {
668                self.show_remote_indicator_popup();
669            }
670            Action::ClearWarnings => {
671                self.clear_warnings();
672            }
673            Action::CommandPalette => {
674                // CommandPalette now delegates to QuickOpen (which starts with ">" prefix
675                // for command mode). Toggle if already open.
676                if let Some(prompt) = &self.prompt {
677                    if prompt.prompt_type == PromptType::QuickOpen {
678                        self.cancel_prompt();
679                        return Ok(());
680                    }
681                }
682                self.start_quick_open();
683            }
684            Action::QuickOpen => {
685                // Toggle Quick Open: close if already open, otherwise open it
686                if let Some(prompt) = &self.prompt {
687                    if prompt.prompt_type == PromptType::QuickOpen {
688                        self.cancel_prompt();
689                        return Ok(());
690                    }
691                }
692
693                // Start Quick Open with file suggestions (default mode)
694                self.start_quick_open();
695            }
696            Action::QuickOpenBuffers => {
697                if let Some(prompt) = &self.prompt {
698                    if prompt.prompt_type == PromptType::QuickOpen {
699                        self.cancel_prompt();
700                        return Ok(());
701                    }
702                }
703                self.start_quick_open_with_prefix("#");
704            }
705            Action::QuickOpenFiles => {
706                if let Some(prompt) = &self.prompt {
707                    if prompt.prompt_type == PromptType::QuickOpen {
708                        self.cancel_prompt();
709                        return Ok(());
710                    }
711                }
712                self.start_quick_open_with_prefix("");
713            }
714            Action::ToggleLineWrap => {
715                let new_value = !self.config.editor.line_wrap;
716                self.config_mut().editor.line_wrap = new_value;
717
718                // Update all viewports to reflect the new line wrap setting,
719                // respecting per-language overrides
720                let leaf_ids: Vec<_> = self.split_view_states.keys().copied().collect();
721                for leaf_id in leaf_ids {
722                    let buffer_id = self
723                        .split_manager
724                        .get_buffer_id(leaf_id.into())
725                        .unwrap_or(BufferId(0));
726                    let effective_wrap = self.resolve_line_wrap_for_buffer(buffer_id);
727                    let wrap_column = self.resolve_wrap_column_for_buffer(buffer_id);
728                    if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
729                        view_state.viewport.line_wrap_enabled = effective_wrap;
730                        view_state.viewport.wrap_indent = self.config.editor.wrap_indent;
731                        view_state.viewport.wrap_column = wrap_column;
732                    }
733                }
734
735                let state = if self.config.editor.line_wrap {
736                    t!("view.state_enabled").to_string()
737                } else {
738                    t!("view.state_disabled").to_string()
739                };
740                self.set_status_message(t!("view.line_wrap_state", state = state).to_string());
741            }
742            Action::ToggleCurrentLineHighlight => {
743                let new_value = !self.config.editor.highlight_current_line;
744                self.config_mut().editor.highlight_current_line = new_value;
745
746                // Update all splits
747                let leaf_ids: Vec<_> = self.split_view_states.keys().copied().collect();
748                for leaf_id in leaf_ids {
749                    if let Some(view_state) = self.split_view_states.get_mut(&leaf_id) {
750                        view_state.highlight_current_line =
751                            self.config.editor.highlight_current_line;
752                    }
753                }
754
755                let state = if self.config.editor.highlight_current_line {
756                    t!("view.state_enabled").to_string()
757                } else {
758                    t!("view.state_disabled").to_string()
759                };
760                self.set_status_message(
761                    t!("view.current_line_highlight_state", state = state).to_string(),
762                );
763            }
764            Action::ToggleReadOnly => {
765                let buffer_id = self.active_buffer();
766                let is_now_read_only = self
767                    .buffer_metadata
768                    .get(&buffer_id)
769                    .map(|m| !m.read_only)
770                    .unwrap_or(false);
771                self.mark_buffer_read_only(buffer_id, is_now_read_only);
772
773                let state_str = if is_now_read_only {
774                    t!("view.state_enabled").to_string()
775                } else {
776                    t!("view.state_disabled").to_string()
777                };
778                self.set_status_message(t!("view.read_only_state", state = state_str).to_string());
779            }
780            Action::TogglePageView => {
781                self.handle_toggle_page_view();
782            }
783            Action::SetPageWidth => {
784                let active_split = self.split_manager.active_split();
785                let current = self
786                    .split_view_states
787                    .get(&active_split)
788                    .and_then(|v| v.compose_width.map(|w| w.to_string()))
789                    .unwrap_or_default();
790                self.start_prompt_with_initial_text(
791                    "Page width (empty = viewport): ".to_string(),
792                    PromptType::SetPageWidth,
793                    current,
794                );
795            }
796            Action::SetBackground => {
797                let default_path = self
798                    .ansi_background_path
799                    .as_ref()
800                    .and_then(|p| {
801                        p.strip_prefix(&self.working_dir)
802                            .ok()
803                            .map(|rel| rel.to_string_lossy().to_string())
804                    })
805                    .unwrap_or_else(|| DEFAULT_BACKGROUND_FILE.to_string());
806
807                self.start_prompt_with_initial_text(
808                    "Background file: ".to_string(),
809                    PromptType::SetBackgroundFile,
810                    default_path,
811                );
812            }
813            Action::SetBackgroundBlend => {
814                let default_amount = format!("{:.2}", self.background_fade);
815                self.start_prompt_with_initial_text(
816                    "Background blend (0-1): ".to_string(),
817                    PromptType::SetBackgroundBlend,
818                    default_amount,
819                );
820            }
821            Action::LspCompletion => {
822                self.request_completion();
823            }
824            Action::DabbrevExpand => {
825                self.dabbrev_expand();
826            }
827            Action::LspGotoDefinition => {
828                self.request_goto_definition()?;
829            }
830            Action::LspRename => {
831                self.start_rename()?;
832            }
833            Action::LspHover => {
834                self.request_hover()?;
835            }
836            Action::LspReferences => {
837                self.request_references()?;
838            }
839            Action::LspSignatureHelp => {
840                self.request_signature_help();
841            }
842            Action::LspCodeActions => {
843                self.request_code_actions()?;
844            }
845            Action::LspRestart => {
846                self.handle_lsp_restart();
847            }
848            Action::LspStop => {
849                self.handle_lsp_stop();
850            }
851            Action::LspToggleForBuffer => {
852                self.handle_lsp_toggle_for_buffer();
853            }
854            Action::ToggleInlayHints => {
855                self.toggle_inlay_hints();
856            }
857            Action::DumpConfig => {
858                self.dump_config();
859            }
860            Action::RedrawScreen => {
861                self.request_full_redraw();
862            }
863            Action::SelectTheme => {
864                self.start_select_theme_prompt();
865            }
866            Action::InspectThemeAtCursor => {
867                self.inspect_theme_at_cursor();
868            }
869            Action::SelectKeybindingMap => {
870                self.start_select_keybinding_map_prompt();
871            }
872            Action::SelectCursorStyle => {
873                self.start_select_cursor_style_prompt();
874            }
875            Action::SelectLocale => {
876                self.start_select_locale_prompt();
877            }
878            Action::Search => {
879                // If already in a search-related prompt, Ctrl+F acts like Enter (confirm search)
880                let is_search_prompt = self.prompt.as_ref().is_some_and(|p| {
881                    matches!(
882                        p.prompt_type,
883                        PromptType::Search
884                            | PromptType::ReplaceSearch
885                            | PromptType::QueryReplaceSearch
886                    )
887                });
888
889                if is_search_prompt {
890                    self.confirm_prompt();
891                } else {
892                    self.start_search_prompt(
893                        t!("file.search_prompt").to_string(),
894                        PromptType::Search,
895                        false,
896                    );
897                }
898            }
899            Action::Replace => {
900                // Use same flow as query-replace, just with confirm_each defaulting to false
901                self.start_search_prompt(
902                    t!("file.replace_prompt").to_string(),
903                    PromptType::ReplaceSearch,
904                    false,
905                );
906            }
907            Action::QueryReplace => {
908                // Enable confirm mode by default for query-replace
909                self.search_confirm_each = true;
910                self.start_search_prompt(
911                    "Query replace: ".to_string(),
912                    PromptType::QueryReplaceSearch,
913                    false,
914                );
915            }
916            Action::FindInSelection => {
917                self.start_search_prompt(
918                    t!("file.search_prompt").to_string(),
919                    PromptType::Search,
920                    true,
921                );
922            }
923            Action::FindNext => {
924                self.find_next();
925            }
926            Action::FindPrevious => {
927                self.find_previous();
928            }
929            Action::FindSelectionNext => {
930                self.find_selection_next();
931            }
932            Action::FindSelectionPrevious => {
933                self.find_selection_previous();
934            }
935            Action::AddCursorNextMatch => self.add_cursor_at_next_match(),
936            Action::AddCursorAbove => self.add_cursor_above(),
937            Action::AddCursorBelow => self.add_cursor_below(),
938            Action::NextBuffer => self.next_buffer(),
939            Action::PrevBuffer => self.prev_buffer(),
940            Action::SwitchToPreviousTab => self.switch_to_previous_tab(),
941            Action::SwitchToTabByName => self.start_switch_to_tab_prompt(),
942
943            // Tab scrolling (manual scroll - don't auto-adjust)
944            Action::ScrollTabsLeft => {
945                let active_split_id = self.split_manager.active_split();
946                if let Some(view_state) = self.split_view_states.get_mut(&active_split_id) {
947                    view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_sub(5);
948                    self.set_status_message(t!("status.scrolled_tabs_left").to_string());
949                }
950            }
951            Action::ScrollTabsRight => {
952                let active_split_id = self.split_manager.active_split();
953                if let Some(view_state) = self.split_view_states.get_mut(&active_split_id) {
954                    view_state.tab_scroll_offset = view_state.tab_scroll_offset.saturating_add(5);
955                    self.set_status_message(t!("status.scrolled_tabs_right").to_string());
956                }
957            }
958            Action::NavigateBack => self.navigate_back(),
959            Action::NavigateForward => self.navigate_forward(),
960            Action::SplitHorizontal => self.split_pane_horizontal(),
961            Action::SplitVertical => self.split_pane_vertical(),
962            Action::CloseSplit => self.close_active_split(),
963            Action::NextSplit => self.next_split(),
964            Action::PrevSplit => self.prev_split(),
965            Action::IncreaseSplitSize => self.adjust_split_size(0.05),
966            Action::DecreaseSplitSize => self.adjust_split_size(-0.05),
967            Action::ToggleMaximizeSplit => self.toggle_maximize_split(),
968            Action::ToggleFileExplorer => self.toggle_file_explorer(),
969            Action::ToggleMenuBar => self.toggle_menu_bar(),
970            Action::ToggleTabBar => self.toggle_tab_bar(),
971            Action::ToggleStatusBar => self.toggle_status_bar(),
972            Action::TogglePromptLine => self.toggle_prompt_line(),
973            Action::ToggleVerticalScrollbar => self.toggle_vertical_scrollbar(),
974            Action::ToggleHorizontalScrollbar => self.toggle_horizontal_scrollbar(),
975            Action::ToggleLineNumbers => self.toggle_line_numbers(),
976            Action::ToggleScrollSync => self.toggle_scroll_sync(),
977            Action::ToggleMouseCapture => self.toggle_mouse_capture(),
978            Action::ToggleMouseHover => self.toggle_mouse_hover(),
979            Action::ToggleDebugHighlights => self.toggle_debug_highlights(),
980            // Rulers
981            Action::AddRuler => {
982                self.start_prompt(t!("rulers.add_prompt").to_string(), PromptType::AddRuler);
983            }
984            Action::RemoveRuler => {
985                self.start_remove_ruler_prompt();
986            }
987            // Buffer settings
988            Action::SetTabSize => {
989                let current = self
990                    .buffers
991                    .get(&self.active_buffer())
992                    .map(|s| s.buffer_settings.tab_size.to_string())
993                    .unwrap_or_else(|| "4".to_string());
994                self.start_prompt_with_initial_text(
995                    "Tab size: ".to_string(),
996                    PromptType::SetTabSize,
997                    current,
998                );
999            }
1000            Action::SetLineEnding => {
1001                self.start_set_line_ending_prompt();
1002            }
1003            Action::SetEncoding => {
1004                self.start_set_encoding_prompt();
1005            }
1006            Action::ReloadWithEncoding => {
1007                self.start_reload_with_encoding_prompt();
1008            }
1009            Action::SetLanguage => {
1010                self.start_set_language_prompt();
1011            }
1012            Action::ToggleIndentationStyle => {
1013                if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
1014                    state.buffer_settings.use_tabs = !state.buffer_settings.use_tabs;
1015                    let status = if state.buffer_settings.use_tabs {
1016                        "Indentation: Tabs"
1017                    } else {
1018                        "Indentation: Spaces"
1019                    };
1020                    self.set_status_message(status.to_string());
1021                }
1022            }
1023            Action::ToggleTabIndicators | Action::ToggleWhitespaceIndicators => {
1024                if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
1025                    state.buffer_settings.whitespace.toggle_all();
1026                    let status = if state.buffer_settings.whitespace.any_visible() {
1027                        t!("toggle.whitespace_indicators_shown")
1028                    } else {
1029                        t!("toggle.whitespace_indicators_hidden")
1030                    };
1031                    self.set_status_message(status.to_string());
1032                }
1033            }
1034            Action::ResetBufferSettings => self.reset_buffer_settings(),
1035            Action::FocusFileExplorer => self.focus_file_explorer(),
1036            Action::FocusEditor => self.focus_editor(),
1037            Action::FileExplorerUp => self.file_explorer_navigate_up(),
1038            Action::FileExplorerDown => self.file_explorer_navigate_down(),
1039            Action::FileExplorerPageUp => self.file_explorer_page_up(),
1040            Action::FileExplorerPageDown => self.file_explorer_page_down(),
1041            Action::FileExplorerExpand => self.file_explorer_toggle_expand(),
1042            Action::FileExplorerCollapse => self.file_explorer_collapse(),
1043            Action::FileExplorerOpen => self.file_explorer_open_file()?,
1044            Action::FileExplorerRefresh => self.file_explorer_refresh(),
1045            Action::FileExplorerNewFile => self.file_explorer_new_file(),
1046            Action::FileExplorerNewDirectory => self.file_explorer_new_directory(),
1047            Action::FileExplorerDelete => self.file_explorer_delete(),
1048            Action::FileExplorerRename => self.file_explorer_rename(),
1049            Action::FileExplorerToggleHidden => self.file_explorer_toggle_hidden(),
1050            Action::FileExplorerToggleGitignored => self.file_explorer_toggle_gitignored(),
1051            Action::FileExplorerSearchClear => self.file_explorer_search_clear(),
1052            Action::FileExplorerSearchBackspace => self.file_explorer_search_pop_char(),
1053            Action::FileExplorerCopy => self.file_explorer_copy(),
1054            Action::FileExplorerCut => self.file_explorer_cut(),
1055            Action::FileExplorerPaste => self.file_explorer_paste(),
1056            Action::FileExplorerExtendSelectionUp => self.file_explorer_extend_selection_up(),
1057            Action::FileExplorerExtendSelectionDown => self.file_explorer_extend_selection_down(),
1058            Action::FileExplorerToggleSelect => self.file_explorer_toggle_select(),
1059            Action::FileExplorerSelectAll => self.file_explorer_select_all(),
1060            Action::RemoveSecondaryCursors => {
1061                // Convert action to events and apply them
1062                if let Some(events) = self.action_to_events(Action::RemoveSecondaryCursors) {
1063                    // Wrap in batch for atomic undo
1064                    let batch = Event::Batch {
1065                        events: events.clone(),
1066                        description: "Remove secondary cursors".to_string(),
1067                    };
1068                    self.active_event_log_mut().append(batch.clone());
1069                    self.apply_event_to_active_buffer(&batch);
1070
1071                    // Ensure the primary cursor is visible after removing secondary cursors
1072                    let active_split = self.split_manager.active_split();
1073                    let active_buffer = self.active_buffer();
1074                    if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
1075                        let state = self.buffers.get_mut(&active_buffer).unwrap();
1076                        view_state.ensure_cursor_visible(&mut state.buffer, &state.marker_list);
1077                    }
1078                }
1079            }
1080
1081            // Menu navigation actions
1082            Action::MenuActivate => {
1083                self.handle_menu_activate();
1084            }
1085            Action::MenuClose => {
1086                self.handle_menu_close();
1087            }
1088            Action::MenuLeft => {
1089                self.handle_menu_left();
1090            }
1091            Action::MenuRight => {
1092                self.handle_menu_right();
1093            }
1094            Action::MenuUp => {
1095                self.handle_menu_up();
1096            }
1097            Action::MenuDown => {
1098                self.handle_menu_down();
1099            }
1100            Action::MenuExecute => {
1101                if let Some(action) = self.handle_menu_execute() {
1102                    return self.handle_action(action);
1103                }
1104            }
1105            Action::MenuOpen(menu_name) => {
1106                if self.config.editor.menu_bar_mnemonics {
1107                    self.handle_menu_open(&menu_name);
1108                }
1109            }
1110
1111            Action::SwitchKeybindingMap(map_name) => {
1112                // Check if the map exists (either built-in or user-defined)
1113                let is_builtin =
1114                    matches!(map_name.as_str(), "default" | "emacs" | "vscode" | "macos");
1115                let is_user_defined = self.config.keybinding_maps.contains_key(&map_name);
1116
1117                if is_builtin || is_user_defined {
1118                    // Update the active keybinding map in config
1119                    self.config_mut().active_keybinding_map = map_name.clone().into();
1120
1121                    // Reload the keybinding resolver with the new map
1122                    *self.keybindings.write().unwrap() =
1123                        crate::input::keybindings::KeybindingResolver::new(&self.config);
1124
1125                    self.set_status_message(
1126                        t!("view.keybindings_switched", map = map_name).to_string(),
1127                    );
1128                } else {
1129                    self.set_status_message(
1130                        t!("view.keybindings_unknown", map = map_name).to_string(),
1131                    );
1132                }
1133            }
1134
1135            Action::SmartHome => {
1136                // In composite (diff) views, use LineStart movement
1137                let buffer_id = self.active_buffer();
1138                if self.is_composite_buffer(buffer_id) {
1139                    if let Some(_handled) =
1140                        self.handle_composite_action(buffer_id, &Action::SmartHome)
1141                    {
1142                        return Ok(());
1143                    }
1144                }
1145                self.smart_home();
1146            }
1147            Action::ToggleComment => {
1148                self.toggle_comment();
1149            }
1150            Action::ToggleFold => {
1151                self.toggle_fold_at_cursor();
1152            }
1153            Action::GoToMatchingBracket => {
1154                self.goto_matching_bracket();
1155            }
1156            Action::JumpToNextError => {
1157                self.jump_to_next_error();
1158            }
1159            Action::JumpToPreviousError => {
1160                self.jump_to_previous_error();
1161            }
1162            Action::SetBookmark(key) => {
1163                self.set_bookmark(key);
1164            }
1165            Action::JumpToBookmark(key) => {
1166                self.jump_to_bookmark(key);
1167            }
1168            Action::ClearBookmark(key) => {
1169                self.clear_bookmark(key);
1170            }
1171            Action::ListBookmarks => {
1172                self.list_bookmarks();
1173            }
1174            Action::ToggleSearchCaseSensitive => {
1175                self.search_case_sensitive = !self.search_case_sensitive;
1176                let state = if self.search_case_sensitive {
1177                    "enabled"
1178                } else {
1179                    "disabled"
1180                };
1181                self.set_status_message(
1182                    t!("search.case_sensitive_state", state = state).to_string(),
1183                );
1184                // Update incremental highlights if in search prompt, otherwise re-run completed search
1185                // Check prompt FIRST since we want to use current prompt input, not stale search_state
1186                if let Some(prompt) = &self.prompt {
1187                    if matches!(
1188                        prompt.prompt_type,
1189                        PromptType::Search
1190                            | PromptType::ReplaceSearch
1191                            | PromptType::QueryReplaceSearch
1192                    ) {
1193                        let query = prompt.input.clone();
1194                        self.update_search_highlights(&query);
1195                    }
1196                } else if let Some(search_state) = &self.search_state {
1197                    let query = search_state.query.clone();
1198                    self.perform_search(&query);
1199                }
1200            }
1201            Action::ToggleSearchWholeWord => {
1202                self.search_whole_word = !self.search_whole_word;
1203                let state = if self.search_whole_word {
1204                    "enabled"
1205                } else {
1206                    "disabled"
1207                };
1208                self.set_status_message(t!("search.whole_word_state", state = state).to_string());
1209                // Update incremental highlights if in search prompt, otherwise re-run completed search
1210                // Check prompt FIRST since we want to use current prompt input, not stale search_state
1211                if let Some(prompt) = &self.prompt {
1212                    if matches!(
1213                        prompt.prompt_type,
1214                        PromptType::Search
1215                            | PromptType::ReplaceSearch
1216                            | PromptType::QueryReplaceSearch
1217                    ) {
1218                        let query = prompt.input.clone();
1219                        self.update_search_highlights(&query);
1220                    }
1221                } else if let Some(search_state) = &self.search_state {
1222                    let query = search_state.query.clone();
1223                    self.perform_search(&query);
1224                }
1225            }
1226            Action::ToggleSearchRegex => {
1227                self.search_use_regex = !self.search_use_regex;
1228                let state = if self.search_use_regex {
1229                    "enabled"
1230                } else {
1231                    "disabled"
1232                };
1233                self.set_status_message(t!("search.regex_state", state = state).to_string());
1234                // Update incremental highlights if in search prompt, otherwise re-run completed search
1235                // Check prompt FIRST since we want to use current prompt input, not stale search_state
1236                if let Some(prompt) = &self.prompt {
1237                    if matches!(
1238                        prompt.prompt_type,
1239                        PromptType::Search
1240                            | PromptType::ReplaceSearch
1241                            | PromptType::QueryReplaceSearch
1242                    ) {
1243                        let query = prompt.input.clone();
1244                        self.update_search_highlights(&query);
1245                    }
1246                } else if let Some(search_state) = &self.search_state {
1247                    let query = search_state.query.clone();
1248                    self.perform_search(&query);
1249                }
1250            }
1251            Action::ToggleSearchConfirmEach => {
1252                self.search_confirm_each = !self.search_confirm_each;
1253                let state = if self.search_confirm_each {
1254                    "enabled"
1255                } else {
1256                    "disabled"
1257                };
1258                self.set_status_message(t!("search.confirm_each_state", state = state).to_string());
1259            }
1260            Action::FileBrowserToggleHidden => {
1261                // Toggle hidden files in file browser (handled via file_open_toggle_hidden)
1262                self.file_open_toggle_hidden();
1263            }
1264            Action::StartMacroRecording => {
1265                // This is a no-op; use ToggleMacroRecording instead
1266                self.set_status_message(
1267                    "Use Ctrl+Shift+R to start recording (will prompt for register)".to_string(),
1268                );
1269            }
1270            Action::StopMacroRecording => {
1271                self.stop_macro_recording();
1272            }
1273            Action::PlayMacro(key) => {
1274                self.play_macro(key);
1275            }
1276            Action::ToggleMacroRecording(key) => {
1277                self.toggle_macro_recording(key);
1278            }
1279            Action::ShowMacro(key) => {
1280                self.show_macro_in_buffer(key);
1281            }
1282            Action::ListMacros => {
1283                self.list_macros_in_buffer();
1284            }
1285            Action::PromptRecordMacro => {
1286                self.start_prompt("Record macro (0-9): ".to_string(), PromptType::RecordMacro);
1287            }
1288            Action::PromptPlayMacro => {
1289                self.start_prompt("Play macro (0-9): ".to_string(), PromptType::PlayMacro);
1290            }
1291            Action::PlayLastMacro => {
1292                if let Some(key) = self.macros.last_register() {
1293                    self.play_macro(key);
1294                } else {
1295                    self.set_status_message(t!("status.no_macro_recorded").to_string());
1296                }
1297            }
1298            Action::PromptSetBookmark => {
1299                self.start_prompt("Set bookmark (0-9): ".to_string(), PromptType::SetBookmark);
1300            }
1301            Action::PromptJumpToBookmark => {
1302                self.start_prompt(
1303                    "Jump to bookmark (0-9): ".to_string(),
1304                    PromptType::JumpToBookmark,
1305                );
1306            }
1307            Action::CompositeNextHunk => {
1308                let buf = self.active_buffer();
1309                self.composite_next_hunk_active(buf);
1310            }
1311            Action::CompositePrevHunk => {
1312                let buf = self.active_buffer();
1313                self.composite_prev_hunk_active(buf);
1314            }
1315            Action::None => {}
1316            Action::DeleteBackward => {
1317                if self.is_editing_disabled() {
1318                    self.set_status_message(t!("buffer.editing_disabled").to_string());
1319                    return Ok(());
1320                }
1321                // Normal backspace handling
1322                if let Some(events) = self.action_to_events(Action::DeleteBackward) {
1323                    if events.len() > 1 {
1324                        // Multi-cursor: use optimized bulk edit (O(n) instead of O(n²))
1325                        let description = "Delete backward".to_string();
1326                        if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, description)
1327                        {
1328                            self.active_event_log_mut().append(bulk_edit);
1329                        }
1330                    } else {
1331                        for event in events {
1332                            self.active_event_log_mut().append(event.clone());
1333                            self.apply_event_to_active_buffer(&event);
1334                        }
1335                    }
1336                }
1337            }
1338            Action::PluginAction(action_name) => {
1339                tracing::debug!("handle_action: PluginAction('{}')", action_name);
1340                // Execute the plugin callback via TypeScript plugin thread
1341                // Use non-blocking version to avoid deadlock with async plugin ops
1342                #[cfg(feature = "plugins")]
1343                if let Some(result) = self.plugin_manager.execute_action_async(&action_name) {
1344                    match result {
1345                        Ok(receiver) => {
1346                            // Store pending action for processing in main loop
1347                            self.pending_plugin_actions
1348                                .push((action_name.clone(), receiver));
1349                        }
1350                        Err(e) => {
1351                            self.set_status_message(
1352                                t!("view.plugin_error", error = e.to_string()).to_string(),
1353                            );
1354                            tracing::error!("Plugin action error: {}", e);
1355                        }
1356                    }
1357                } else {
1358                    self.set_status_message(t!("status.plugin_manager_unavailable").to_string());
1359                }
1360                #[cfg(not(feature = "plugins"))]
1361                {
1362                    let _ = action_name;
1363                    self.set_status_message(
1364                        "Plugins not available (compiled without plugin support)".to_string(),
1365                    );
1366                }
1367            }
1368            Action::LoadPluginFromBuffer => {
1369                #[cfg(feature = "plugins")]
1370                {
1371                    let buffer_id = self.active_buffer();
1372                    let state = self.active_state();
1373                    let buffer = &state.buffer;
1374                    let total = buffer.total_bytes();
1375                    let content =
1376                        String::from_utf8_lossy(&buffer.slice_bytes(0..total)).to_string();
1377
1378                    // Determine if TypeScript from file extension, default to TS
1379                    let is_ts = buffer
1380                        .file_path()
1381                        .and_then(|p| p.extension())
1382                        .and_then(|e| e.to_str())
1383                        .map(|e| e == "ts" || e == "tsx")
1384                        .unwrap_or(true);
1385
1386                    // Derive plugin name from buffer filename
1387                    let name = buffer
1388                        .file_path()
1389                        .and_then(|p| p.file_name())
1390                        .and_then(|s| s.to_str())
1391                        .map(|s| s.to_string())
1392                        .unwrap_or_else(|| "buffer-plugin".to_string());
1393
1394                    match self
1395                        .plugin_manager
1396                        .load_plugin_from_source(&content, &name, is_ts)
1397                    {
1398                        Ok(()) => {
1399                            self.set_status_message(format!(
1400                                "Plugin '{}' loaded from buffer",
1401                                name
1402                            ));
1403                        }
1404                        Err(e) => {
1405                            self.set_status_message(format!("Failed to load plugin: {}", e));
1406                            tracing::error!("LoadPluginFromBuffer error: {}", e);
1407                        }
1408                    }
1409
1410                    // Set up plugin dev workspace for LSP support
1411                    self.setup_plugin_dev_lsp(buffer_id, &content);
1412                }
1413                #[cfg(not(feature = "plugins"))]
1414                {
1415                    self.set_status_message(
1416                        "Plugins not available (compiled without plugin support)".to_string(),
1417                    );
1418                }
1419            }
1420            Action::InitReload => {
1421                // Same code path as auto-load: read init.ts and push it
1422                // through the existing plugin pipeline. The runtime's
1423                // hot-reload semantics drop prior commands / handlers /
1424                // event subs / settings before the new source runs.
1425                self.load_init_script(true);
1426                // Re-fire plugins_loaded so handlers expecting a "fresh"
1427                // post-load environment (M2) see it.
1428                self.fire_plugins_loaded_hook();
1429            }
1430            Action::InitEdit => {
1431                // Ensure the file exists (create from template if absent),
1432                // then open it in the editor so users can edit + reload.
1433                let config_dir = self.dir_context.config_dir.clone();
1434                match crate::init_script::ensure_starter(&config_dir) {
1435                    Ok(path) => {
1436                        // Regenerate `types/plugins.d.ts` from the live plugin
1437                        // set. It's written once at editor startup, but any
1438                        // plugin loaded/reloaded/unloaded since then would
1439                        // leave the aggregate stale (or missing, in builds
1440                        // where the plugins feature was off at boot but the
1441                        // user has since enabled a plugin). The user's
1442                        // tsconfig.json lists this file in `files`, so a
1443                        // stale copy is exactly when `getPluginApi("foo")`
1444                        // loses its typed overload.
1445                        let declarations = self.plugin_manager.plugin_declarations();
1446                        crate::init_script::write_plugin_declarations(&config_dir, &declarations);
1447                        match self.open_file(&path) {
1448                            Ok(_) => {
1449                                self.set_status_message(format!("init.ts: {}", path.display()));
1450                            }
1451                            Err(e) => {
1452                                self.set_status_message(format!("init.ts: open failed: {e}"));
1453                            }
1454                        }
1455                    }
1456                    Err(e) => {
1457                        self.set_status_message(format!("init.ts: create failed: {e}"));
1458                    }
1459                }
1460            }
1461            Action::InitCheck => {
1462                // Run the same parse check as `fresh --cmd init check` but
1463                // surface results in the status bar.
1464                let report = crate::init_script::check(&self.dir_context.config_dir);
1465                if report.ok && report.diagnostics.is_empty() {
1466                    self.set_status_message("init.ts: ok".into());
1467                } else if !report.ok {
1468                    let first = report
1469                        .diagnostics
1470                        .first()
1471                        .map(|d| format!("{}:{}: {}", d.line, d.column, d.message))
1472                        .unwrap_or_else(|| "unknown error".into());
1473                    self.set_status_message(format!(
1474                        "init.ts: {} error(s) — first: {first}",
1475                        report.diagnostics.len()
1476                    ));
1477                } else {
1478                    self.set_status_message(format!(
1479                        "init.ts: {} warning(s)",
1480                        report.diagnostics.len()
1481                    ));
1482                }
1483            }
1484            Action::OpenTerminal => {
1485                self.open_terminal();
1486            }
1487            Action::CloseTerminal => {
1488                self.close_terminal();
1489            }
1490            Action::FocusTerminal => {
1491                // If viewing a terminal buffer, switch to terminal mode
1492                if self.is_terminal_buffer(self.active_buffer()) {
1493                    self.terminal_mode = true;
1494                    self.key_context = KeyContext::Terminal;
1495                    self.set_status_message(t!("status.terminal_mode_enabled").to_string());
1496                }
1497            }
1498            Action::TerminalEscape => {
1499                // Exit terminal mode back to editor
1500                if self.terminal_mode {
1501                    self.terminal_mode = false;
1502                    self.key_context = KeyContext::Normal;
1503                    self.set_status_message(t!("status.terminal_mode_disabled").to_string());
1504                }
1505            }
1506            Action::ToggleKeyboardCapture => {
1507                // Toggle keyboard capture mode in terminal
1508                if self.terminal_mode {
1509                    self.keyboard_capture = !self.keyboard_capture;
1510                    if self.keyboard_capture {
1511                        self.set_status_message(
1512                            "Keyboard capture ON - all keys go to terminal (F9 to toggle)"
1513                                .to_string(),
1514                        );
1515                    } else {
1516                        self.set_status_message(
1517                            "Keyboard capture OFF - UI bindings active (F9 to toggle)".to_string(),
1518                        );
1519                    }
1520                }
1521            }
1522            Action::TerminalPaste => {
1523                // Paste clipboard contents into terminal as a single batch
1524                if self.terminal_mode {
1525                    if let Some(text) = self.clipboard.paste() {
1526                        self.send_terminal_input(text.as_bytes());
1527                    }
1528                }
1529            }
1530            Action::ShellCommand => {
1531                // Run shell command on buffer/selection, output to new buffer
1532                self.start_shell_command_prompt(false);
1533            }
1534            Action::ShellCommandReplace => {
1535                // Run shell command on buffer/selection, replace content
1536                self.start_shell_command_prompt(true);
1537            }
1538            Action::OpenSettings => {
1539                self.open_settings();
1540            }
1541            Action::CloseSettings => {
1542                // Check if there are unsaved changes
1543                let has_changes = self
1544                    .settings_state
1545                    .as_ref()
1546                    .is_some_and(|s| s.has_changes());
1547                if has_changes {
1548                    // Show confirmation dialog
1549                    if let Some(ref mut state) = self.settings_state {
1550                        state.show_confirm_dialog();
1551                    }
1552                } else {
1553                    self.close_settings(false);
1554                }
1555            }
1556            Action::SettingsSave => {
1557                self.save_settings();
1558            }
1559            Action::SettingsReset => {
1560                if let Some(ref mut state) = self.settings_state {
1561                    state.reset_current_to_default();
1562                }
1563            }
1564            Action::SettingsInherit => {
1565                if let Some(ref mut state) = self.settings_state {
1566                    state.set_current_to_null();
1567                }
1568            }
1569            Action::SettingsToggleFocus => {
1570                if let Some(ref mut state) = self.settings_state {
1571                    state.toggle_focus();
1572                }
1573            }
1574            Action::SettingsActivate => {
1575                self.settings_activate_current();
1576            }
1577            Action::SettingsSearch => {
1578                if let Some(ref mut state) = self.settings_state {
1579                    state.start_search();
1580                }
1581            }
1582            Action::SettingsHelp => {
1583                if let Some(ref mut state) = self.settings_state {
1584                    state.toggle_help();
1585                }
1586            }
1587            Action::SettingsIncrement => {
1588                self.settings_increment_current();
1589            }
1590            Action::SettingsDecrement => {
1591                self.settings_decrement_current();
1592            }
1593            Action::CalibrateInput => {
1594                self.open_calibration_wizard();
1595            }
1596            Action::EventDebug => {
1597                self.open_event_debug();
1598            }
1599            Action::SuspendProcess => {
1600                self.request_suspend();
1601            }
1602            Action::OpenKeybindingEditor => {
1603                self.open_keybinding_editor();
1604            }
1605            Action::PromptConfirm => {
1606                if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
1607                    use super::prompt_actions::PromptResult;
1608                    match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
1609                        PromptResult::ExecuteAction(action) => {
1610                            return self.handle_action(action);
1611                        }
1612                        PromptResult::EarlyReturn => {
1613                            return Ok(());
1614                        }
1615                        PromptResult::Done => {}
1616                    }
1617                }
1618            }
1619            Action::PromptConfirmWithText(ref text) => {
1620                // For macro playback: set the prompt text before confirming
1621                if let Some(ref mut prompt) = self.prompt {
1622                    prompt.set_input(text.clone());
1623                    self.update_prompt_suggestions();
1624                }
1625                if let Some((input, prompt_type, selected_index)) = self.confirm_prompt() {
1626                    use super::prompt_actions::PromptResult;
1627                    match self.handle_prompt_confirm_input(input, prompt_type, selected_index) {
1628                        PromptResult::ExecuteAction(action) => {
1629                            return self.handle_action(action);
1630                        }
1631                        PromptResult::EarlyReturn => {
1632                            return Ok(());
1633                        }
1634                        PromptResult::Done => {}
1635                    }
1636                }
1637            }
1638            Action::PopupConfirm => {
1639                use super::popup_actions::PopupConfirmResult;
1640                if let PopupConfirmResult::EarlyReturn = self.handle_popup_confirm() {
1641                    return Ok(());
1642                }
1643            }
1644            Action::PopupCancel => {
1645                self.handle_popup_cancel();
1646            }
1647            Action::CompletionAccept => {
1648                use super::popup_actions::PopupConfirmResult;
1649                if let PopupConfirmResult::EarlyReturn = self.handle_popup_confirm() {
1650                    return Ok(());
1651                }
1652            }
1653            Action::CompletionDismiss => {
1654                self.handle_popup_cancel();
1655            }
1656            Action::InsertChar(c) => {
1657                if self.is_prompting() {
1658                    return self.handle_insert_char_prompt(c);
1659                } else if self.key_context == KeyContext::FileExplorer {
1660                    self.file_explorer_search_push_char(c);
1661                } else {
1662                    self.handle_insert_char_editor(c)?;
1663                }
1664            }
1665            // Prompt clipboard actions
1666            Action::PromptCopy => {
1667                if let Some(prompt) = &self.prompt {
1668                    let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
1669                    if !text.is_empty() {
1670                        self.clipboard.copy(text);
1671                        self.set_status_message(t!("clipboard.copied").to_string());
1672                    }
1673                }
1674            }
1675            Action::PromptCut => {
1676                if let Some(prompt) = &self.prompt {
1677                    let text = prompt.selected_text().unwrap_or_else(|| prompt.get_text());
1678                    if !text.is_empty() {
1679                        self.clipboard.copy(text);
1680                    }
1681                }
1682                if let Some(prompt) = self.prompt.as_mut() {
1683                    if prompt.has_selection() {
1684                        prompt.delete_selection();
1685                    } else {
1686                        prompt.clear();
1687                    }
1688                }
1689                self.set_status_message(t!("clipboard.cut").to_string());
1690                self.update_prompt_suggestions();
1691            }
1692            Action::PromptPaste => {
1693                if let Some(text) = self.clipboard.paste() {
1694                    if let Some(prompt) = self.prompt.as_mut() {
1695                        prompt.insert_str(&text);
1696                    }
1697                    self.update_prompt_suggestions();
1698                }
1699            }
1700            _ => {
1701                // TODO: Why do we have this catch-all? It seems like actions should either:
1702                // 1. Be handled explicitly above (like InsertChar, PopupConfirm, etc.)
1703                // 2. Or be converted to events consistently
1704                // This catch-all makes it unclear which actions go through event conversion
1705                // vs. direct handling. Consider making this explicit or removing the pattern.
1706                self.apply_action_as_events(action)?;
1707            }
1708        }
1709
1710        Ok(())
1711    }
1712}