Skip to main content

fresh/app/
input_dispatch.rs

1//! Input dispatch using the hierarchical InputHandler system.
2//!
3//! This module provides the bridge between Editor and the InputHandler trait,
4//! dispatching input to modal components and processing deferred actions.
5
6use super::terminal_input::{should_enter_terminal_mode, TerminalModeInputHandler};
7use super::Editor;
8use crate::input::handler::{DeferredAction, InputContext, InputHandler, InputResult};
9use crate::input::keybindings::{Action, KeyContext};
10use crate::view::file_browser_input::FileBrowserInputHandler;
11use crate::view::query_replace_input::QueryReplaceConfirmInputHandler;
12use crate::view::ui::MenuInputHandler;
13use anyhow::Result as AnyhowResult;
14use crossterm::event::KeyEvent;
15use rust_i18n::t;
16
17impl Editor {
18    /// Dispatch input when in terminal mode.
19    ///
20    /// Returns `Some(InputResult)` if terminal mode handled the input,
21    /// `None` if not in terminal mode or if a modal is active.
22    pub fn dispatch_terminal_input(&mut self, event: &KeyEvent) -> Option<InputResult> {
23        // Skip if we're in a prompt/popup (those need to handle keys normally)
24        // — including the floating widget panel (Orchestrator picker,
25        // new-session form, plugin overlays), which is the editor-wide
26        // modal owner of the keyboard while it's up. Without this skip,
27        // a terminal-buffer-active window with `terminal_mode=true` would
28        // route keys to the PTY child even when the user's keystrokes
29        // are meant for the picker on top of it.
30        let in_modal = self.is_prompting()
31            || self.global_popups.is_visible()
32            || self.active_state().popups.is_visible()
33            || self.menu_state.active_menu.is_some()
34            || self.settings_state.as_ref().is_some_and(|s| s.visible)
35            || self.calibration_wizard.is_some()
36            || self.keybinding_editor.is_some()
37            || self.floating_widget_panel.is_some();
38
39        if in_modal {
40            return None;
41        }
42
43        // Handle terminal mode input
44        if self.active_window().terminal_mode {
45            // If the user navigated away from the terminal buffer (e.g. opened
46            // Review Diff via the command palette), the active buffer is no
47            // longer a terminal. Exit terminal mode so the new buffer's
48            // keybindings work.
49            if !self
50                .active_window()
51                .is_terminal_buffer(self.active_buffer())
52            {
53                self.active_window_mut().terminal_mode = false;
54                self.active_window_mut().key_context =
55                    crate::input::keybindings::KeyContext::Normal;
56                return None; // fall through to normal input dispatch
57            }
58            // Keyboard focus has been explicitly handed to the file
59            // explorer (issue #2029, sub-issue 1). Skip the PTY route
60            // even though `terminal_mode` is still set, so arrow keys
61            // reach the explorer instead of being swallowed by the
62            // shell. The `terminal_mode` flag is cleared up front by
63            // `take_focus_for_file_explorer`; this is a belt-and-braces
64            // guard against any async path that re-enables
65            // `terminal_mode` while file-explorer focus is legitimate.
66            if matches!(
67                self.active_window().key_context,
68                crate::input::keybindings::KeyContext::FileExplorer
69            ) {
70                return None;
71            }
72            // Plugin commands flagged `terminalBypass: true` (via
73            // `editor.registerCommand(..., { terminalBypass: true })`)
74            // resolve to actions that must reach the editor even
75            // when a terminal pane owns the keyboard — that's how
76            // bound shortcuts to commands like `Orchestrator: Open`
77            // stay reachable from inside `top`/`htop`/a shell.
78            // Resolve the key against the regular (Normal) context;
79            // if it's a registered bypass action, dispatch it and
80            // return *before* the terminal handler claims the key.
81            // Builtin UI actions (CommandPalette, QuickOpen, …)
82            // still flow through `TerminalModeInputHandler`'s own
83            // `is_terminal_ui_action` allowlist below.
84            let bypass_action = {
85                let keybindings = self.keybindings.read().unwrap();
86                let action = keybindings.resolve(event, KeyContext::Normal);
87                if self
88                    .command_registry
89                    .read()
90                    .unwrap()
91                    .is_terminal_bypass_action(&action)
92                {
93                    Some(action)
94                } else {
95                    None
96                }
97            };
98            if let Some(action) = bypass_action {
99                if let Err(e) = self.handle_action(action) {
100                    tracing::warn!("terminal-bypass action failed: {e}");
101                }
102                return Some(InputResult::Consumed);
103            }
104            let mut ctx = InputContext::new();
105            let keyboard_capture = self.active_window().keyboard_capture;
106            let keybindings = self.keybindings.read().unwrap();
107            let mut handler = TerminalModeInputHandler::new(keyboard_capture, &keybindings);
108            let result = handler.dispatch_input(event, &mut ctx);
109            drop(keybindings);
110            self.process_deferred_actions(ctx);
111            return Some(result);
112        }
113
114        // Check for keys that should re-enter terminal mode from scrollback view.
115        // Any plain character key exits scrollback and is forwarded to the terminal.
116        if self
117            .active_window()
118            .is_terminal_buffer(self.active_buffer())
119            && should_enter_terminal_mode(event)
120        {
121            self.enter_terminal_mode();
122            // Forward the key to the terminal so the user's input isn't lost
123            self.active_window_mut()
124                .send_terminal_key(event.code, event.modifiers);
125            return Some(InputResult::Consumed);
126        }
127
128        None
129    }
130
131    /// Dispatch input to the appropriate modal handler.
132    ///
133    /// Returns `Some(InputResult)` if a modal handled the input,
134    /// `None` if no modal is active and input should be handled normally.
135    pub fn dispatch_modal_input(&mut self, event: &KeyEvent) -> Option<InputResult> {
136        let mut ctx = InputContext::new();
137
138        // Settings has highest priority
139        if let Some(ref mut settings) = self.settings_state {
140            if settings.visible {
141                let result = settings.dispatch_input(event, &mut ctx);
142                self.process_deferred_actions(ctx);
143                return Some(result);
144            }
145        }
146
147        // Keybinding editor is next
148        if self.keybinding_editor.is_some() {
149            let result = self.handle_keybinding_editor_input(event);
150            return Some(result);
151        }
152
153        // Calibration wizard is next (modal, blocks all other input)
154        if self.calibration_wizard.is_some() {
155            let result = self.handle_calibration_input(event);
156            return Some(result);
157        }
158
159        // Menu is next
160        if self.menu_state.active_menu.is_some() {
161            let all_menus: Vec<crate::config::Menu> = self
162                .menus
163                .menus
164                .iter()
165                .chain(self.menu_state.plugin_menus.iter())
166                .cloned()
167                .collect();
168
169            let mut handler = MenuInputHandler::new(&mut self.menu_state, &all_menus);
170            let result = handler.dispatch_input(event, &mut ctx);
171            self.process_deferred_actions(ctx);
172            return Some(result);
173        }
174
175        // Prompt is next
176        if self.active_window().prompt.is_some() {
177            // Check for Alt+key keybindings in Prompt context first
178            // Use resolve_in_context_only to bypass Global bindings (like menu mnemonics)
179            // This allows Prompt-specific Alt+key bindings (like encoding toggle) to work
180            if event
181                .modifiers
182                .contains(crossterm::event::KeyModifiers::ALT)
183            {
184                if let crossterm::event::KeyCode::Char(_) = event.code {
185                    let prompt_action = self.keybindings.read().unwrap().resolve_in_context_only(
186                        event,
187                        crate::input::keybindings::KeyContext::Prompt,
188                    );
189                    if let Some(action) = prompt_action {
190                        // For file browser actions, route to handle_file_open_action
191                        if self.is_file_open_active() && self.handle_file_open_action(&action) {
192                            return Some(InputResult::Consumed);
193                        }
194                        // For other prompt actions, use handle_action
195                        if let Err(e) = self.handle_action(action) {
196                            tracing::warn!("Prompt action failed: {}", e);
197                        }
198                        return Some(InputResult::Consumed);
199                    }
200                }
201            }
202
203            // File browser prompts use FileBrowserInputHandler
204            if self.is_file_open_active() {
205                let active_window_id = self.active_window;
206                let __win = self
207                    .windows
208                    .get_mut(&active_window_id)
209                    .expect("active window present");
210                if let (Some(ref mut file_state), Some(ref mut prompt)) =
211                    (&mut __win.file_open_state, &mut __win.prompt)
212                {
213                    let mut handler = FileBrowserInputHandler::new(file_state, prompt);
214                    let result = handler.dispatch_input(event, &mut ctx);
215                    self.process_deferred_actions(ctx);
216                    return Some(result);
217                }
218            }
219
220            // QueryReplaceConfirm prompts use QueryReplaceConfirmInputHandler
221            use crate::view::prompt::PromptType;
222            let is_query_replace_confirm = self
223                .active_window()
224                .prompt
225                .as_ref()
226                .is_some_and(|p| p.prompt_type == PromptType::QueryReplaceConfirm);
227            if is_query_replace_confirm {
228                let mut handler = QueryReplaceConfirmInputHandler::new();
229                let result = handler.dispatch_input(event, &mut ctx);
230                self.process_deferred_actions(ctx);
231                return Some(result);
232            }
233
234            if let Some(ref mut prompt) = self.active_window_mut().prompt {
235                let result = prompt.dispatch_input(event, &mut ctx);
236                // Only return and process deferred actions if the prompt handled the input
237                // If Ignored, fall through to check global keybindings
238                if result != InputResult::Ignored {
239                    self.process_deferred_actions(ctx);
240                    return Some(result);
241                }
242            }
243        }
244
245        // Editor-pane popups (global + buffer) belong to the editor pane and
246        // must not capture input when the file explorer is the focused pane.
247        // Mirrors the priority encoded in `get_key_context()` via the same
248        // `popups_capture_keys()` predicate so the two paths cannot drift —
249        // one source of truth for "is the popup eligible to eat this key?".
250        if self.popups_capture_keys() {
251            // Completion popups consult the keybinding resolver in the
252            // `Completion` context first, so accept/dismiss can be remapped
253            // via the keybinding editor. Falls through to the popup's own
254            // handler for everything else (type-to-filter, navigation, etc.).
255            if let Some(action) = self.resolve_completion_popup_action(event) {
256                self.process_deferred_actions(ctx);
257                if let Err(e) = self.handle_action(action) {
258                    tracing::warn!("Completion popup action failed: {}", e);
259                }
260                return Some(InputResult::Consumed);
261            }
262
263            // The workspace-trust prompt is a bespoke modal with its own keys
264            // (mnemonics select-and-confirm, Q quits, Esc is inert). Intercept
265            // before the generic popup handler so list type-to-filter etc.
266            // never swallow them.
267            if self.global_popups.top().is_some_and(|p| {
268                matches!(
269                    p.resolver,
270                    crate::view::popup::PopupResolver::WorkspaceTrust
271                )
272            }) {
273                if let Some(result) = self.handle_workspace_trust_key(event) {
274                    return Some(result);
275                }
276            }
277
278            // Editor-level (global) popups take precedence over buffer popups
279            // so that plugin notifications stay focused even when the active
280            // buffer owns its own popup stack.
281            if self.global_popups.is_visible() {
282                let result = self.global_popups.dispatch_input(event, &mut ctx);
283                self.process_deferred_actions(ctx);
284                if result != InputResult::Ignored {
285                    return Some(result);
286                }
287                // Re-check visibility — the dispatch may have queued a
288                // ClosePopup that the deferred-action processor has now fired.
289                return None;
290            }
291
292            // Popup is next
293            if self.active_state().popups.is_visible() {
294                let result = self
295                    .active_state_mut()
296                    .popups
297                    .dispatch_input(event, &mut ctx);
298                self.process_deferred_actions(ctx);
299                // If the popup handler returned Ignored (e.g., non-word
300                // character, Ctrl+key, arrow keys), fall through to normal
301                // input handling. The deferred ClosePopup action was already
302                // processed above.
303                if result != InputResult::Ignored {
304                    return Some(result);
305                }
306            }
307        }
308
309        None
310    }
311
312    /// Process deferred actions collected during input handling.
313    pub fn process_deferred_actions(&mut self, ctx: InputContext) {
314        // Set status message if provided
315        if let Some(msg) = ctx.status_message {
316            self.set_status_message(msg);
317        }
318
319        // Process each deferred action
320        for action in ctx.deferred_actions {
321            if let Err(e) = self.execute_deferred_action(action) {
322                self.set_status_message(
323                    t!("error.deferred_action", error = e.to_string()).to_string(),
324                );
325            }
326        }
327    }
328
329    /// Execute a single deferred action.
330    fn execute_deferred_action(&mut self, action: DeferredAction) -> AnyhowResult<()> {
331        match action {
332            // Settings actions
333            DeferredAction::CloseSettings { save } => {
334                if save {
335                    self.save_settings();
336                }
337                self.close_settings(false);
338            }
339            DeferredAction::PasteToSettings => {
340                if let Some(text) = self.clipboard.paste() {
341                    if !text.is_empty() {
342                        if let Some(settings) = &mut self.settings_state {
343                            if let Some(dialog) = settings.entry_dialog_mut() {
344                                dialog.insert_str(&text);
345                            }
346                        }
347                    }
348                }
349            }
350            DeferredAction::OpenConfigFile { layer } => {
351                self.open_config_file(layer)?;
352            }
353
354            // Menu actions
355            DeferredAction::CloseMenu => {
356                self.close_menu_with_auto_hide();
357            }
358            DeferredAction::ExecuteMenuAction { action, args } => {
359                // Convert menu action to keybinding Action and execute
360                if let Some(kb_action) = self.menu_action_to_action(&action, args) {
361                    self.handle_action(kb_action)?;
362                }
363            }
364
365            // Prompt actions
366            DeferredAction::ClosePrompt => {
367                self.cancel_prompt();
368            }
369            DeferredAction::ConfirmPrompt => {
370                self.handle_action(Action::PromptConfirm)?;
371            }
372            DeferredAction::UpdatePromptSuggestions => {
373                self.update_prompt_suggestions();
374            }
375            DeferredAction::PromptHistoryPrev => {
376                self.prompt_history_prev();
377            }
378            DeferredAction::PromptHistoryNext => {
379                self.prompt_history_next();
380            }
381            DeferredAction::PreviewThemeFromPrompt => {
382                if let Some(prompt) = &self.active_window_mut().prompt {
383                    if matches!(
384                        prompt.prompt_type,
385                        crate::view::prompt::PromptType::SelectTheme { .. }
386                    ) {
387                        let theme_name = prompt.input.clone();
388                        self.preview_theme(&theme_name);
389                    }
390                }
391            }
392            DeferredAction::PromptSelectionChanged { selected_index } => {
393                // Fire hook for plugin prompts so they can update live preview
394                let plugin_custom_type =
395                    self.active_window()
396                        .prompt
397                        .as_ref()
398                        .and_then(|p| match &p.prompt_type {
399                            crate::view::prompt::PromptType::Plugin { custom_type } => {
400                                Some(custom_type.clone())
401                            }
402                            _ => None,
403                        });
404                if let Some(custom_type) = plugin_custom_type {
405                    self.plugin_manager.read().unwrap().run_hook(
406                        "prompt_selection_changed",
407                        crate::services::plugins::hooks::HookArgs::PromptSelectionChanged {
408                            prompt_type: custom_type.clone(),
409                            selected_index,
410                        },
411                    );
412                }
413            }
414
415            // Popup actions
416            DeferredAction::ClosePopup => {
417                // Route through handle_popup_cancel so popup-specific
418                // cleanup runs (e.g. the LSP auto-prompt needs to mark
419                // the language as prompted and drop the pending queue
420                // entry — otherwise the render-time drain would just
421                // re-open the popup on the next frame, defeating Esc).
422                self.handle_popup_cancel();
423            }
424            DeferredAction::ConfirmPopup => {
425                self.handle_action(Action::PopupConfirm)?;
426            }
427            DeferredAction::PopupTypeChar(c) => {
428                self.handle_popup_type_char(c);
429            }
430            DeferredAction::PopupBackspace => {
431                self.handle_popup_backspace();
432            }
433            DeferredAction::CopyToClipboard(text) => {
434                self.clipboard.copy(text);
435                self.set_status_message(t!("clipboard.copied").to_string());
436            }
437
438            // Generic action execution
439            DeferredAction::ExecuteAction(kb_action) => {
440                self.handle_action(kb_action)?;
441            }
442
443            // Character insertion with suggestion update
444            DeferredAction::InsertCharAndUpdate(c) => {
445                if let Some(ref mut prompt) = self.active_window_mut().prompt {
446                    prompt.insert_char(c);
447                }
448                self.update_prompt_suggestions();
449            }
450
451            // File browser actions
452            DeferredAction::FileBrowserSelectPrev => {
453                if let Some(state) = &mut self.active_window_mut().file_open_state {
454                    state.select_prev();
455                }
456            }
457            DeferredAction::FileBrowserSelectNext => {
458                if let Some(state) = &mut self.active_window_mut().file_open_state {
459                    state.select_next();
460                }
461            }
462            DeferredAction::FileBrowserPageUp => {
463                if let Some(state) = &mut self.active_window_mut().file_open_state {
464                    state.page_up(10);
465                }
466            }
467            DeferredAction::FileBrowserPageDown => {
468                if let Some(state) = &mut self.active_window_mut().file_open_state {
469                    state.page_down(10);
470                }
471            }
472            DeferredAction::FileBrowserConfirm => {
473                // Must call handle_file_open_action directly to get proper
474                // file browser behavior (e.g., project switch triggering restart)
475                self.handle_file_open_action(&Action::PromptConfirm);
476            }
477            DeferredAction::FileBrowserAcceptSuggestion => {
478                self.handle_file_open_action(&Action::PromptAcceptSuggestion);
479            }
480            DeferredAction::FileBrowserGoParent => {
481                // Navigate to parent directory
482                let parent = self
483                    .active_window_mut()
484                    .file_open_state
485                    .as_ref()
486                    .and_then(|s| s.current_dir.parent())
487                    .map(|p| p.to_path_buf());
488                if let Some(parent_path) = parent {
489                    self.load_file_open_directory(parent_path);
490                }
491            }
492            DeferredAction::FileBrowserUpdateFilter => {
493                self.update_file_open_filter();
494            }
495            DeferredAction::FileBrowserToggleHidden => {
496                self.file_open_toggle_hidden();
497            }
498
499            // Interactive replace actions
500            DeferredAction::InteractiveReplaceKey(c) => {
501                self.handle_interactive_replace_key(c)?;
502            }
503            DeferredAction::CancelInteractiveReplace => {
504                self.cancel_prompt();
505                self.active_window_mut().interactive_replace_state = None;
506            }
507
508            // Terminal mode actions
509            DeferredAction::ToggleKeyboardCapture => {
510                self.active_window_mut().keyboard_capture =
511                    !self.active_window_mut().keyboard_capture;
512                if self.active_window_mut().keyboard_capture {
513                    self.set_status_message(
514                        "Keyboard capture ON - all keys go to terminal (F9 to toggle)".to_string(),
515                    );
516                } else {
517                    self.set_status_message(
518                        "Keyboard capture OFF - UI bindings active (F9 to toggle)".to_string(),
519                    );
520                }
521            }
522            DeferredAction::SendTerminalKey(code, modifiers) => {
523                self.active_window_mut().send_terminal_key(code, modifiers);
524            }
525            DeferredAction::SendTerminalMouse {
526                col,
527                row,
528                kind,
529                modifiers,
530            } => {
531                self.active_window_mut()
532                    .send_terminal_mouse(col, row, kind, modifiers);
533            }
534            DeferredAction::ExitTerminalMode { explicit } => {
535                self.active_window_mut().terminal_mode = false;
536                self.active_window_mut().key_context =
537                    crate::input::keybindings::KeyContext::Normal;
538                if explicit {
539                    // User explicitly exited - don't auto-resume when switching back
540                    let buf = self.active_buffer();
541                    self.active_window_mut().terminal_mode_resume.remove(&buf);
542                    {
543                        let __b = self.active_buffer();
544                        self.active_window_mut().sync_terminal_to_buffer(__b);
545                    };
546                    self.set_status_message(
547                        "Terminal mode disabled - read only (Ctrl+Space to resume)".to_string(),
548                    );
549                }
550            }
551            DeferredAction::EnterScrollbackMode => {
552                self.active_window_mut().terminal_mode = false;
553                self.active_window_mut().key_context =
554                    crate::input::keybindings::KeyContext::Normal;
555                {
556                    let __b = self.active_buffer();
557                    self.active_window_mut().sync_terminal_to_buffer(__b);
558                };
559                self.set_status_message(
560                    "Scrollback mode - use PageUp/Down to scroll (Ctrl+Space to resume)"
561                        .to_string(),
562                );
563                // Scroll up using normal buffer scrolling
564                self.handle_action(Action::MovePageUp)?;
565            }
566            DeferredAction::EnterTerminalMode => {
567                self.enter_terminal_mode();
568            }
569        }
570
571        Ok(())
572    }
573
574    /// Convert a menu action string to a keybinding Action.
575    fn menu_action_to_action(
576        &self,
577        action_name: &str,
578        args: std::collections::HashMap<String, serde_json::Value>,
579    ) -> Option<Action> {
580        // Try to parse as a built-in action first
581        if let Some(action) = Action::from_str(action_name, &args) {
582            return Some(action);
583        }
584
585        // Otherwise treat as a plugin action
586        Some(Action::PluginAction(action_name.to_string()))
587    }
588
589    /// Navigate to previous history entry in prompt.
590    fn prompt_history_prev(&mut self) {
591        // Get the prompt type and current input
592        let prompt_info = self
593            .active_window()
594            .prompt
595            .as_ref()
596            .map(|p| (p.prompt_type.clone(), p.input.clone()));
597
598        if let Some((prompt_type, current_input)) = prompt_info {
599            // Get the history key for this prompt type
600            if let Some(key) = Self::prompt_type_to_history_key(&prompt_type) {
601                if let Some(history) = self.active_window_mut().prompt_histories.get_mut(&key) {
602                    if let Some(entry) = history.navigate_prev(&current_input) {
603                        if let Some(ref mut prompt) = self.active_window_mut().prompt {
604                            prompt.set_input(entry);
605                        }
606                    }
607                }
608            }
609        }
610    }
611
612    /// Navigate to next history entry in prompt.
613    fn prompt_history_next(&mut self) {
614        let prompt_type = self
615            .active_window()
616            .prompt
617            .as_ref()
618            .map(|p| p.prompt_type.clone());
619
620        if let Some(prompt_type) = prompt_type {
621            // Get the history key for this prompt type
622            if let Some(key) = Self::prompt_type_to_history_key(&prompt_type) {
623                if let Some(history) = self.active_window_mut().prompt_histories.get_mut(&key) {
624                    if let Some(entry) = history.navigate_next() {
625                        if let Some(ref mut prompt) = self.active_window_mut().prompt {
626                            prompt.set_input(entry);
627                        }
628                    }
629                }
630            }
631        }
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    #[test]
640    fn test_deferred_action_close_menu() {
641        // This is a basic structure test - full integration tests
642        // would require a complete Editor setup
643        let action = DeferredAction::CloseMenu;
644        assert!(matches!(action, DeferredAction::CloseMenu));
645    }
646
647    #[test]
648    fn test_deferred_action_execute_menu_action() {
649        let action = DeferredAction::ExecuteMenuAction {
650            action: "save".to_string(),
651            args: std::collections::HashMap::new(),
652        };
653        if let DeferredAction::ExecuteMenuAction { action: name, .. } = action {
654            assert_eq!(name, "save");
655        } else {
656            panic!("Expected ExecuteMenuAction");
657        }
658    }
659}