//! Keyboard input handling: vim modes, chords, and command execution. use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use super::{AppState, Mode}; use crate::core::types::Role; /// Internal control flow signal returned by [`handle_key`]. pub(super) enum LoopControl { /// The user pressed Enter with non-empty input; send this message to the core. SendMessage(String), /// The user pressed Ctrl+C or Ctrl+D; exit the event loop. Quit, /// The user ran `:clear`; wipe the conversation. ClearHistory, /// The user responded to a tool approval prompt. ToolApproval { tool_use_id: String, approved: bool }, } /// Map a key event to a [`LoopControl`] signal, mutating `state` as a side-effect. /// /// Ctrl+C / Ctrl+D quit from any mode. All other keys are dispatched to the /// handler for the current [`Mode`]. pub(super) fn handle_key(key: Option, state: &mut AppState) -> Option { let key = key?; // Clear any transient status error on the next keypress. state.status_error = None; // If a tool approval is pending, intercept y/n before normal key handling. if let Some(approval) = &state.pending_approval { let tool_use_id = approval.tool_use_id.clone(); match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { state.pending_approval = None; return Some(LoopControl::ToolApproval { tool_use_id, approved: true, }); } KeyCode::Char('n') | KeyCode::Char('N') => { state.pending_approval = None; return Some(LoopControl::ToolApproval { tool_use_id, approved: false, }); } _ => return None, // ignore other keys while approval pending } } // Ctrl+C quits from any mode. if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { return Some(LoopControl::Quit); } // Ctrl+D quits only from Insert/Command mode. In Normal mode it scrolls. if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('d') && !matches!(state.mode, Mode::Normal) { return Some(LoopControl::Quit); } match &state.mode { Mode::Normal => handle_normal_key(key, state), Mode::Insert => handle_insert_key(key, state), Mode::Command => handle_command_key(key, state), } } enum ChordResult { /// The chord was fully matched; the returned closure applies the effect. Executed, /// The keys so far are a valid prefix of at least one chord. Prefix, /// The keys don't match any chord or prefix. NoMatch, } /// Check `keys` against known multi-key chords in Normal mode. /// /// Currently only `gg` (scroll to top) is defined. Returns the match status /// without mutating state -- the caller applies effects for `Executed`. fn resolve_chord(keys: &[char]) -> ChordResult { match keys { [.., 'g', 'g'] => ChordResult::Executed, [.., 'g'] => ChordResult::Prefix, _ => ChordResult::NoMatch, } } /// Handle a keypress in Normal mode. /// /// | Key | Effect | /// |-----|-------------------------------------------| /// | `i` | Switch to Insert mode | /// | `:` | Switch to Command mode (empty buffer) | /// | `j` | Scroll down one line | /// | `k` | Scroll up one line | /// | `G` | Scroll to bottom | /// | `g` | Begin chord; `gg` scrolls to top | /// | Ctrl+d | Scroll down half a page | /// | Ctrl+u | Scroll up half a page | fn handle_normal_key(key: KeyEvent, state: &mut AppState) -> Option { let is_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { KeyCode::Char('d') if is_ctrl => { let half = (state.viewport_height / 2).max(1); state.scroll = state.scroll.saturating_add(half); state.pending_keys.clear(); } KeyCode::Char('u') if is_ctrl => { let half = (state.viewport_height / 2).max(1); state.scroll = state.scroll.saturating_sub(half); state.pending_keys.clear(); } KeyCode::Char('i') if !is_ctrl && state.pending_keys.is_empty() => { state.mode = Mode::Insert; } KeyCode::Char(':') if !is_ctrl && state.pending_keys.is_empty() => { state.command_buffer.clear(); state.mode = Mode::Command; } KeyCode::Char('j') if !is_ctrl && state.pending_keys.is_empty() => { state.scroll = state.scroll.saturating_add(1); } KeyCode::Char('k') if !is_ctrl && state.pending_keys.is_empty() => { state.scroll = state.scroll.saturating_sub(1); } KeyCode::Char('G') if !is_ctrl && state.pending_keys.is_empty() => { state.scroll = u16::MAX; } KeyCode::Char(c) => { state.pending_keys.push(c); match resolve_chord(&state.pending_keys) { ChordResult::Executed => { // Apply the chord effect. Currently only `gg`. state.scroll = 0; state.pending_keys.clear(); } ChordResult::NoMatch => { state.pending_keys.clear(); } ChordResult::Prefix => {} // wait for more keys } } _ => { state.pending_keys.clear(); } } None } /// Handle a keypress in Insert mode (the default text-entry mode). /// /// | Key | Effect | /// |------------------|-------------------------------------------------| /// | Esc | Switch to Normal mode | /// | Printable char | Append to input buffer | /// | Backspace | Pop last char from input buffer | /// | Enter (non-empty)| Take input, push User message, return `SendMessage` | /// | Enter (empty) | No-op | fn handle_insert_key(key: KeyEvent, state: &mut AppState) -> Option { match key.code { KeyCode::Esc => { state.mode = Mode::Normal; None } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { state.input.push(c); None } KeyCode::Backspace => { state.input.pop(); None } KeyCode::Enter => { let msg = std::mem::take(&mut state.input); if msg.is_empty() { None } else { state.messages.push((Role::User, msg.clone())); Some(LoopControl::SendMessage(msg)) } } _ => None, } } /// Execute a parsed command string and return the appropriate control signal. /// /// | Command | Effect | /// |---------------|-------------------------------------------------| /// | `quit` / `q` | Return `LoopControl::Quit` | /// | `clear` | Clear messages and scroll, return `ClearHistory` | /// | anything else | Set `status_error` on state | fn execute_command(buf: &str, state: &mut AppState) -> Option { match buf.trim() { "quit" | "q" => Some(LoopControl::Quit), "clear" => { state.messages.clear(); state.scroll = 0; Some(LoopControl::ClearHistory) } other => { state.status_error = Some(format!("Unknown command: {other}")); None } } } /// Handle a keypress in Command mode (ex-style `:` command entry). /// /// | Key | Effect | /// |-----------|-----------------------------------------------------| /// | Esc | Abandon command, return to Normal | /// | Enter | Accept command (execution deferred to 2.3), Normal | /// | Backspace | Pop from buffer; if empty, return to Normal | /// | Char(c) | Append to command buffer | fn handle_command_key(key: KeyEvent, state: &mut AppState) -> Option { match key.code { KeyCode::Esc => { state.mode = Mode::Normal; } KeyCode::Enter => { state.mode = Mode::Normal; return execute_command(&state.command_buffer.clone(), state); } KeyCode::Backspace => { state.command_buffer.pop(); } KeyCode::Char(c) => { state.command_buffer.push(c); } _ => {} } None } #[cfg(test)] mod tests { use super::*; fn make_key(code: KeyCode) -> Option { Some(KeyEvent::new(code, KeyModifiers::empty())) } fn ctrl_key(c: char) -> Option { Some(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)) } // --- handle_key tests --- #[test] fn handle_key_printable_appends() { let mut state = AppState::new(); handle_key(make_key(KeyCode::Char('h')), &mut state); assert_eq!(state.input, "h"); } #[test] fn handle_key_backspace_pops() { let mut state = AppState::new(); state.input = "ab".to_string(); handle_key(make_key(KeyCode::Backspace), &mut state); assert_eq!(state.input, "a"); } #[test] fn handle_key_backspace_empty_noop() { let mut state = AppState::new(); handle_key(make_key(KeyCode::Backspace), &mut state); assert_eq!(state.input, ""); } #[test] fn handle_key_enter_empty_noop() { let mut state = AppState::new(); let result = handle_key(make_key(KeyCode::Enter), &mut state); assert!(result.is_none()); assert!(state.messages.is_empty()); } #[test] fn handle_key_enter_sends_and_clears() { let mut state = AppState::new(); state.input = "hello".to_string(); let result = handle_key(make_key(KeyCode::Enter), &mut state); assert!(state.input.is_empty()); assert_eq!(state.messages.len(), 1); assert!(matches!(result, Some(LoopControl::SendMessage(ref m)) if m == "hello")); } #[test] fn handle_key_ctrl_c_quits() { let mut state = AppState::new(); let result = handle_key(ctrl_key('c'), &mut state); assert!(matches!(result, Some(LoopControl::Quit))); } #[test] fn insert_ctrl_char_is_noop() { let mut state = AppState::new(); handle_key(ctrl_key('a'), &mut state); assert_eq!(state.input, "", "Ctrl+A should not insert 'a'"); } // --- mode tests --- #[test] fn mode_starts_insert() { assert_eq!(AppState::new().mode, Mode::Insert); } #[test] fn insert_esc_enters_normal() { let mut state = AppState::new(); handle_key(make_key(KeyCode::Esc), &mut state); assert_eq!(state.mode, Mode::Normal); } #[test] fn normal_i_enters_insert() { let mut state = AppState::new(); state.mode = Mode::Normal; handle_key(make_key(KeyCode::Char('i')), &mut state); assert_eq!(state.mode, Mode::Insert); } #[test] fn normal_colon_enters_command() { let mut state = AppState::new(); state.mode = Mode::Normal; handle_key(make_key(KeyCode::Char(':')), &mut state); assert_eq!(state.mode, Mode::Command); } #[test] fn command_esc_enters_normal() { let mut state = AppState::new(); state.mode = Mode::Command; state.command_buffer = "q".to_string(); handle_key(make_key(KeyCode::Esc), &mut state); assert_eq!(state.mode, Mode::Normal); } #[test] fn command_enter_enters_normal() { let mut state = AppState::new(); state.mode = Mode::Command; handle_key(make_key(KeyCode::Enter), &mut state); assert_eq!(state.mode, Mode::Normal); } #[test] fn command_chars_append_to_buffer() { let mut state = AppState::new(); state.mode = Mode::Command; handle_key(make_key(KeyCode::Char('q')), &mut state); assert_eq!(state.mode, Mode::Command); assert_eq!(state.command_buffer, "q"); } #[test] fn command_backspace_empty_stays_in_command() { let mut state = AppState::new(); state.mode = Mode::Command; state.command_buffer = "ab".to_string(); handle_key(make_key(KeyCode::Backspace), &mut state); assert_eq!(state.command_buffer, "a"); handle_key(make_key(KeyCode::Backspace), &mut state); assert_eq!(state.command_buffer, ""); handle_key(make_key(KeyCode::Backspace), &mut state); assert_eq!(state.command_buffer, ""); handle_key(make_key(KeyCode::Backspace), &mut state); assert_eq!(state.command_buffer, ""); } #[test] fn normal_j_increments_scroll() { let mut state = AppState::new(); state.mode = Mode::Normal; handle_key(make_key(KeyCode::Char('j')), &mut state); assert_eq!(state.scroll, 1); } #[test] fn normal_k_decrements_scroll() { let mut state = AppState::new(); state.mode = Mode::Normal; state.scroll = 5; handle_key(make_key(KeyCode::Char('k')), &mut state); assert_eq!(state.scroll, 4); } #[test] fn normal_k_clamps_at_zero() { let mut state = AppState::new(); state.mode = Mode::Normal; handle_key(make_key(KeyCode::Char('k')), &mut state); assert_eq!(state.scroll, 0); } #[test] fn normal_big_g_scrolls_bottom() { let mut state = AppState::new(); state.mode = Mode::Normal; handle_key(make_key(KeyCode::Char('G')), &mut state); assert_eq!(state.scroll, u16::MAX); } #[test] fn normal_gg_scrolls_top() { let mut state = AppState::new(); state.mode = Mode::Normal; state.scroll = 50; handle_key(make_key(KeyCode::Char('g')), &mut state); assert_eq!(state.pending_keys, vec!['g']); // prefix, waiting handle_key(make_key(KeyCode::Char('g')), &mut state); assert_eq!(state.scroll, 0); assert!(state.pending_keys.is_empty()); } #[test] fn normal_g_then_other_clears_pending() { let mut state = AppState::new(); state.mode = Mode::Normal; state.scroll = 50; handle_key(make_key(KeyCode::Char('g')), &mut state); handle_key(make_key(KeyCode::Char('x')), &mut state); assert!(state.pending_keys.is_empty()); assert_eq!(state.scroll, 50); // unchanged } #[test] fn ctrl_c_quits_from_any_mode() { for mode in [Mode::Normal, Mode::Insert, Mode::Command] { let mut state = AppState::new(); state.mode = mode; let result = handle_key(ctrl_key('c'), &mut state); assert!(matches!(result, Some(LoopControl::Quit))); } } #[test] fn ctrl_d_quits_from_insert() { let mut state = AppState::new(); let result = handle_key(ctrl_key('d'), &mut state); assert!(matches!(result, Some(LoopControl::Quit))); } #[test] fn ctrl_d_scrolls_in_normal() { let mut state = AppState::new(); state.mode = Mode::Normal; state.viewport_height = 20; let result = handle_key(ctrl_key('d'), &mut state); assert!(result.is_none()); assert_eq!(state.scroll, 10); } #[test] fn ctrl_u_scrolls_up_in_normal() { let mut state = AppState::new(); state.mode = Mode::Normal; state.viewport_height = 20; state.scroll = 15; let result = handle_key(ctrl_key('u'), &mut state); assert!(result.is_none()); assert_eq!(state.scroll, 5); } #[test] fn ctrl_u_clamps_at_zero() { let mut state = AppState::new(); state.mode = Mode::Normal; state.viewport_height = 20; state.scroll = 3; handle_key(ctrl_key('u'), &mut state); assert_eq!(state.scroll, 0); } // --- execute_command tests --- #[test] fn command_quit_returns_quit() { let mut state = AppState::new(); let result = execute_command("quit", &mut state); assert!(matches!(result, Some(LoopControl::Quit))); } #[test] fn command_q_returns_quit() { let mut state = AppState::new(); let result = execute_command("q", &mut state); assert!(matches!(result, Some(LoopControl::Quit))); } #[test] fn command_clear_empties_messages() { let mut state = AppState::new(); state.messages.push((Role::User, "hi".to_string())); state.messages.push((Role::Assistant, "hello".to_string())); state.scroll = 10; let result = execute_command("clear", &mut state); assert!(matches!(result, Some(LoopControl::ClearHistory))); assert!(state.messages.is_empty()); assert_eq!(state.scroll, 0); } #[test] fn command_unknown_sets_status_error() { let mut state = AppState::new(); let result = execute_command("foo", &mut state); assert!(result.is_none()); assert_eq!(state.status_error.as_deref(), Some("Unknown command: foo")); } #[test] fn status_error_cleared_on_next_keypress() { let mut state = AppState::new(); state.status_error = Some("some error".to_string()); handle_key(make_key(KeyCode::Char('h')), &mut state); assert!(state.status_error.is_none()); } #[test] fn command_enter_executes_quit() { let mut state = AppState::new(); state.mode = Mode::Command; state.command_buffer = "q".to_string(); let result = handle_key(make_key(KeyCode::Enter), &mut state); assert!(matches!(result, Some(LoopControl::Quit))); assert_eq!(state.mode, Mode::Normal); } }