//! Key event dispatcher: maps crossterm `KeyEvent` values into `Action` //! variants, with special handling for overlays, auto-complete, and the //! inline editor. use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::app::mode; use crate::app::runtime::actions::Action; use crate::app::runtime::commands::apply_command; use crate::app::state::rest::AppStateRest; use crate::app::state::types::Overlay; use crate::controller::command::parse_command; /// Translate a terminal `KeyEvent` into zero or more `Action` values /// based on the current application state. /// /// Flow: check overlay first (Editor gets its own handler) -> match on /// key code and modifiers -> handle auto-complete cycles -> dispatch to /// `Action` variants or overlay-specific handlers. /// /// Why: when Editor overlay is active, all key events are consumed by the /// editor handler and never reach the main action dispatch. Return `Vec` /// so that a single key press can trigger multiple actions. #[allow(clippy::too_many_lines)] pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { // While Editor overlay is active, route input directly to the editor handler if state.misc.overlay == Overlay::Editor { match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { return vec![Action::QuitConfirm]; } KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { if let Some(ref ed) = state.misc.editor.clone() { let content = ed.as_string(); if let Err(e) = std::fs::write(&ed.path, &content) { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Error, format!("Save failed: {e}"), )); } else { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Success, format!("Saved {}", ed.path), )); } state.dirty = true; } return vec![]; } KeyCode::Esc => { crate::app::mode::editor::handle_editor_dismiss(state); return vec![]; } KeyCode::Backspace => { if let Some(ref mut ed) = state.misc.editor { ed.delete_left(); state.dirty = true; } return vec![]; } KeyCode::Enter => { crate::app::mode::editor::handle_editor_input(state, "\n"); return vec![]; } KeyCode::Char(c) => { crate::app::mode::editor::handle_editor_input(state, &c.to_string()); return vec![]; } _ => return vec![], } } if state.misc.overlay == Overlay::Learning { match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { return vec![Action::QuitConfirm]; } KeyCode::Esc => { return vec![Action::CloseOverlay]; } KeyCode::Up => { let items = crate::app::mode::learning::get_learning_items(state); let n = items.len(); state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 }; state.dirty = true; return vec![]; } KeyCode::Down => { let items = crate::app::mode::learning::get_learning_items(state); let n = items.len(); state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n }; state.dirty = true; return vec![]; } KeyCode::Enter | KeyCode::Char('a') => { let items = crate::app::mode::learning::get_learning_items(state); if let Some(crate::app::mode::learning::LearningItem::Pending { name, .. }) = items.get(state.misc.selected_index) { return vec![Action::LessonAccept { name: name.clone() }]; } return vec![]; } KeyCode::Char('r') => { let items = crate::app::mode::learning::get_learning_items(state); if let Some(crate::app::mode::learning::LearningItem::Pending { name, .. }) = items.get(state.misc.selected_index) { return vec![Action::LessonReject { name: name.clone() }]; } return vec![]; } KeyCode::Char('d') | KeyCode::Delete | KeyCode::Backspace => { let items = crate::app::mode::learning::get_learning_items(state); if let Some(item) = items.get(state.misc.selected_index) { match item { crate::app::mode::learning::LearningItem::Pending { name, .. } => { return vec![Action::LessonReject { name: name.clone() }]; } crate::app::mode::learning::LearningItem::Stored { name, .. } => { return vec![Action::LessonDelete { name: name.clone() }]; } } } return vec![]; } _ => return vec![], } } match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { vec![Action::QuitConfirm] } KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { vec![Action::CloseOverlay] } KeyCode::Enter => { if state.input.autocomplete_visible { state.input.select_autocomplete(); state.dirty = true; return Vec::new(); } if state.misc.overlay.is_active() { return handle_overlay_enter(state); } let text = state.input.buffer.clone(); if text.starts_with('/') { return apply_command(parse_command(&text)); } vec![Action::SubmitInput(text)] } KeyCode::Backspace => { if state.input.autocomplete_visible { state.input.close_autocomplete(); state.dirty = true; return Vec::new(); } vec![Action::DeleteChar] } KeyCode::Delete => { if state.input.autocomplete_visible { state.input.close_autocomplete(); state.dirty = true; return Vec::new(); } vec![Action::DeleteCharRight] } KeyCode::Left => { vec![Action::CursorLeft] } KeyCode::Right => { vec![Action::CursorRight] } KeyCode::Up => { if state.input.autocomplete_visible { state.input.cycle_autocomplete(false); state.dirty = true; Vec::new() } else if state.misc.overlay == Overlay::Effort { mode::effort::cycle_effort(state); Vec::new() } else if state.misc.overlay == Overlay::Rewind { let n = mode::rewind::rewind_count(state); state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 }; state.dirty = true; Vec::new() } else if state.misc.overlay == Overlay::ModelSelector { let n = state.app_config.providers.len(); state.misc.selected_index = if state.misc.selected_index == 0 { n.saturating_sub(1) } else { state.misc.selected_index - 1 }; state.dirty = true; Vec::new() } else if key.modifiers.contains(KeyModifiers::CONTROL) { vec![Action::ScrollUp] } else { vec![Action::HistoryUp] } } KeyCode::Down => { if state.input.autocomplete_visible { state.input.cycle_autocomplete(true); state.dirty = true; Vec::new() } else if state.misc.overlay == Overlay::Effort { mode::effort::cycle_effort(state); Vec::new() } else if state.misc.overlay == Overlay::Rewind { let n = mode::rewind::rewind_count(state); state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n }; state.dirty = true; Vec::new() } else if state.misc.overlay == Overlay::ModelSelector { let n = state.app_config.providers.len(); state.misc.selected_index = if n == 0 { 0 } else { (state.misc.selected_index + 1) % n }; state.dirty = true; Vec::new() } else if key.modifiers.contains(KeyModifiers::CONTROL) { vec![Action::ScrollDown] } else { vec![Action::HistoryDown] } } KeyCode::PageUp => { vec![Action::ScrollUp] } KeyCode::PageDown => { vec![Action::ScrollDown] } KeyCode::Esc => { if state.turn_in_flight() { vec![Action::AbortTurn] } else if state.input.autocomplete_visible { state.input.close_autocomplete(); state.dirty = true; Vec::new() } else if state.misc.overlay.is_active() { vec![Action::CloseOverlay] } else { Vec::new() } } KeyCode::Tab => { if state.input.buffer.starts_with('/') { if state.input.autocomplete_visible { state.input.cycle_autocomplete(true); } else { state.input.tab_complete(); } state.dirty = true; } Vec::new() } KeyCode::Char(c) => { if state.input.autocomplete_visible { state.input.close_autocomplete(); state.dirty = true; } // Insert the character inline so we can immediately check the // new buffer state for autocomplete triggers. state.input.insert(c); state.dirty = true; // Show autocomplete immediately when the buffer starts with `/`, // without requiring an extra Tab press. if state.input.buffer.starts_with('/') { state.input.open_autocomplete(); } Vec::new() } _ => Vec::new(), } } /// Handle pressing Enter while a modal overlay is active: dispatch /// overlay-specific submit logic (bash, settings, todo, quit, etc.). /// /// Flow: match the current overlay -> run the associated handler -> /// mutate state or produce actions as needed -> always return `Vec::new()` /// (the handler itself applies state mutations). fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { match state.misc.overlay { Overlay::Bash => { let command = state.input.buffer.clone(); mode::bash::handle_bash_submit(state, command); Vec::new() } Overlay::Settings => { mode::settings::cycle_internet_mode(&mut state.settings); state.dirty = true; Vec::new() } Overlay::Todo => { mode::todo::handle_todo_toggle(state); Vec::new() } Overlay::QuitConfirm => { vec![mode::quit_confirm::handle_quit_confirm(true)] } Overlay::KeyInput => { let text = state.input.buffer.clone(); mode::key_input::handle_key_text(state, text.clone()); if text.is_empty() { state.settings.api_keys.remove(&state.settings.provider); } else { state.settings.api_keys.insert(state.settings.provider.clone(), text.clone()); } let _ = state.settings.save(); state.input.buffer.clear(); state.input.cursor = 0; state.misc.overlay = Overlay::None; state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Success, "API key saved".to_string(), )); state.dirty = true; Vec::new() } Overlay::Mcp => { mode::mcp::connect_mcp(state, ""); Vec::new() } Overlay::Rewind => { let idx = state.misc.selected_index; mode::rewind::rewind_to(state, idx); Vec::new() } Overlay::ModelSelector => { let providers: Vec = state.app_config.providers.keys().cloned().collect(); if let Some(provider) = providers.get(state.misc.selected_index) { if let Some(cfg) = state.app_config.providers.get(provider) { let model = cfg.default_model.clone().unwrap_or_else(|| { tracing::warn!("[input] provider '{}' has no default_model, using 'claude-opus-4-8'", provider); "claude-opus-4-8".to_string() }); state.settings.provider.clone_from(provider); state.settings.model.clone_from(&model); if let Some(ref key) = cfg.default_api_key { state.settings.api_keys.insert(provider.clone(), key.clone()); } else if let Some(env_key) = cfg.api_key_env.as_ref().and_then(|env| std::env::var(env).ok()) { state.settings.api_keys.insert(provider.clone(), env_key); } let _ = state.settings.save(); state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Success, format!("Switched to {provider} / {model}"), )); } } state.misc.overlay = Overlay::None; state.dirty = true; Vec::new() } Overlay::ClearConfirm => { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Info, "Transcript cleared".to_string(), )); state.misc.overlay = Overlay::None; state.dirty = true; Vec::new() } _ => Vec::new(), } }