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