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