use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; use harness_app::EngineHandle; use harness_core::permission::PermissionReply; use crate::modal; use crate::state::{AppState, ModalState}; /// Possible high-level actions produced by key input. #[derive(Debug)] pub enum InputAction { None, Submit { text: String }, Abort, Quit, LoadSessions, NewSession, SetModel(String), SetAgent(String), CloseModal, ScrollUp(u16), ScrollDown(u16), PermissionReply(PermissionReply), SessionPickerUp, SessionPickerDown, SessionPickerSelect, } /// Translate a crossterm event into an action and/or mutate `state` directly. pub fn handle_event(event: Event, state: &mut AppState, _engine: &EngineHandle) -> InputAction { match event { Event::Key(key) => { let action = handle_key(key, state); // Any keypress can change the input buffer or cursor; force a redraw so typed // characters appear immediately rather than only when some other event sets dirty. state.dirty = true; action } Event::Resize(_, _) => { state.dirty = true; InputAction::None } Event::Mouse(_) | Event::FocusGained | Event::FocusLost | Event::Paste(_) => { InputAction::None } } } fn handle_key(key: KeyEvent, state: &mut AppState) -> InputAction { match &state.modal { ModalState::Permission { .. } => handle_permission_key(key, state), ModalState::SessionPicker { .. } => handle_session_picker_key(key, state), ModalState::None => handle_normal_key(key, state), } } fn handle_permission_key(key: KeyEvent, _state: &mut AppState) -> InputAction { match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { InputAction::PermissionReply(PermissionReply::Once) } KeyCode::Char('a') | KeyCode::Char('A') => { InputAction::PermissionReply(PermissionReply::Always) } KeyCode::Char('n') | KeyCode::Char('N') => { InputAction::PermissionReply(PermissionReply::Reject) } // Esc rejects the pending request rather than merely hiding the modal — closing // without a reply would leave the tool call blocked on its oneshot forever. KeyCode::Esc => InputAction::PermissionReply(PermissionReply::Reject), _ => InputAction::None, } } fn handle_session_picker_key(key: KeyEvent, _state: &mut AppState) -> InputAction { match key.code { KeyCode::Up => InputAction::SessionPickerUp, KeyCode::Down => InputAction::SessionPickerDown, KeyCode::Enter => InputAction::SessionPickerSelect, KeyCode::Esc => InputAction::CloseModal, _ => InputAction::None, } } fn handle_normal_key(key: KeyEvent, state: &mut AppState) -> InputAction { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); // Reset the double-Ctrl+C guard on any key that isn't another Ctrl+C. if !(matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')) && ctrl) { state.ctrl_c_pressed = false; } match key.code { KeyCode::Enter => { let text = state.input.lines().join("\n"); state.input = tui_textarea::TextArea::default(); state .input .set_cursor_line_style(ratatui::style::Style::default()); if let Some(action) = parse_slash_command(&text) { action } else { InputAction::Submit { text } } } KeyCode::Char('c') if ctrl => { if state.ctrl_c_pressed { InputAction::Quit } else { state.ctrl_c_pressed = true; state.dirty = true; InputAction::None } } KeyCode::Esc => { if state.running { InputAction::Abort } else { InputAction::None } } KeyCode::Char('s') if ctrl => InputAction::LoadSessions, KeyCode::Up => { let (row, _) = state.input.cursor(); if row == 0 { InputAction::ScrollUp(3) } else { state.input.input(key); InputAction::None } } KeyCode::Down => { let (row, _) = state.input.cursor(); let last_line = state.input.lines().len().saturating_sub(1); if row == last_line { InputAction::ScrollDown(3) } else { state.input.input(key); InputAction::None } } _ => { state.input.input(key); InputAction::None } } } fn parse_slash_command(text: &str) -> Option { let trimmed = text.trim(); if !trimmed.starts_with('/') { return None; } let mut parts = trimmed.split_whitespace(); let cmd = parts.next()?; let rest: String = parts.collect::>().join(" "); match cmd { "/new" => Some(InputAction::NewSession), "/model" if !rest.is_empty() => Some(InputAction::SetModel(rest)), "/agent" if !rest.is_empty() => Some(InputAction::SetAgent(rest)), "/sessions" => Some(InputAction::LoadSessions), "/quit" => Some(InputAction::Quit), _ => None, } } /// Apply an action that needs async engine calls. Non-async decisions are applied inline. pub async fn apply_action(action: InputAction, state: &mut AppState, engine: &EngineHandle) { match action { InputAction::Submit { text } => { if let Some(session_id) = state.session_id.clone() { if let Err(e) = engine.prompt(session_id, text, &state.model_ref).await { tracing::error!(error = %e, "prompt failed"); } } } InputAction::Abort => { if let Some(session_id) = state.session_id.clone() { engine.abort(&session_id); } } InputAction::LoadSessions => match engine.list_sessions().await { Ok(sessions) => modal::open_session_picker(state, sessions), Err(e) => tracing::error!(error = %e, "failed to list sessions"), }, InputAction::NewSession => match engine.new_session(&state.agent, &state.model_ref).await { Ok(id) => { state.session_id = Some(id); state.messages.clear(); state.scroll_offset = 0; state.dirty = true; } Err(e) => tracing::error!(error = %e, "failed to create session"), }, InputAction::SetModel(model_ref) => { state.model_ref = model_ref; state.dirty = true; } InputAction::SetAgent(agent) => { state.agent = agent; state.dirty = true; } InputAction::Quit => state.quit = true, InputAction::CloseModal => state.close_modal(), InputAction::ScrollUp(n) => state.scroll_up(n), InputAction::ScrollDown(n) => state.scroll_down(n), InputAction::PermissionReply(reply) => { modal::resolve_permission(state, engine, reply); } InputAction::SessionPickerUp => { if let ModalState::SessionPicker { selected, sessions: _, } = &mut state.modal { *selected = selected.saturating_sub(1); } state.dirty = true; } InputAction::SessionPickerDown => { if let ModalState::SessionPicker { selected, sessions } = &mut state.modal { if *selected + 1 < sessions.len() { *selected += 1; } } state.dirty = true; } InputAction::SessionPickerSelect => { if let ModalState::SessionPicker { sessions, selected } = &state.modal { if let Some(session) = sessions.get(*selected).cloned() { if let Err(e) = modal::select_session(state, engine, session).await { tracing::error!(error = %e, "failed to load session"); } } } } InputAction::None => {} } } #[cfg(test)] mod tests { use super::*; use crate::state::AppState; use crossterm::event::KeyEvent; #[tokio::test] async fn typing_marks_frame_dirty_and_updates_input() { let engine = EngineHandle::init_in_memory(std::env::temp_dir()).unwrap(); let mut state = AppState::new("anthropic/claude".into(), "orchestrator".into()); state.dirty = false; let action = handle_event( Event::Key(KeyEvent::from(KeyCode::Char('x'))), &mut state, &engine, ); assert!(matches!(action, InputAction::None)); assert!(state.dirty, "a keystroke must request a redraw"); assert_eq!(state.input.lines().join("\n"), "x"); } }