Skip to main content

fresh/app/
input.rs

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