use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::tui::events::UserAction;
use super::{
double_press_window, glob_workspace_files, search_history, App, InputMode, TranscriptBlock,
};
impl App {
pub fn handle_key(&mut self, key: KeyEvent) -> Option<UserAction> {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return self.handle_ctrl_c();
}
if self.pending_permission.is_some() {
return self.handle_permission_key(key);
}
if self.plan_mode_request_pending {
return self.handle_plan_mode_request_key(key);
}
if !self.modals.is_empty() {
return self.handle_modal_key_action(key);
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('e') {
if self.prompt.buffer.is_empty() {
self.toggle_last_expandable();
} else {
self.prompt.move_end();
}
return None;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('a') {
self.prompt.move_home();
return None;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('b') {
self.prompt.move_left();
return None;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('f') {
self.prompt.move_right();
return None;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('p') {
self.prompt.move_prev_line();
return None;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('n') {
self.prompt.move_next_line();
return None;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('r') {
match self.prompt.mode {
InputMode::Prompt => {
self.enter_history_search_mode();
return None;
}
InputMode::HistorySearch => {
if !self.hsearch_matches.is_empty() {
self.hsearch_selected =
(self.hsearch_selected + 1) % self.hsearch_matches.len();
}
return None;
}
_ => return None,
}
}
if key.code == KeyCode::BackTab {
self.prompt.mode = self.prompt.mode.cycle_next();
return None;
}
if self.prompt.mode == InputMode::HistorySearch {
return self.handle_history_search_key(key);
}
if self.prompt.mode == InputMode::AtFile {
return self.handle_atfile_key(key);
}
if self.prompt.mode == InputMode::Command {
if let Some(action) = self.handle_command_menu_key(key) {
return action;
}
}
match key.code {
KeyCode::Enter
if key.modifiers.contains(KeyModifiers::SHIFT)
|| key.modifiers.contains(KeyModifiers::ALT)
|| key.modifiers.contains(KeyModifiers::CONTROL) =>
{
self.prompt.insert_char('\n');
None
}
KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.prompt.insert_char('\n');
None
}
KeyCode::Enter => self.submit_prompt(),
KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => {
self.scroll_offset = self.scroll_offset.saturating_add(1);
None
}
KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
None
}
KeyCode::PageUp => {
self.scroll_offset = self.scroll_offset.saturating_add(10);
None
}
KeyCode::PageDown => {
self.scroll_offset = self.scroll_offset.saturating_sub(10);
None
}
KeyCode::Up if self.should_walk_history_up() => {
self.prompt.history_prev();
None
}
KeyCode::Down if self.should_walk_history_down() => {
self.prompt.history_next();
None
}
KeyCode::Char('q') if self.prompt.buffer.is_empty() => {
self.should_quit = true;
None
}
KeyCode::Char(c) => {
self.handle_char_input(c);
None
}
KeyCode::Backspace => {
if self.prompt.buffer.is_empty() && self.prompt.mode != InputMode::Prompt {
self.prompt.mode = InputMode::Prompt;
} else {
self.prompt.backspace();
}
None
}
KeyCode::Delete => {
self.prompt.delete_forward();
None
}
KeyCode::Left => {
self.prompt.move_left();
None
}
KeyCode::Right => {
self.prompt.move_right();
None
}
KeyCode::Home => {
self.prompt.move_home();
None
}
KeyCode::End => {
self.prompt.move_end();
None
}
KeyCode::Esc => self.handle_esc(),
_ => None,
}
}
fn handle_esc(&mut self) -> Option<UserAction> {
use std::time::Instant;
let now = Instant::now();
let _within_window = self
.double_press
.last_esc_at
.map(|t| now.duration_since(t) <= double_press_window())
.unwrap_or(false);
self.double_press.last_esc_at = Some(now);
if !self.prompt.buffer.is_empty() || self.prompt.mode != InputMode::Prompt {
self.prompt.buffer.clear();
self.prompt.cursor = 0;
self.prompt.mode = InputMode::Prompt;
self.prompt.history_idx = None;
return None;
}
if self.turn.running {
self.push_system("Interrupting… (press Ctrl+C again to exit)");
return Some(UserAction::Interrupt);
}
None
}
fn handle_ctrl_c(&mut self) -> Option<UserAction> {
use std::time::Instant;
let now = Instant::now();
let within_window = self
.double_press
.last_ctrl_c_at
.map(|t| now.duration_since(t) <= double_press_window())
.unwrap_or(false);
if within_window {
self.should_quit = true;
self.double_press.last_ctrl_c_at = None;
return None;
}
self.double_press.last_ctrl_c_at = Some(now);
if !self.modals.is_empty() {
self.modals.pop();
return None;
}
if !self.prompt.buffer.is_empty() || self.prompt.mode != InputMode::Prompt {
self.prompt.buffer.clear();
self.prompt.cursor = 0;
self.prompt.mode = InputMode::Prompt;
self.prompt.history_idx = None;
return None;
}
if self.turn.running {
self.push_system("Interrupting… (press Ctrl+C again to exit)");
return Some(UserAction::Interrupt);
}
self.push_system("Press Ctrl+C again to exit");
None
}
fn should_walk_history_up(&self) -> bool {
if self.prompt.history.is_empty() {
return false;
}
self.prompt.history_idx.is_some() || self.prompt.buffer.is_empty()
}
fn should_walk_history_down(&self) -> bool {
self.prompt.history_idx.is_some()
}
fn handle_char_input(&mut self, c: char) {
if self.prompt.buffer.is_empty() && self.prompt.mode == InputMode::Prompt {
match c {
'!' => {
self.prompt.mode = InputMode::Bash;
return;
}
'#' => {
self.prompt.mode = InputMode::Note;
return;
}
'/' => {
self.prompt.mode = InputMode::Command;
return;
}
_ => {}
}
}
if c == '@' && self.prompt.mode == InputMode::Prompt {
self.prompt.insert_char('@');
self.enter_atfile_mode();
return;
}
self.prompt.insert_char(c);
}
fn submit_prompt(&mut self) -> Option<UserAction> {
if self.prompt.buffer.is_empty() {
return None;
}
let mode = self.prompt.mode;
let body = self.prompt.buffer.clone();
let prefixed = format!("{}{}", mode.history_prefix(), body);
let action = match mode {
InputMode::Prompt => {
self.blocks
.push(TranscriptBlock::User { text: body.clone() });
self.scroll_to_bottom();
self.start_turn();
Some(UserAction::SendMessage(body))
}
InputMode::Bash => {
self.blocks.push(TranscriptBlock::User {
text: format!("!{body}"),
});
self.scroll_to_bottom();
Some(UserAction::RunShell(body))
}
InputMode::Note => {
self.blocks.push(TranscriptBlock::System {
text: format!("# {body}"),
});
self.scroll_to_bottom();
None
}
InputMode::Command => self.dispatch_slash_command(&body),
InputMode::AtFile => {
self.exit_atfile_mode();
self.blocks
.push(TranscriptBlock::User { text: body.clone() });
self.scroll_to_bottom();
self.start_turn();
Some(UserAction::SendMessage(body))
}
InputMode::HistorySearch => {
self.exit_history_search_mode();
self.blocks
.push(TranscriptBlock::User { text: body.clone() });
self.scroll_to_bottom();
self.start_turn();
Some(UserAction::SendMessage(body))
}
};
self.prompt.record_submission(prefixed);
self.command_menu_selected = None;
action
}
fn dispatch_slash_command(&mut self, body: &str) -> Option<UserAction> {
use crate::tui::commands::{CommandHandler, CommandOutcome};
let mut parts = body.split_whitespace();
let name = parts.next().unwrap_or("");
let args: Vec<String> = parts.map(String::from).collect();
let registry = self.commands.clone();
if let Some(spec) = registry.lookup(name) {
return match &spec.handler {
CommandHandler::Sync(f) => {
match f(self, &args) {
CommandOutcome::Done => {}
CommandOutcome::Error(msg) => self.push_error(msg),
CommandOutcome::OpenModal(modal) => self.push_modal(modal),
}
None
}
CommandHandler::Async(f) => {
let actions = f(self, &args);
actions.into_iter().next()
}
};
}
if let Some(skill) = registry.lookup_skill(name) {
let args_str = args.join(" ");
let prompt = skill.expand(&args_str);
self.push_system(format!(
"Running skill /{}: {}",
skill.name, skill.description
));
self.blocks.push(TranscriptBlock::User {
text: prompt.clone(),
});
self.scroll_to_bottom();
self.start_turn();
return Some(UserAction::RunSkillPrompt { prompt });
}
self.push_error(format!("Unknown command: /{name}. Try /help."));
None
}
pub fn handle_command_menu_key(&mut self, key: KeyEvent) -> Option<Option<UserAction>> {
use crate::tui::ui::command_menu;
let matches_count = self.commands.search(&self.prompt.buffer).len();
match key.code {
KeyCode::Up => {
match self.command_menu_selected {
None => return None,
Some(0) => self.command_menu_selected = None,
Some(n) => self.command_menu_selected = Some(n - 1),
}
Some(None)
}
KeyCode::Down => {
if matches_count == 0 {
return None;
}
let next = match self.command_menu_selected {
None => 0,
Some(n) if n + 1 < matches_count.min(command_menu::MAX_VISIBLE) => n + 1,
Some(n) => n,
};
self.command_menu_selected = Some(next);
Some(None)
}
KeyCode::Tab => {
let registry = self.commands.clone();
let matches = registry.search(&self.prompt.buffer);
if let Some(target) =
command_menu::tab_completion_target(&self.prompt.buffer, &matches)
{
self.prompt.buffer = target;
self.prompt.cursor = self.prompt.buffer.len();
self.command_menu_selected = None;
}
Some(None)
}
KeyCode::Enter => {
if let Some(idx) = self.command_menu_selected {
let registry = self.commands.clone();
let matches = registry.search(&self.prompt.buffer);
if let Some(spec) = matches.get(idx) {
let chosen = spec.name.to_string();
self.prompt.buffer = chosen;
self.prompt.cursor = self.prompt.buffer.len();
}
self.command_menu_selected = None;
}
None
}
_ => None,
}
}
fn enter_atfile_mode(&mut self) {
self.prompt.mode = InputMode::AtFile;
self.atfile_query.clear();
self.atfile_selected = None;
self.atfile_suggestions = glob_workspace_files("");
}
fn refresh_atfile_suggestions(&mut self) {
self.atfile_suggestions = glob_workspace_files(&self.atfile_query);
if let Some(sel) = self.atfile_selected {
if sel >= self.atfile_suggestions.len() {
self.atfile_selected = if self.atfile_suggestions.is_empty() {
None
} else {
Some(self.atfile_suggestions.len() - 1)
};
}
}
}
fn commit_atfile_selection(&mut self) {
let idx = self.atfile_selected.unwrap_or(0);
let Some(chosen) = self.atfile_suggestions.get(idx).cloned() else {
self.exit_atfile_mode();
return;
};
let at_pos = self
.prompt
.buffer
.rfind('@')
.unwrap_or(self.prompt.buffer.len());
self.prompt.buffer.truncate(at_pos);
self.prompt.buffer.push('@');
self.prompt.buffer.push_str(&chosen);
self.prompt.cursor = self.prompt.buffer.len();
self.exit_atfile_mode();
}
fn exit_atfile_mode(&mut self) {
self.prompt.mode = InputMode::Prompt;
self.atfile_query.clear();
self.atfile_suggestions.clear();
self.atfile_selected = None;
}
pub fn handle_atfile_key(&mut self, key: KeyEvent) -> Option<UserAction> {
match key.code {
KeyCode::Esc => {
self.exit_atfile_mode();
None
}
KeyCode::Enter | KeyCode::Tab => {
self.commit_atfile_selection();
None
}
KeyCode::Up => {
match self.atfile_selected {
None => {}
Some(0) => self.atfile_selected = None,
Some(n) => self.atfile_selected = Some(n - 1),
}
None
}
KeyCode::Down => {
let count = self.atfile_suggestions.len();
if count == 0 {
return None;
}
let next = match self.atfile_selected {
None => 0,
Some(n) if n + 1 < count => n + 1,
Some(n) => n,
};
self.atfile_selected = Some(next);
None
}
KeyCode::Backspace => {
if self.atfile_query.is_empty() {
self.exit_atfile_mode();
self.prompt.backspace(); } else {
let last_len = self
.atfile_query
.chars()
.last()
.map(|c| c.len_utf8())
.unwrap_or(0);
let new_len = self.atfile_query.len() - last_len;
self.atfile_query.truncate(new_len);
self.prompt.backspace();
self.refresh_atfile_suggestions();
}
None
}
KeyCode::Char(c) => {
self.atfile_query.push(c);
self.prompt.insert_char(c);
self.refresh_atfile_suggestions();
None
}
_ => None,
}
}
fn enter_history_search_mode(&mut self) {
self.prompt.mode = InputMode::HistorySearch;
self.hsearch_query.clear();
self.hsearch_selected = 0;
self.hsearch_matches = search_history(&self.prompt.history, "");
}
fn refresh_hsearch_matches(&mut self) {
self.hsearch_matches = search_history(&self.prompt.history, &self.hsearch_query);
if self.hsearch_selected >= self.hsearch_matches.len().max(1) {
self.hsearch_selected = 0;
}
}
fn commit_history_selection(&mut self) {
if let Some(&hist_idx) = self.hsearch_matches.get(self.hsearch_selected) {
if let Some(entry) = self.prompt.history.get(hist_idx) {
self.prompt.buffer = entry.clone();
self.prompt.cursor = self.prompt.buffer.len();
}
}
self.exit_history_search_mode();
}
fn exit_history_search_mode(&mut self) {
self.prompt.mode = InputMode::Prompt;
self.hsearch_query.clear();
self.hsearch_matches.clear();
self.hsearch_selected = 0;
}
pub fn handle_history_search_key(&mut self, key: KeyEvent) -> Option<UserAction> {
match key.code {
KeyCode::Esc => {
self.exit_history_search_mode();
None
}
KeyCode::Enter => {
self.commit_history_selection();
None
}
KeyCode::Up => {
if !self.hsearch_matches.is_empty() && self.hsearch_selected > 0 {
self.hsearch_selected -= 1;
}
None
}
KeyCode::Down => {
if !self.hsearch_matches.is_empty()
&& self.hsearch_selected + 1 < self.hsearch_matches.len()
{
self.hsearch_selected += 1;
}
None
}
KeyCode::Backspace => {
if self.hsearch_query.is_empty() {
self.exit_history_search_mode();
} else {
let last_len = self
.hsearch_query
.chars()
.last()
.map(|c| c.len_utf8())
.unwrap_or(0);
let new_len = self.hsearch_query.len() - last_len;
self.hsearch_query.truncate(new_len);
self.refresh_hsearch_matches();
}
None
}
KeyCode::Char(c) => {
self.hsearch_query.push(c);
self.refresh_hsearch_matches();
None
}
_ => None,
}
}
pub fn handle_permission_key(&mut self, key: KeyEvent) -> Option<UserAction> {
let (allow, auto_allow) = match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => (true, false),
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => (false, false),
KeyCode::Char('a') | KeyCode::Char('A') => (true, true),
_ => return None,
};
if let Some(p) = self.pending_permission.take() {
if auto_allow {
self.auto_allowed_tools.insert(p.tool_name.clone());
}
let _ = p.reply.send(allow);
}
None
}
pub fn handle_modal_key_action(&mut self, key: KeyEvent) -> Option<UserAction> {
use crate::tui::ui::modal::Modal;
if let Some(Modal::PlanReview { .. }) = self.modals.last() {
return self.handle_plan_review_key(key);
}
if let Some(Modal::ResumePicker { .. }) = self.modals.last() {
return self.handle_resume_picker_key(key);
}
if let Some(Modal::McpServers { .. }) = self.modals.last() {
return self.handle_mcp_servers_key(key);
}
self.handle_modal_key(key);
None
}
fn handle_plan_review_key(&mut self, key: KeyEvent) -> Option<UserAction> {
use crate::tui::ui::modal::Modal;
match key.code {
KeyCode::Char('y') | KeyCode::Enter => {
self.modals.pop();
Some(UserAction::ConfirmPlan)
}
KeyCode::Char('n') | KeyCode::Esc => {
self.modals.pop();
Some(UserAction::RejectPlan("user rejected".into()))
}
KeyCode::Char('e') => {
if let Some(Modal::PlanReview { plan_text, .. }) = self.modals.last().cloned() {
self.set_input(plan_text);
}
self.modals.pop();
None
}
_ => None,
}
}
fn handle_plan_mode_request_key(&mut self, key: KeyEvent) -> Option<UserAction> {
match key.code {
KeyCode::Char('y') | KeyCode::Enter => {
self.plan_mode_request_pending = false;
Some(UserAction::ApprovePlanMode)
}
KeyCode::Char('n') | KeyCode::Esc => {
self.plan_mode_request_pending = false;
Some(UserAction::RejectPlanMode("user skipped".into()))
}
_ => None,
}
}
fn handle_resume_picker_key(&mut self, key: KeyEvent) -> Option<UserAction> {
use crate::tui::ui::modal::Modal;
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
self.modals.pop();
None
}
KeyCode::Up => {
let mut new_sel: Option<usize> = None;
if let Some(Modal::ResumePicker { selected, .. }) = self.modals.last_mut() {
if *selected > 0 {
*selected -= 1;
}
new_sel = Some(*selected);
}
if let Some(sel) = new_sel {
self.modal_scroll_follow_selection(sel);
}
None
}
KeyCode::Down => {
let mut new_sel: Option<usize> = None;
if let Some(Modal::ResumePicker { entries, selected }) = self.modals.last_mut() {
if *selected + 1 < entries.len() {
*selected += 1;
}
new_sel = Some(*selected);
}
if let Some(sel) = new_sel {
self.modal_scroll_follow_selection(sel);
}
None
}
KeyCode::Enter => {
if let Some(Modal::ResumePicker { entries, selected }) = self.modals.last() {
if let Some(entry) = entries.get(*selected) {
let session_dir = entry.session_dir.clone();
self.modals.pop();
return Some(UserAction::ResumeSession { session_dir });
}
}
self.modals.pop();
None
}
_ => None,
}
}
fn handle_mcp_servers_key(&mut self, key: KeyEvent) -> Option<UserAction> {
use crate::tui::ui::modal::Modal;
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
self.modals.pop();
None
}
KeyCode::Up => {
let mut new_sel: Option<usize> = None;
if let Some(Modal::McpServers { selected, .. }) = self.modals.last_mut() {
if *selected > 0 {
*selected -= 1;
}
new_sel = Some(*selected);
}
if let Some(sel) = new_sel {
self.modal_scroll_follow_selection(sel);
}
None
}
KeyCode::Down => {
let mut new_sel: Option<usize> = None;
if let Some(Modal::McpServers { entries, selected }) = self.modals.last_mut() {
if *selected + 1 < entries.len() {
*selected += 1;
}
new_sel = Some(*selected);
}
if let Some(sel) = new_sel {
self.modal_scroll_follow_selection(sel);
}
None
}
_ => None,
}
}
pub fn handle_modal_key(&mut self, key: KeyEvent) -> bool {
use crate::tui::ui::modal::{ConfirmAction, Modal};
if self.modals.is_empty() {
return false;
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
self.modals.pop();
}
KeyCode::Char('y') => {
if let Some(Modal::Confirm { on_yes, .. }) = self.modals.last().cloned() {
self.modals.pop();
match on_yes {
ConfirmAction::Exit => {
self.should_quit = true;
}
ConfirmAction::Clear => {
self.reset_transcript();
}
}
}
}
KeyCode::Char('n') => {
if matches!(self.modals.last(), Some(Modal::Confirm { .. })) {
self.modals.pop();
}
}
KeyCode::Enter => {
if let Some(Modal::Confirm { on_yes, .. }) = self.modals.last().cloned() {
self.modals.pop();
match on_yes {
ConfirmAction::Exit => self.should_quit = true,
ConfirmAction::Clear => self.reset_transcript(),
}
} else {
self.modals.pop();
}
}
KeyCode::Up | KeyCode::PageUp => {
let step: u16 = if key.code == KeyCode::PageUp { 10 } else { 1 };
let mut journal_new_sel: Option<usize> = None;
if let Some(Modal::Journal { selected, .. }) = self.modals.last_mut() {
if *selected > 0 {
*selected -= 1;
}
journal_new_sel = Some(*selected);
}
if let Some(sel) = journal_new_sel {
self.modal_scroll_follow_selection(sel);
} else {
self.modal_scroll = self.modal_scroll.saturating_sub(step);
}
}
KeyCode::Down | KeyCode::PageDown => {
let step: u16 = if key.code == KeyCode::PageDown { 10 } else { 1 };
let mut journal_new_sel: Option<usize> = None;
if let Some(Modal::Journal { entries, selected }) = self.modals.last_mut() {
if *selected + 1 < entries.len() {
*selected += 1;
}
journal_new_sel = Some(*selected);
}
if let Some(sel) = journal_new_sel {
self.modal_scroll_follow_selection(sel);
} else {
self.modal_scroll = self.modal_scroll.saturating_add(step);
}
}
_ => {}
}
true
}
const MODAL_LIST_VISIBLE: u16 = 28;
fn modal_scroll_follow_selection(&mut self, selected: usize) {
let row = selected as u16 + 2; if row < self.modal_scroll {
self.modal_scroll = row.saturating_sub(1);
} else if row + 1 > self.modal_scroll + Self::MODAL_LIST_VISIBLE {
self.modal_scroll = row + 1 - Self::MODAL_LIST_VISIBLE;
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::tui::app::{App, AppScreen, InputMode, ToolResultData, TranscriptBlock};
use crate::tui::events::{UiEvent, UserAction};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn shift(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::SHIFT)
}
#[test]
fn ctrl_e_toggles_expanded_on_last_tool_result() {
let mut app = App::new();
app.screen = AppScreen::Chat;
app.handle_ui_event(UiEvent::ToolResult {
id: "1".into(),
name: "read_file".into(),
output: "long output".into(),
success: true,
});
let _ = app.handle_key(ctrl('e'));
match app.blocks.last() {
Some(TranscriptBlock::ToolCall {
result: Some(ToolResultData { expanded, .. }),
..
}) => assert!(*expanded),
other => panic!("expected ToolCall with Some(result), got {other:?}"),
}
let _ = app.handle_key(ctrl('e'));
match app.blocks.last() {
Some(TranscriptBlock::ToolCall {
result: Some(ToolResultData { expanded, .. }),
..
}) => assert!(!*expanded),
other => panic!("expected ToolCall with Some(result), got {other:?}"),
}
}
#[test]
fn enter_moves_input_to_blocks() {
let mut app = App::new();
app.screen = AppScreen::Chat;
app.set_input("hello");
let action = app.handle_key(key(KeyCode::Enter));
assert!(app.input().is_empty());
assert!(app
.blocks
.iter()
.any(|b| matches!(b, TranscriptBlock::User { text } if text == "hello")));
assert!(matches!(action, Some(UserAction::SendMessage(s)) if s == "hello"));
}
#[test]
fn enter_starts_a_turn() {
let mut app = App::new();
app.screen = AppScreen::Chat;
app.set_input("hi");
let _ = app.handle_key(key(KeyCode::Enter));
assert!(app.turn.running);
assert_eq!(app.turn_count, 1);
}
#[test]
fn esc_clears_buffer_without_quitting() {
let mut app = App::new();
app.screen = AppScreen::Chat;
app.set_input("partial");
let _ = app.handle_key(key(KeyCode::Esc));
assert!(!app.should_quit);
assert!(app.input().is_empty());
}
#[test]
fn char_appends_to_input() {
let mut app = App::new();
app.screen = AppScreen::Chat;
let _ = app.handle_key(key(KeyCode::Char('h')));
let _ = app.handle_key(key(KeyCode::Char('i')));
assert_eq!(app.input(), "hi");
}
#[test]
fn backspace_removes_last_char() {
let mut app = App::new();
app.screen = AppScreen::Chat;
app.set_input("hello");
let _ = app.handle_key(key(KeyCode::Backspace));
assert_eq!(app.input(), "hell");
}
#[test]
fn plain_up_does_not_scroll_transcript() {
let mut app = App::new();
app.screen = AppScreen::Chat;
for i in 0..30 {
app.blocks.push(TranscriptBlock::System {
text: format!("msg {i}"),
});
}
let _ = app.handle_key(key(KeyCode::Up));
assert_eq!(app.scroll_offset, 0);
let _ = app.handle_key(key(KeyCode::Down));
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn shift_up_increases_scroll_offset() {
let mut app = App::new();
app.screen = AppScreen::Chat;
for i in 0..30 {
app.blocks.push(TranscriptBlock::System {
text: format!("msg {i}"),
});
}
let _ = app.handle_key(shift(KeyCode::Up));
assert_eq!(app.scroll_offset, 1);
let _ = app.handle_key(shift(KeyCode::Up));
assert_eq!(app.scroll_offset, 2);
}
#[test]
fn shift_down_stops_at_zero() {
let mut app = App::new();
app.screen = AppScreen::Chat;
app.scroll_offset = 2;
let _ = app.handle_key(shift(KeyCode::Down));
let _ = app.handle_key(shift(KeyCode::Down));
let _ = app.handle_key(shift(KeyCode::Down));
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn page_up_scrolls_by_ten() {
let mut app = App::new();
app.screen = AppScreen::Chat;
let _ = app.handle_key(key(KeyCode::PageUp));
assert_eq!(app.scroll_offset, 10);
}
#[test]
fn page_down_scrolls_by_ten() {
let mut app = App::new();
app.screen = AppScreen::Chat;
app.scroll_offset = 15;
let _ = app.handle_key(key(KeyCode::PageDown));
assert_eq!(app.scroll_offset, 5);
}
#[test]
fn page_up_scrolls_even_when_buffer_not_empty() {
let mut app = App::new();
app.screen = AppScreen::Chat;
app.set_input("typing");
let _ = app.handle_key(key(KeyCode::PageUp));
assert_eq!(app.scroll_offset, 10);
}
#[test]
fn plan_review_y_dispatches_confirm_plan_action() {
use crate::tui::ui::modal::Modal;
let mut app = App::new();
app.screen = AppScreen::Chat;
app.modals.push(Modal::PlanReview {
plan_text: "do".into(),
tool_calls: vec![],
edited_text: None,
});
let action = app.handle_key(key(KeyCode::Char('y')));
assert!(matches!(action, Some(UserAction::ConfirmPlan)));
assert!(app.modals.is_empty());
}
#[test]
fn plan_review_n_dispatches_reject_plan_action() {
use crate::tui::ui::modal::Modal;
let mut app = App::new();
app.screen = AppScreen::Chat;
app.modals.push(Modal::PlanReview {
plan_text: "do".into(),
tool_calls: vec![],
edited_text: None,
});
let action = app.handle_key(key(KeyCode::Char('n')));
match action {
Some(UserAction::RejectPlan(reason)) => assert_eq!(reason, "user rejected"),
other => panic!("expected RejectPlan, got {other:?}"),
}
assert!(app.modals.is_empty());
}
#[test]
fn plan_review_e_copies_text_to_input_and_closes_modal() {
use crate::tui::ui::modal::Modal;
let mut app = App::new();
app.screen = AppScreen::Chat;
app.modals.push(Modal::PlanReview {
plan_text: "edit me please".into(),
tool_calls: vec![],
edited_text: None,
});
let action = app.handle_key(key(KeyCode::Char('e')));
assert!(action.is_none());
assert_eq!(app.input(), "edit me please");
assert_eq!(app.prompt.mode, InputMode::Prompt);
assert!(app.modals.is_empty());
}
#[test]
fn esc_first_press_closes_modal_not_quits() {
use crate::tui::ui::modal::Modal;
let mut app = App::new();
app.screen = AppScreen::Chat;
app.modals.push(Modal::Help);
let _ = app.handle_key(key(KeyCode::Esc));
assert!(app.modals.is_empty());
assert!(!app.should_quit);
}
#[test]
fn esc_first_press_clears_input_when_modal_empty_and_buffer_set() {
let mut app = App::new();
app.screen = AppScreen::Chat;
app.set_input("partial");
let _ = app.handle_key(key(KeyCode::Esc));
assert!(!app.should_quit);
assert!(app.input().is_empty());
}
#[test]
fn esc_does_not_quit_after_double_press_when_idle() {
let mut app = App::new();
app.screen = AppScreen::Chat;
let _ = app.handle_key(key(KeyCode::Esc));
let _ = app.handle_key(key(KeyCode::Esc));
assert!(!app.should_quit);
}
#[test]
fn ctrl_c_first_press_during_turn_dispatches_interrupt() {
let mut app = App::new();
app.screen = AppScreen::Chat;
app.turn.start();
let action = app.handle_key(ctrl('c'));
assert!(matches!(action, Some(UserAction::Interrupt)));
assert!(app.blocks.iter().any(|b| matches!(b,
TranscriptBlock::System { text } if text.contains("Interrupting"))));
assert!(!app.should_quit);
}
#[test]
fn ctrl_c_first_press_idle_pushes_warning_then_exits_on_second() {
let mut app = App::new();
app.screen = AppScreen::Chat;
let _ = app.handle_key(ctrl('c'));
assert!(!app.should_quit);
assert!(app.blocks.iter().any(|b| matches!(b,
TranscriptBlock::System { text } if text.contains("Press Ctrl+C again"))));
let _ = app.handle_key(ctrl('c'));
assert!(app.should_quit);
}
#[test]
fn ctrl_c_double_press_within_window_quits() {
let mut app = App::new();
app.screen = AppScreen::Chat;
app.turn.start();
let _ = app.handle_key(ctrl('c'));
let _ = app.handle_key(ctrl('c'));
assert!(app.should_quit);
}
#[test]
fn ctrl_c_outside_window_resets_counter() {
use std::time::Instant;
let mut app = App::new();
app.screen = AppScreen::Chat;
app.double_press.last_ctrl_c_at = Some(Instant::now() - Duration::from_secs(60));
let action = app.handle_key(ctrl('c'));
assert!(action.is_none());
assert!(!app.should_quit);
assert!(app.blocks.iter().any(|b| matches!(b,
TranscriptBlock::System { text } if text.contains("Press Ctrl+C again"))));
}
}
#[cfg(test)]
mod prompt_input_tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::tui::app::{App, AppScreen, InputMode, HISTORY_CAPACITY};
use crate::tui::input_state::strip_history_prefix;
fn k(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn shift(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::SHIFT)
}
fn alt(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::ALT)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn fresh_app() -> App {
let mut app = App::new();
app.screen = AppScreen::Chat;
app
}
#[test]
fn shift_tab_cycles_modes() {
let mut app = fresh_app();
assert_eq!(app.prompt.mode, InputMode::Prompt);
let _ = app.handle_key(k(KeyCode::BackTab));
assert_eq!(app.prompt.mode, InputMode::Bash);
let _ = app.handle_key(k(KeyCode::BackTab));
assert_eq!(app.prompt.mode, InputMode::Note);
let _ = app.handle_key(k(KeyCode::BackTab));
assert_eq!(app.prompt.mode, InputMode::Prompt);
}
#[test]
fn leading_bang_enters_bash_mode_when_buffer_empty() {
let mut app = fresh_app();
let _ = app.handle_key(k(KeyCode::Char('!')));
assert_eq!(app.prompt.mode, InputMode::Bash);
assert!(app.prompt.buffer.is_empty());
}
#[test]
fn leading_hash_enters_note_mode() {
let mut app = fresh_app();
let _ = app.handle_key(k(KeyCode::Char('#')));
assert_eq!(app.prompt.mode, InputMode::Note);
assert!(app.prompt.buffer.is_empty());
}
#[test]
fn leading_slash_enters_command_mode() {
let mut app = fresh_app();
let _ = app.handle_key(k(KeyCode::Char('/')));
assert_eq!(app.prompt.mode, InputMode::Command);
assert!(app.prompt.buffer.is_empty());
}
#[test]
fn leading_bang_after_existing_text_is_just_a_char() {
let mut app = fresh_app();
let _ = app.handle_key(k(KeyCode::Char('h')));
let _ = app.handle_key(k(KeyCode::Char('!')));
assert_eq!(app.prompt.mode, InputMode::Prompt);
assert_eq!(app.prompt.buffer, "h!");
}
#[test]
fn backspace_on_empty_exits_to_prompt_mode() {
let mut app = fresh_app();
let _ = app.handle_key(k(KeyCode::Char('!')));
assert_eq!(app.prompt.mode, InputMode::Bash);
let _ = app.handle_key(k(KeyCode::Backspace));
assert_eq!(app.prompt.mode, InputMode::Prompt);
}
#[test]
fn cursor_left_right_moves_within_buffer() {
let mut app = fresh_app();
for c in "abc".chars() {
let _ = app.handle_key(k(KeyCode::Char(c)));
}
assert_eq!(app.prompt.cursor, 3);
let _ = app.handle_key(k(KeyCode::Left));
assert_eq!(app.prompt.cursor, 2);
let _ = app.handle_key(k(KeyCode::Left));
assert_eq!(app.prompt.cursor, 1);
let _ = app.handle_key(k(KeyCode::Right));
assert_eq!(app.prompt.cursor, 2);
}
#[test]
fn cursor_handles_multibyte_chars() {
let mut app = fresh_app();
for c in "你好".chars() {
let _ = app.handle_key(k(KeyCode::Char(c)));
}
assert_eq!(app.prompt.cursor, 6);
let _ = app.handle_key(k(KeyCode::Left));
assert_eq!(app.prompt.cursor, 3);
let _ = app.handle_key(k(KeyCode::Backspace));
assert_eq!(app.prompt.buffer, "好");
}
#[test]
fn insert_at_cursor_not_just_end() {
let mut app = fresh_app();
for c in "ac".chars() {
let _ = app.handle_key(k(KeyCode::Char(c)));
}
let _ = app.handle_key(k(KeyCode::Left));
let _ = app.handle_key(k(KeyCode::Char('b')));
assert_eq!(app.prompt.buffer, "abc");
}
#[test]
fn shift_enter_inserts_newline_at_cursor() {
let mut app = fresh_app();
let _ = app.handle_key(k(KeyCode::Char('a')));
let _ = app.handle_key(shift(KeyCode::Enter));
let _ = app.handle_key(k(KeyCode::Char('b')));
assert_eq!(app.prompt.buffer, "a\nb");
}
#[test]
fn alt_enter_also_inserts_newline() {
let mut app = fresh_app();
let _ = app.handle_key(k(KeyCode::Char('a')));
let _ = app.handle_key(alt(KeyCode::Enter));
let _ = app.handle_key(k(KeyCode::Char('b')));
assert_eq!(app.prompt.buffer, "a\nb");
}
#[test]
fn history_up_down_navigates_records() {
let mut app = fresh_app();
app.set_input("first");
let _ = app.handle_key(k(KeyCode::Enter));
app.set_input("second");
let _ = app.handle_key(k(KeyCode::Enter));
assert_eq!(app.prompt.history.len(), 2);
let _ = app.handle_key(k(KeyCode::Up));
assert_eq!(app.prompt.buffer, "second");
let _ = app.handle_key(k(KeyCode::Up));
assert_eq!(app.prompt.buffer, "first");
let _ = app.handle_key(k(KeyCode::Down));
assert_eq!(app.prompt.buffer, "second");
let _ = app.handle_key(k(KeyCode::Down));
assert!(app.prompt.buffer.is_empty());
}
#[test]
fn history_up_saves_draft_and_restores_on_overflow() {
let mut app = fresh_app();
app.set_input("alpha");
let _ = app.handle_key(k(KeyCode::Enter));
let _ = app.handle_key(k(KeyCode::Up));
assert_eq!(app.prompt.buffer, "alpha");
let _ = app.handle_key(k(KeyCode::Down));
assert!(app.prompt.buffer.is_empty());
}
#[test]
fn history_preserves_mode_prefix() {
let mut app = fresh_app();
let _ = app.handle_key(k(KeyCode::Char('!')));
for c in "echo hi".chars() {
let _ = app.handle_key(k(KeyCode::Char(c)));
}
let _ = app.handle_key(k(KeyCode::Enter));
assert_eq!(app.prompt.mode, InputMode::Prompt);
let _ = app.handle_key(k(KeyCode::Up));
assert_eq!(app.prompt.mode, InputMode::Bash);
assert_eq!(app.prompt.buffer, "echo hi");
}
#[test]
fn history_capacity_truncates_oldest() {
let mut app = fresh_app();
for i in 0..(HISTORY_CAPACITY + 5) {
app.set_input(format!("msg{i}"));
let _ = app.handle_key(k(KeyCode::Enter));
}
assert_eq!(app.prompt.history.len(), HISTORY_CAPACITY);
assert!(!app.prompt.history.iter().any(|h| h == "msg0"));
}
#[test]
fn submit_in_bash_mode_dispatches_run_shell() {
use crate::tui::events::UserAction;
let mut app = fresh_app();
let _ = app.handle_key(k(KeyCode::Char('!')));
for c in "ls".chars() {
let _ = app.handle_key(k(KeyCode::Char(c)));
}
let action = app.handle_key(k(KeyCode::Enter));
assert!(matches!(action, Some(UserAction::RunShell(s)) if s == "ls"));
assert!(app.prompt.buffer.is_empty());
assert_eq!(app.prompt.mode, InputMode::Prompt);
}
#[test]
fn submit_in_note_mode_appends_system_block() {
use crate::tui::app::TranscriptBlock;
let mut app = fresh_app();
let _ = app.handle_key(k(KeyCode::Char('#')));
for c in "remember this".chars() {
let _ = app.handle_key(k(KeyCode::Char(c)));
}
let action = app.handle_key(k(KeyCode::Enter));
assert!(action.is_none());
assert!(app
.blocks
.iter()
.any(|b| matches!(b, TranscriptBlock::System { text }
if text.contains("remember this"))));
}
#[test]
fn submit_in_command_mode_dispatches_to_registry() {
let mut app = fresh_app();
let _ = app.handle_key(k(KeyCode::Char('/')));
for c in "help".chars() {
let _ = app.handle_key(k(KeyCode::Char(c)));
}
let action = app.handle_key(k(KeyCode::Enter));
assert!(action.is_none());
assert_eq!(app.modals.last(), Some(&crate::tui::ui::modal::Modal::Help));
assert!(app.prompt.buffer.is_empty());
assert_eq!(app.prompt.mode, InputMode::Prompt);
}
#[test]
fn submit_clears_buffer_and_resets_mode() {
let mut app = fresh_app();
let _ = app.handle_key(k(KeyCode::Char('!')));
let _ = app.handle_key(k(KeyCode::Char('x')));
let _ = app.handle_key(k(KeyCode::Enter));
assert!(app.prompt.buffer.is_empty());
assert_eq!(app.prompt.cursor, 0);
assert_eq!(app.prompt.mode, InputMode::Prompt);
assert!(app.prompt.history_idx.is_none());
}
#[test]
fn home_end_target_current_line_only() {
let mut app = fresh_app();
app.set_input("ab\ncd");
app.prompt.cursor = 4; let _ = app.handle_key(k(KeyCode::Home));
assert_eq!(app.prompt.cursor, 3); let _ = app.handle_key(k(KeyCode::End));
assert_eq!(app.prompt.cursor, 5); }
#[test]
fn ctrl_e_with_empty_buffer_toggles_tool_result() {
use crate::tui::app::{ToolResultData, TranscriptBlock};
use crate::tui::events::UiEvent;
let mut app = fresh_app();
app.handle_ui_event(UiEvent::ToolResult {
id: "1".into(),
name: "read_file".into(),
output: "ok".into(),
success: true,
});
let _ = app.handle_key(ctrl('e'));
match app.blocks.last() {
Some(TranscriptBlock::ToolCall {
result: Some(ToolResultData { expanded, .. }),
..
}) => assert!(*expanded),
other => panic!("expected ToolCall with Some(result), got {other:?}"),
}
}
#[test]
fn ctrl_e_with_text_moves_to_end_of_line() {
let mut app = fresh_app();
app.set_input("hello");
app.prompt.cursor = 1;
let _ = app.handle_key(ctrl('e'));
assert_eq!(app.prompt.cursor, 5);
}
#[test]
fn ctrl_a_moves_to_line_start() {
let mut app = fresh_app();
app.set_input("hello");
let _ = app.handle_key(ctrl('a'));
assert_eq!(app.prompt.cursor, 0);
}
#[test]
fn history_down_with_no_walk_in_progress_is_noop() {
let mut app = fresh_app();
let _ = app.handle_key(k(KeyCode::Down));
assert!(app.prompt.history_idx.is_none());
}
#[test]
fn strip_history_prefix_recognises_all_modes() {
assert_eq!(strip_history_prefix("!ls").0, InputMode::Bash);
assert_eq!(strip_history_prefix("#note").0, InputMode::Note);
assert_eq!(strip_history_prefix("/cmd").0, InputMode::Command);
assert_eq!(strip_history_prefix("hello").0, InputMode::Prompt);
assert_eq!(strip_history_prefix("!ls").1, "ls");
}
}
#[cfg(test)]
mod atfile_tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::tui::app::{App, InputMode, MAX_ATFILE_SUGGESTIONS};
use crate::tui::completion::glob_workspace_files;
fn k(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn atfile_mode_triggered_by_at_in_prompt_mode() {
let mut app = App::new();
assert_eq!(app.prompt.mode, InputMode::Prompt);
app.handle_char_input('@');
assert_eq!(app.prompt.mode, InputMode::AtFile);
assert!(app.prompt.buffer.ends_with('@'));
}
#[test]
fn atfile_mode_not_triggered_in_bash_mode() {
let mut app = App::new();
app.prompt.mode = InputMode::Bash;
app.handle_char_input('@');
assert_eq!(app.prompt.mode, InputMode::Bash);
}
#[test]
fn atfile_mode_not_triggered_in_command_mode() {
let mut app = App::new();
app.prompt.mode = InputMode::Command;
app.handle_char_input('@');
assert_eq!(app.prompt.mode, InputMode::Command);
}
#[test]
fn glob_workspace_files_filters_by_query_prefix() {
let results = glob_workspace_files("Cargo");
assert!(results.len() <= MAX_ATFILE_SUGGESTIONS);
for r in &results {
assert!(r.to_lowercase().contains("cargo"), "unexpected result: {r}");
}
}
#[test]
fn glob_workspace_files_returns_at_most_12() {
let results = glob_workspace_files("");
assert!(results.len() <= MAX_ATFILE_SUGGESTIONS);
}
#[test]
fn atfile_backspace_on_empty_query_exits_mode_and_deletes_at() {
let mut app = App::new();
app.handle_char_input('h');
app.handle_char_input('i');
app.handle_char_input('@');
assert_eq!(app.prompt.mode, InputMode::AtFile);
assert_eq!(app.prompt.buffer, "hi@");
app.handle_atfile_key(k(KeyCode::Backspace));
assert_eq!(app.prompt.mode, InputMode::Prompt);
assert_eq!(app.prompt.buffer, "hi");
}
#[test]
fn atfile_enter_inserts_selected_path_and_exits() {
let mut app = App::new();
app.handle_char_input('@');
assert_eq!(app.prompt.mode, InputMode::AtFile);
app.atfile_suggestions = vec!["src/lib.rs".to_string(), "src/main.rs".to_string()];
app.atfile_selected = Some(0);
app.handle_atfile_key(k(KeyCode::Enter));
assert_eq!(app.prompt.mode, InputMode::Prompt);
assert!(
app.prompt.buffer.ends_with("@src/lib.rs"),
"buffer was: {}",
app.prompt.buffer
);
}
#[test]
fn atfile_esc_cancels_and_preserves_at_query() {
let mut app = App::new();
app.handle_char_input('t');
app.handle_char_input('e');
app.handle_char_input('s');
app.handle_char_input('t');
app.handle_char_input(' ');
app.handle_char_input('@');
app.handle_atfile_key(k(KeyCode::Char('s')));
app.handle_atfile_key(k(KeyCode::Char('r')));
app.handle_atfile_key(k(KeyCode::Char('c')));
assert_eq!(app.prompt.mode, InputMode::AtFile);
let buf_before = app.prompt.buffer.clone();
app.handle_atfile_key(k(KeyCode::Esc));
assert_eq!(app.prompt.mode, InputMode::Prompt);
assert_eq!(app.prompt.buffer, buf_before);
assert!(app.atfile_suggestions.is_empty());
}
#[test]
fn atfile_up_down_navigation() {
let mut app = App::new();
app.handle_char_input('@');
app.atfile_suggestions = vec!["a.rs".to_string(), "b.rs".to_string(), "c.rs".to_string()];
app.atfile_selected = None;
app.handle_atfile_key(k(KeyCode::Down));
assert_eq!(app.atfile_selected, Some(0));
app.handle_atfile_key(k(KeyCode::Down));
assert_eq!(app.atfile_selected, Some(1));
app.handle_atfile_key(k(KeyCode::Up));
assert_eq!(app.atfile_selected, Some(0));
app.handle_atfile_key(k(KeyCode::Up));
assert_eq!(app.atfile_selected, None);
}
}
#[cfg(test)]
mod hsearch_tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::tui::app::{App, InputMode, MAX_HSEARCH_RESULTS};
use crate::tui::completion::search_history;
fn k(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
fn history_app(entries: &[&str]) -> App {
let mut app = App::new();
for e in entries {
app.prompt.history.push(e.to_string());
}
app
}
#[test]
fn history_search_empty_query_returns_all_reversed() {
let h = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let r = search_history(&h, "");
assert_eq!(r, vec![2, 1, 0]);
}
#[test]
fn history_search_prefix_match_ranked_first() {
let h = vec![
"foo bar".to_string(),
"zz foo".to_string(),
"foobar".to_string(),
];
let r = search_history(&h, "foo");
assert!(r.iter().position(|&x| x == 2) < r.iter().position(|&x| x == 1));
assert!(r.iter().position(|&x| x == 0) < r.iter().position(|&x| x == 1));
}
#[test]
fn history_search_case_insensitive() {
let h = vec!["Hello World".to_string(), "goodbye".to_string()];
let r = search_history(&h, "hello");
assert!(r.contains(&0));
assert!(!r.contains(&1));
}
#[test]
fn history_search_returns_at_most_12() {
let h: Vec<String> = (0..20).map(|i| format!("entry {i}")).collect();
let r = search_history(&h, "entry");
assert!(r.len() <= MAX_HSEARCH_RESULTS);
}
#[test]
fn ctrl_r_in_prompt_mode_enters_history_search() {
let mut app = history_app(&["hello", "world"]);
assert_eq!(app.prompt.mode, InputMode::Prompt);
app.handle_key(ctrl(KeyCode::Char('r')));
assert_eq!(app.prompt.mode, InputMode::HistorySearch);
assert_eq!(app.hsearch_matches.len(), 2);
}
#[test]
fn ctrl_r_in_bash_mode_no_op() {
let mut app = history_app(&["hello"]);
app.prompt.mode = InputMode::Bash;
app.handle_key(ctrl(KeyCode::Char('r')));
assert_eq!(app.prompt.mode, InputMode::Bash);
}
#[test]
fn history_search_enter_fills_buffer() {
let mut app = history_app(&["cargo build", "cargo test"]);
app.handle_key(ctrl(KeyCode::Char('r')));
assert_eq!(app.prompt.mode, InputMode::HistorySearch);
assert_eq!(app.hsearch_selected, 0);
app.handle_history_search_key(k(KeyCode::Enter));
assert_eq!(app.prompt.mode, InputMode::Prompt);
assert_eq!(app.prompt.buffer, "cargo test");
}
#[test]
fn history_search_esc_cancels() {
let mut app = history_app(&["hello"]);
app.handle_key(ctrl(KeyCode::Char('r')));
assert_eq!(app.prompt.mode, InputMode::HistorySearch);
app.handle_history_search_key(k(KeyCode::Esc));
assert_eq!(app.prompt.mode, InputMode::Prompt);
assert!(app.prompt.buffer.is_empty());
}
#[test]
fn history_search_backspace_on_empty_exits_mode() {
let mut app = history_app(&["hello"]);
app.handle_key(ctrl(KeyCode::Char('r')));
assert_eq!(app.prompt.mode, InputMode::HistorySearch);
assert!(app.hsearch_query.is_empty());
app.handle_history_search_key(k(KeyCode::Backspace));
assert_eq!(app.prompt.mode, InputMode::Prompt);
}
}
#[cfg(test)]
mod perm_tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::tui::app::{App, PendingPermission};
use crate::tui::events::UiEvent;
fn make_perm(tool: &str, args: &str) -> (App, tokio::sync::oneshot::Receiver<bool>) {
let mut app = App::new();
let (tx, rx) = tokio::sync::oneshot::channel::<bool>();
let req = crate::tui::events::PermissionRequest {
tool_name: tool.to_string(),
args_preview: args.to_string(),
reply: tx,
};
app.set_pending_permission(req);
(app, rx)
}
fn k(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn pending_permission_set_and_stored() {
let (app, _rx) = make_perm("run_shell", "ls -la");
assert!(app.pending_permission.is_some());
let p = app.pending_permission.as_ref().unwrap();
assert_eq!(p.tool_name, "run_shell");
assert_eq!(p.args_preview, "ls -la");
}
#[tokio::test]
async fn y_key_sends_true_and_clears_modal() {
let (mut app, rx) = make_perm("run_shell", "ls");
app.handle_permission_key(k(KeyCode::Char('y')));
assert!(app.pending_permission.is_none());
assert!(rx.await.unwrap());
}
#[tokio::test]
async fn n_key_sends_false_and_clears_modal() {
let (mut app, rx) = make_perm("run_shell", "rm -rf /");
app.handle_permission_key(k(KeyCode::Char('n')));
assert!(app.pending_permission.is_none());
assert!(!rx.await.unwrap());
}
#[tokio::test]
async fn esc_key_sends_false() {
let (mut app, rx) = make_perm("write_file", "path=foo.txt");
app.handle_permission_key(k(KeyCode::Esc));
assert!(app.pending_permission.is_none());
assert!(!rx.await.unwrap());
}
#[tokio::test]
async fn enter_key_sends_true() {
let (mut app, rx) = make_perm("read_file", "path=foo.txt");
app.handle_permission_key(k(KeyCode::Enter));
assert!(app.pending_permission.is_none());
assert!(rx.await.unwrap());
}
#[tokio::test]
async fn a_key_sends_true_and_adds_to_auto_allowed() {
let (mut app, rx) = make_perm("run_shell", "cargo test");
app.handle_permission_key(k(KeyCode::Char('a')));
assert!(app.pending_permission.is_none());
assert!(rx.await.unwrap());
assert!(app.auto_allowed_tools.contains("run_shell"));
}
#[tokio::test]
async fn auto_allowed_tool_skips_modal() {
let mut app = App::new();
app.auto_allowed_tools.insert("run_shell".to_string());
let (tx, rx) = tokio::sync::oneshot::channel::<bool>();
let req = crate::tui::events::PermissionRequest {
tool_name: "run_shell".to_string(),
args_preview: "cargo build".to_string(),
reply: tx,
};
app.set_pending_permission(req);
assert!(app.pending_permission.is_none());
assert!(rx.await.unwrap());
}
#[test]
fn handle_key_routes_to_permission_when_pending() {
let (tx, _rx) = tokio::sync::oneshot::channel::<bool>();
let mut app = App::new();
let req = crate::tui::events::PermissionRequest {
tool_name: "write_file".to_string(),
args_preview: "path=foo.rs".to_string(),
reply: tx,
};
app.pending_permission = Some(PendingPermission {
tool_name: req.tool_name,
args_preview: req.args_preview,
reply: req.reply,
});
assert!(app.pending_permission.is_some());
app.handle_key(k(KeyCode::Char('n')));
assert!(app.pending_permission.is_none());
}
#[test]
fn plan_mode_requested_event_sets_pending_flag() {
use crate::tui::app::TranscriptBlock;
let mut app = App::new();
app.handle_ui_event(UiEvent::PlanModeRequested {
reason: "This task is complex".into(),
});
assert!(app.plan_mode_request_pending);
assert!(app.blocks.iter().any(|b| matches!(b,
TranscriptBlock::PlanModeRequest { reason, approved: None }
if reason.contains("complex"))));
}
#[test]
fn plan_mode_request_y_dispatches_approve_action() {
use crate::tui::events::UserAction;
let mut app = App::new();
app.handle_ui_event(UiEvent::PlanModeRequested {
reason: "need to plan".into(),
});
assert!(app.plan_mode_request_pending);
let action = app.handle_key(k(KeyCode::Char('y')));
assert!(!app.plan_mode_request_pending, "flag should be cleared");
assert!(matches!(action, Some(UserAction::ApprovePlanMode)));
}
#[test]
fn plan_mode_request_n_dispatches_reject_action() {
use crate::tui::events::UserAction;
let mut app = App::new();
app.handle_ui_event(UiEvent::PlanModeRequested {
reason: "need to plan".into(),
});
let action = app.handle_key(k(KeyCode::Char('n')));
assert!(!app.plan_mode_request_pending, "flag should be cleared");
assert!(matches!(action, Some(UserAction::RejectPlanMode(r)) if r == "user skipped"));
}
#[test]
fn plan_mode_approved_event_marks_block() {
use crate::tui::app::TranscriptBlock;
let mut app = App::new();
app.handle_ui_event(UiEvent::PlanModeRequested {
reason: "complex".into(),
});
app.handle_ui_event(UiEvent::PlanModeApproved);
assert!(!app.plan_mode_request_pending);
assert!(app.blocks.iter().any(|b| matches!(
b,
TranscriptBlock::PlanModeRequest {
approved: Some(true),
..
}
)));
}
#[test]
fn plan_mode_rejected_event_marks_block() {
use crate::tui::app::TranscriptBlock;
let mut app = App::new();
app.handle_ui_event(UiEvent::PlanModeRequested {
reason: "complex".into(),
});
app.handle_ui_event(UiEvent::PlanModeRejected {
reason: "user skipped".into(),
});
assert!(!app.plan_mode_request_pending);
assert!(app.blocks.iter().any(|b| matches!(
b,
TranscriptBlock::PlanModeRequest {
approved: Some(false),
..
}
)));
}
}