use std::io;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::app::{App, Tab};
use crate::domain::input::InputState;
use crate::domain::session::{Session, Status};
use crate::runtime::EventResult;
use crate::runtime::mode::confirmation::DEFAULT_OPTION_INDEX;
use crate::runtime::mode::question;
use crate::ui::state::app_mode::{AppMode, ConfirmationIntent, HelpContext, QuestionFocus};
use crate::ui::state::help_action::{
HelpAction, project_list_actions, session_list_actions, settings_actions, system_log_actions,
};
use crate::ui::state::prompt::{PromptAttachmentState, PromptHistoryState};
use crate::ui::text_util::inline_text;
pub(crate) async fn handle(app: &mut App, key: KeyEvent) -> io::Result<EventResult> {
if app.tabs.current() == Tab::Settings && app.settings.is_editing_text_input() {
return handle_settings_text_input(app, key).await;
}
match key.code {
KeyCode::Char('q') => {
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::Quit,
confirmation_message: "Quit agentty?".to_string(),
confirmation_title: "Confirm Quit".to_string(),
restore_view: None,
session_id: None,
selected_confirmation_index: DEFAULT_OPTION_INDEX,
};
return Ok(EventResult::Continue);
}
KeyCode::Tab => {
app.next_tab();
}
KeyCode::BackTab => {
app.previous_tab();
}
KeyCode::Char('a')
if app.tabs.current() == Tab::Sessions && key.modifiers == KeyModifiers::NONE =>
{
app.mode = AppMode::SessionCreation {
selected_option_index: 0,
};
}
KeyCode::Char('j') | KeyCode::Down => match app.tabs.current() {
Tab::Projects => app.next_project(),
Tab::Sessions => app.next(),
Tab::Review => app.next_requested_review(),
Tab::Settings => app.settings.next(),
Tab::Logs => app.scroll_system_logs_down(),
},
KeyCode::Char('k') | KeyCode::Up => match app.tabs.current() {
Tab::Projects => app.previous_project(),
Tab::Sessions => app.previous(),
Tab::Review => app.previous_requested_review(),
Tab::Settings => app.settings.previous(),
Tab::Logs => app.scroll_system_logs_up(),
},
KeyCode::Char('g') if app.tabs.current() == Tab::Logs => {
app.scroll_system_logs_to_top();
}
KeyCode::Char('G') if app.tabs.current() == Tab::Logs => {
app.scroll_system_logs_to_bottom();
}
KeyCode::Enter => return handle_enter_key(app).await,
KeyCode::Char('c') if app.tabs.current() == Tab::Sessions => {
let selected_session = app.selected_session().and_then(|session| {
session
.allows_cancel_action()
.then(|| (session.id.clone(), inline_text(session.display_title())))
});
if let Some((session_id, session_title)) = selected_session {
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::CancelSession,
confirmation_message: format!("Cancel session \"{session_title}\"?"),
confirmation_title: "Confirm Cancel".to_string(),
restore_view: None,
session_id: Some(session_id),
selected_confirmation_index: DEFAULT_OPTION_INDEX,
};
}
}
KeyCode::Char(character) if character.eq_ignore_ascii_case(&'s') => sync_list_context(app),
KeyCode::Char('?') => {
open_list_help_overlay(app);
}
_ => {}
}
Ok(EventResult::Continue)
}
async fn handle_enter_key(app: &mut App) -> io::Result<EventResult> {
match app.tabs.current() {
Tab::Projects => {
if app.switch_selected_project().await.is_ok() {
app.tabs.set(Tab::Sessions);
}
}
Tab::Sessions => {
if let Some(session_index) = app.sessions.selected_session_index() {
let Some(session_id) = app
.sessions
.session_at(session_index)
.map(|session| session.id.clone())
else {
return Ok(EventResult::Continue);
};
app.sessions
.load_session_detail_into_state(app.services.db(), session_id.as_str())
.await;
if let Some(session) = app.sessions.session_at(session_index)
&& session.status == Status::Question
{
let questions = session.questions.clone();
let selected_option_index = question::default_option_index(&questions, 0);
let (review_status_message, review_text) = app.review_view_state(&session_id);
app.mode = AppMode::Question {
at_mention_state: None,
review_status_message,
review_text,
session_id,
questions,
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::Answer,
input: InputState::default(),
scroll_offset: None,
selected_option_index,
};
} else {
let (review_status_message, review_text) = app.review_view_state(&session_id);
app.mode = AppMode::View {
review_status_message,
review_text,
session_id,
scroll_offset: None,
};
}
}
}
Tab::Settings => {
app.settings.handle_enter(&app.services).await;
}
Tab::Review => {
app.open_selected_requested_review();
}
Tab::Logs => {}
}
Ok(EventResult::Continue)
}
async fn handle_settings_text_input(app: &mut App, key: KeyEvent) -> io::Result<EventResult> {
match key.code {
_ if should_insert_settings_newline(app, key) => {
app.settings
.append_selected_text_character(&app.services, '\n')
.await;
}
code if is_settings_enter_key(code) => {
app.settings.stop_text_input_editing();
}
KeyCode::Esc => {
app.settings.stop_text_input_editing();
}
KeyCode::Left => {
app.settings.move_selected_text_cursor_left();
}
KeyCode::Right => {
app.settings.move_selected_text_cursor_right();
}
KeyCode::Up => {
app.settings.move_selected_text_cursor_up();
}
KeyCode::Down => {
app.settings.move_selected_text_cursor_down();
}
KeyCode::Backspace => {
app.settings
.remove_selected_text_character(&app.services)
.await;
}
KeyCode::Char(character) if is_settings_text_key(key) => {
app.settings
.append_selected_text_character(&app.services, character)
.await;
}
_ => {}
}
Ok(EventResult::Continue)
}
fn should_insert_settings_newline(app: &App, key: KeyEvent) -> bool {
app.settings.is_editing_open_commands()
&& (is_settings_newline_character_key(key.code)
|| is_settings_modified_enter_key(key)
|| is_settings_control_newline_key(key))
}
fn is_settings_newline_character_key(key_code: KeyCode) -> bool {
matches!(key_code, KeyCode::Char('\r' | '\n'))
}
fn is_settings_modified_enter_key(key: KeyEvent) -> bool {
is_settings_enter_key(key.code)
&& key
.modifiers
.intersects(KeyModifiers::ALT | KeyModifiers::SHIFT)
}
fn is_settings_control_newline_key(key: KeyEvent) -> bool {
key.modifiers == KeyModifiers::CONTROL
&& matches!(key.code, KeyCode::Char('j' | 'm' | '\n' | '\r'))
}
fn is_settings_enter_key(key_code: KeyCode) -> bool {
matches!(key_code, KeyCode::Enter)
}
fn is_settings_text_key(key: KeyEvent) -> bool {
key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT
}
fn sync_list_context(app: &mut App) {
if app.tabs.current() == Tab::Review {
app.refresh_requested_reviews_for_current_project();
return;
}
app.start_sync_main();
}
fn open_list_help_overlay(app: &mut App) {
let keybindings = list_keybindings(app);
app.mode = AppMode::Help {
context: HelpContext::List { keybindings },
scroll_offset: 0,
};
}
fn list_keybindings(app: &App) -> Vec<HelpAction> {
if app.tabs.current() == Tab::Projects {
return project_list_actions();
}
if app.tabs.current() == Tab::Settings {
return settings_actions();
}
if app.tabs.current() == Tab::Review {
return crate::ui::state::help_action::review_actions();
}
if app.tabs.current() == Tab::Logs {
return system_log_actions();
}
let is_sessions_tab = app.tabs.current() == Tab::Sessions;
let selected_session = app.selected_session();
let can_cancel_selected_session =
is_sessions_tab && selected_session.is_some_and(Session::allows_cancel_action);
let can_open_selected_session = is_sessions_tab
&& app
.sessions
.selected_session_index()
.and_then(|selected_index| app.sessions.session_at(selected_index))
.is_some();
session_list_actions(can_cancel_selected_session, can_open_selected_session)
}
pub(crate) fn open_session_prompt(app: &mut App, session_id: String) {
app.mode = AppMode::Prompt {
at_mention_state: None,
attachment_state: PromptAttachmentState::default(),
history_state: PromptHistoryState::new(Vec::new()),
review_status_message: None,
review_text: None,
slash_state: app.prompt_slash_state(),
session_id: session_id.into(),
input: InputState::default(),
scroll_offset: None,
};
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use ag_forge::{ForgeKind, RequestedReview, RequestedReviewAudience};
use crossterm::event::KeyModifiers;
use tempfile::tempdir;
use super::*;
use crate::app::{AppEvent, MockSyncMainRunner, SyncMainOutcome, SyncSessionStartError};
use crate::db::Database;
use crate::domain::question::QuestionItem;
fn test_app_clients() -> crate::app::AppClients {
crate::app::AppClients::new().with_agent_availability_probe(Arc::new(
crate::infra::agent::StaticAgentAvailabilityProbe {
available_agent_kinds: crate::domain::agent::AgentKind::ALL.to_vec(),
},
))
}
async fn new_test_app() -> (App, tempfile::TempDir) {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
let app = App::new_with_clients(
base_path.clone(),
base_path,
None,
database,
test_app_clients(),
)
.await
.expect("failed to build app");
(app, base_dir)
}
fn setup_test_git_repo(path: &Path) {
Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.expect("git init failed");
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()
.expect("git config failed");
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()
.expect("git config failed");
std::fs::write(path.join("README.md"), "test").expect("write failed");
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()
.expect("git add failed");
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(path)
.output()
.expect("git commit failed");
Command::new("git")
.args(["branch", "-M", "main"])
.current_dir(path)
.output()
.expect("git branch failed");
}
async fn new_test_app_with_git() -> (App, tempfile::TempDir) {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
setup_test_git_repo(base_dir.path());
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
let app = App::new_with_clients(
base_path.clone(),
base_path,
Some("main".to_string()),
database,
test_app_clients(),
)
.await
.expect("failed to build app");
(app, base_dir)
}
async fn new_test_app_for_settings() -> (App, tempfile::TempDir) {
let (mut app, base_dir) = new_test_app_with_git().await;
app.create_session()
.await
.expect("failed to create session for settings tests");
app.tabs.set(Tab::Settings);
let open_command_row_index = app
.settings
.settings_rows()
.iter()
.position(|(setting_name, _)| *setting_name == "Open Commands")
.expect("missing Open Commands setting row");
app.settings
.table_state
.select(Some(open_command_row_index));
(app, base_dir)
}
fn mock_sync_main_completion(
app: &mut App,
result: Result<SyncMainOutcome, SyncSessionStartError>,
) {
let mut mock_sync_main_runner = MockSyncMainRunner::new();
mock_sync_main_runner
.expect_start_sync_main()
.times(1)
.returning(move |app_event_tx, _, _, _, _| {
let _ = app_event_tx.send(AppEvent::SyncMainCompleted {
result: result.clone(),
});
});
app.sync_main_runner = std::sync::Arc::new(mock_sync_main_runner);
}
#[tokio::test]
async fn test_handle_quit_key_shows_confirm_quit_overlay() {
let (mut app, _base_dir) = new_test_app().await;
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::Quit,
ref confirmation_message,
ref confirmation_title,
restore_view: None,
session_id: None,
selected_confirmation_index: DEFAULT_OPTION_INDEX,
} if confirmation_title == "Confirm Quit" && confirmation_message == "Quit agentty?"
));
}
#[tokio::test]
async fn test_handle_backtab_key_cycles_tabs_backward() {
let (mut app, _base_dir) = new_test_app().await;
app.tabs.set(Tab::Projects);
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert_eq!(app.tabs.current(), Tab::Logs);
}
#[tokio::test]
async fn test_logs_tab_scroll_keys_update_tail_offset() {
let (mut app, _base_dir) = new_test_app().await;
app.tabs.set(Tab::Logs);
app.scroll_system_logs_up();
let down_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
)
.await
.expect("failed to handle down key");
let after_down_offset = app.system_log_tail_offset;
let up_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
)
.await
.expect("failed to handle up key");
let after_up_offset = app.system_log_tail_offset;
handle(
&mut app,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
)
.await
.expect("failed to handle top key");
let after_top_offset = app.system_log_tail_offset;
handle(
&mut app,
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT),
)
.await
.expect("failed to handle bottom key");
assert!(matches!(down_result, EventResult::Continue));
assert_eq!(after_down_offset, 0);
assert!(matches!(up_result, EventResult::Continue));
assert_eq!(after_up_offset, 1);
assert_eq!(after_top_offset, u16::MAX);
assert_eq!(app.system_log_tail_offset, 0);
}
#[tokio::test]
async fn test_handle_add_key_opens_session_creation_overlay() {
let (mut app, _base_dir) = new_test_app_with_git().await;
app.tabs.set(Tab::Sessions);
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(app.sessions.sessions().is_empty());
assert!(matches!(
app.mode,
AppMode::SessionCreation {
selected_option_index: 0,
}
));
}
#[tokio::test]
async fn test_handle_add_key_ignored_on_projects_tab() {
let (mut app, _base_dir) = new_test_app_with_git().await;
app.tabs.set(Tab::Projects);
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(app.sessions.sessions().is_empty());
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_handle_add_key_ignored_on_settings_tab() {
let (mut app, _base_dir) = new_test_app_with_git().await;
app.tabs.set(Tab::Settings);
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(app.sessions.sessions().is_empty());
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_handle_enter_key_opens_selected_session_in_view_mode() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let expected_session_id = app
.create_session()
.await
.expect("failed to create session");
app.tabs.set(Tab::Sessions);
app.sessions.select_session_index(Some(0));
app.mode = AppMode::List;
let event_result = handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
scroll_offset: None,
..
} if session_id == &expected_session_id
));
}
#[tokio::test]
async fn test_handle_enter_key_restores_cached_review_output() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let expected_session_id = app
.create_session()
.await
.expect("failed to create session");
app.review_cache.insert(
expected_session_id.clone().into(),
crate::app::ReviewCacheEntry::Ready {
diff_hash: 7,
text: "Focused review".to_string(),
},
);
app.tabs.set(Tab::Sessions);
app.sessions.select_session_index(Some(0));
app.mode = AppMode::List;
let event_result = handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
review_status_message: None,
review_text: Some(ref review_text),
scroll_offset: None,
..
} if session_id == &expected_session_id && review_text == "Focused review"
));
}
#[tokio::test]
async fn test_handle_enter_key_opens_selected_question_session_in_question_mode() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let expected_session_id = app
.create_session()
.await
.expect("failed to create session");
let expected_questions: Vec<QuestionItem> = vec![
QuestionItem {
options: vec!["main".to_string(), "develop".to_string()],
text: "Need a target branch?".to_string(),
},
QuestionItem {
options: vec!["Yes".to_string(), "No".to_string()],
text: "Need migration notes?".to_string(),
},
];
if let Some(session) = app.sessions.sessions_mut().first_mut() {
session.status = Status::Question;
session.questions = expected_questions.clone();
}
app.review_cache.insert(
expected_session_id.clone().into(),
crate::app::ReviewCacheEntry::Ready {
text: "Focused review".to_string(),
diff_hash: 42,
},
);
app.tabs.set(Tab::Sessions);
app.sessions.select_session_index(Some(0));
app.mode = AppMode::List;
let event_result = handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Question {
ref session_id,
ref questions,
review_status_message: None,
review_text: Some(ref review_text),
current_index: 0,
ref responses,
ref input,
selected_option_index: Some(0),
..
} if session_id == &expected_session_id
&& questions == &expected_questions
&& review_text == "Focused review"
&& responses.is_empty()
&& input.text().is_empty()
));
}
#[tokio::test]
async fn test_review_tab_navigation_selects_loaded_reviews() {
let (mut app, _base_dir) = new_test_app_with_git().await;
app.tabs.set(Tab::Review);
app.replace_requested_reviews(
app.projects.active_project_id(),
vec![
requested_review("First review body"),
requested_review("Second body"),
],
);
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert_eq!(app.requested_review_selected_index(), Some(1));
}
#[tokio::test]
async fn test_review_tab_enter_opens_selected_review_detail() {
let (mut app, _base_dir) = new_test_app_with_git().await;
app.tabs.set(Tab::Review);
app.replace_requested_reviews(
app.projects.active_project_id(),
vec![
requested_review("First review body"),
requested_review("Second body"),
],
);
app.next_requested_review();
let event_result = handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::ReviewDetail {
ref review,
scroll_offset,
..
} if review.body.as_deref() == Some("Second body")
&& scroll_offset == 0
));
}
#[tokio::test]
async fn test_review_tab_enter_uses_grouped_selection_order_after_mixed_provider_order() {
let (mut app, _base_dir) = new_test_app_with_git().await;
app.tabs.set(Tab::Review);
app.replace_requested_reviews(
app.projects.active_project_id(),
vec![
requested_review_with_audience(RequestedReviewAudience::Group, "Group body"),
requested_review_with_audience(RequestedReviewAudience::Personal, "Personal body"),
],
);
let event_result = handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::ReviewDetail {
ref review,
scroll_offset,
..
} if review.audience == RequestedReviewAudience::Personal
&& review.body.as_deref() == Some("Personal body")
&& scroll_offset == 0
));
}
#[tokio::test]
async fn test_handle_enter_key_keeps_persisted_size_until_turn_completion() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let expected_session_id = app
.create_session()
.await
.expect("failed to create session");
app.services
.db()
.sessions()
.update_session_diff_stats(0, 0, &expected_session_id, "XS")
.await
.expect("failed to set stale size");
let session_index = app
.session_index_for_id(&expected_session_id)
.expect("missing created session");
let session_folder = app.sessions.sessions()[session_index].folder.clone();
let changed_lines = "line\n".repeat(40);
std::fs::write(session_folder.join("open-size-test.txt"), changed_lines)
.expect("failed to write test file");
app.tabs.set(Tab::Sessions);
app.sessions.select_session_index(Some(0));
app.mode = AppMode::List;
let event_result = handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to handle key");
let db_sessions = app
.services
.db()
.sessions()
.load_sessions()
.await
.expect("failed to load sessions");
let db_size = db_sessions
.iter()
.find(|db_session| db_session.id == expected_session_id)
.map(|db_session| db_session.size.clone())
.expect("missing persisted session");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
scroll_offset: None,
..
} if session_id == &expected_session_id
));
assert_eq!(db_size, "XS");
}
#[tokio::test]
async fn test_handle_enter_key_opens_done_session() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let expected_session_id = app
.create_session()
.await
.expect("failed to create session");
if let Some(session) = app.sessions.sessions_mut().first_mut() {
session.status = Status::Done;
}
app.tabs.set(Tab::Sessions);
app.sessions.select_session_index(Some(0));
app.mode = AppMode::List;
let event_result = handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::View {
review_status_message: None,
review_text: None,
ref session_id,
scroll_offset: None,
} if session_id == &expected_session_id
));
}
#[tokio::test]
async fn test_handle_enter_key_opens_canceled_session() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let expected_session_id = app
.create_session()
.await
.expect("failed to create session");
if let Some(session) = app.sessions.sessions_mut().first_mut() {
session.status = Status::Canceled;
}
app.tabs.set(Tab::Sessions);
app.sessions.select_session_index(Some(0));
app.mode = AppMode::List;
let event_result = handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::View {
review_status_message: None,
review_text: None,
ref session_id,
scroll_offset: None,
} if session_id == &expected_session_id
));
}
#[tokio::test]
async fn test_handle_enter_key_switches_to_sessions_tab_from_projects_tab() {
let (mut app, _base_dir) = new_test_app_with_git().await;
app.tabs.set(Tab::Projects);
app.mode = AppMode::List;
let event_result = handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert_eq!(app.tabs.current(), Tab::Sessions);
}
#[tokio::test]
async fn test_handle_enter_key_starts_open_command_editing_in_settings_tab() {
let (mut app, _base_dir) = new_test_app_for_settings().await;
let event_result = handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(app.settings.is_editing_text_input());
}
#[tokio::test]
async fn test_handle_char_key_appends_open_command_value_while_editing() {
let (mut app, _base_dir) = new_test_app_for_settings().await;
handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to start settings editing");
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert_eq!(app.settings.open_command, "a");
assert_eq!(app.sessions.sessions().len(), 1);
}
#[tokio::test]
async fn test_handle_backspace_key_removes_open_command_character_while_editing() {
let (mut app, _base_dir) = new_test_app_for_settings().await;
app.settings.open_command = "abc".to_string();
handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to start settings editing");
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert_eq!(app.settings.open_command, "ab");
}
#[tokio::test]
async fn test_handle_shift_enter_key_inserts_open_command_newline_while_editing() {
let (mut app, _base_dir) = new_test_app_for_settings().await;
app.settings.open_command = "cargo test".to_string();
handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to start settings editing");
let event_result = handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT))
.await
.expect("failed to handle Shift+Enter key");
assert!(matches!(event_result, EventResult::Continue));
assert!(app.settings.is_editing_text_input());
assert_eq!(app.settings.open_command, "cargo test\n");
}
#[tokio::test]
async fn test_handle_alt_enter_key_inserts_open_command_newline_while_editing() {
let (mut app, _base_dir) = new_test_app_for_settings().await;
app.settings.open_command = "cargo test".to_string();
handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to start settings editing");
let event_result = handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT))
.await
.expect("failed to handle Alt+Enter key");
assert!(matches!(event_result, EventResult::Continue));
assert!(app.settings.is_editing_text_input());
assert_eq!(app.settings.open_command, "cargo test\n");
}
#[tokio::test]
async fn test_handle_shift_carriage_return_key_inserts_open_command_newline_while_editing() {
let (mut app, _base_dir) = new_test_app_for_settings().await;
app.settings.open_command = "cargo test".to_string();
handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to start settings editing");
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('\r'), KeyModifiers::SHIFT),
)
.await
.expect("failed to handle Shift+carriage-return key");
assert!(matches!(event_result, EventResult::Continue));
assert!(app.settings.is_editing_text_input());
assert_eq!(app.settings.open_command, "cargo test\n");
}
#[tokio::test]
async fn test_handle_control_j_key_inserts_open_command_newline_while_editing() {
let (mut app, _base_dir) = new_test_app_for_settings().await;
app.settings.open_command = "cargo test".to_string();
handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to start settings editing");
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL),
)
.await
.expect("failed to handle Control+j key");
assert!(matches!(event_result, EventResult::Continue));
assert!(app.settings.is_editing_text_input());
assert_eq!(app.settings.open_command, "cargo test\n");
}
#[tokio::test]
async fn test_handle_line_feed_key_inserts_open_command_newline_while_editing() {
let (mut app, _base_dir) = new_test_app_for_settings().await;
app.settings.open_command = "cargo test".to_string();
handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to start settings editing");
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('\n'), KeyModifiers::NONE),
)
.await
.expect("failed to handle line-feed key");
assert!(matches!(event_result, EventResult::Continue));
assert!(app.settings.is_editing_text_input());
assert_eq!(app.settings.open_command, "cargo test\n");
}
#[tokio::test]
async fn test_handle_enter_key_stops_open_command_editing_when_already_editing() {
let (mut app, _base_dir) = new_test_app_for_settings().await;
handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to start settings editing");
let event_result = handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(!app.settings.is_editing_text_input());
}
#[tokio::test]
async fn test_handle_esc_key_stops_open_command_editing_when_already_editing() {
let (mut app, _base_dir) = new_test_app_for_settings().await;
handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to start settings editing");
let event_result = handle(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.await
.expect("failed to handle Esc key");
assert!(matches!(event_result, EventResult::Continue));
assert!(!app.settings.is_editing_text_input());
}
#[tokio::test]
async fn test_handle_left_key_moves_open_command_cursor_while_editing() {
let (mut app, _base_dir) = new_test_app_for_settings().await;
app.settings.open_command = "ac".to_string();
handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to start settings editing");
handle(&mut app, KeyEvent::new(KeyCode::Left, KeyModifiers::NONE))
.await
.expect("failed to handle left key");
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE),
)
.await
.expect("failed to insert character after cursor move");
assert!(matches!(event_result, EventResult::Continue));
assert_eq!(app.settings.open_command, "abc");
}
#[tokio::test]
async fn test_handle_up_key_moves_open_command_cursor_to_previous_line() {
let (mut app, _base_dir) = new_test_app_for_settings().await;
app.settings.open_command = "ab\nxy".to_string();
handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to start settings editing");
handle(&mut app, KeyEvent::new(KeyCode::Up, KeyModifiers::NONE))
.await
.expect("failed to handle up key");
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::SHIFT),
)
.await
.expect("failed to insert character after up move");
assert!(matches!(event_result, EventResult::Continue));
assert_eq!(app.settings.open_command, "abZ\nxy");
}
#[tokio::test]
async fn test_handle_enter_key_without_session_selection_keeps_list_mode() {
let (mut app, _base_dir) = new_test_app_with_git().await;
app.tabs.set(Tab::Sessions);
app.mode = AppMode::List;
let event_result = handle(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(app.sessions.sessions().is_empty());
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_handle_cancel_key_opens_cancel_confirmation_for_review_session() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let expected_session_id = app
.create_session()
.await
.expect("failed to create session");
app.sessions.sessions_mut()[0].status = Status::Review;
let expected_session_title = app.sessions.sessions()[0].display_title().to_string();
app.tabs.set(Tab::Sessions);
app.sessions.select_session_index(Some(0));
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::CancelSession,
ref confirmation_message,
ref confirmation_title,
restore_view: None,
session_id: Some(ref mode_session_id),
selected_confirmation_index: DEFAULT_OPTION_INDEX,
} if mode_session_id == &expected_session_id
&& confirmation_title == "Confirm Cancel"
&& confirmation_message == &format!("Cancel session \"{expected_session_title}\"?")
));
}
#[tokio::test]
async fn test_handle_cancel_key_opens_cancel_confirmation_for_draft_session() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let expected_session_id = app
.create_draft_session()
.await
.expect("failed to create draft session");
let expected_session_title = app.sessions.sessions()[0].display_title().to_string();
app.tabs.set(Tab::Sessions);
app.sessions.select_session_index(Some(0));
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::CancelSession,
ref confirmation_message,
ref confirmation_title,
restore_view: None,
session_id: Some(ref mode_session_id),
selected_confirmation_index: DEFAULT_OPTION_INDEX,
} if mode_session_id == &expected_session_id
&& confirmation_title == "Confirm Cancel"
&& confirmation_message == &format!("Cancel session \"{expected_session_title}\"?")
));
}
#[tokio::test]
async fn test_handle_cancel_key_opens_cancel_confirmation_for_running_session() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let expected_session_id = app
.create_session()
.await
.expect("failed to create session");
app.sessions.sessions_mut()[0].status = Status::InProgress;
let expected_session_title = app.sessions.sessions()[0].display_title().to_string();
app.tabs.set(Tab::Sessions);
app.sessions.select_session_index(Some(0));
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::CancelSession,
ref confirmation_message,
ref confirmation_title,
restore_view: None,
session_id: Some(ref mode_session_id),
selected_confirmation_index: DEFAULT_OPTION_INDEX,
} if mode_session_id == &expected_session_id
&& confirmation_title == "Confirm Cancel"
&& confirmation_message == &format!("Cancel session \"{expected_session_title}\"?")
));
}
#[tokio::test]
async fn test_handle_cancel_key_ignores_non_review_session() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let _session_id = app
.create_session()
.await
.expect("failed to create session");
app.tabs.set(Tab::Sessions);
app.sessions.select_session_index(Some(0));
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_handle_question_mark_opens_help_overlay() {
let (mut app, _base_dir) = new_test_app().await;
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Help {
context: HelpContext::List { ref keybindings },
scroll_offset: 0,
} if keybindings.iter().any(|action| action.key == "q")
&& keybindings.iter().any(|action| action.key == "?")
));
}
#[tokio::test]
async fn test_handle_sync_key_shows_failure_when_upstream_is_missing() {
let (mut app, _base_dir) = new_test_app_with_git().await;
mock_sync_main_completion(
&mut app,
Err(SyncSessionStartError::Other("missing upstream".to_string())),
);
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::SyncBlockedPopup {
is_loading: true,
ref title,
..
} if title == "Sync in progress"
));
app.process_pending_app_events().await;
assert!(matches!(
app.mode,
AppMode::SyncBlockedPopup {
is_loading: false,
ref title,
..
} if title == "Sync failed"
));
}
#[tokio::test]
async fn test_handle_sync_key_is_case_insensitive() {
let (mut app, _base_dir) = new_test_app_with_git().await;
mock_sync_main_completion(
&mut app,
Err(SyncSessionStartError::Other("missing upstream".to_string())),
);
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::SyncBlockedPopup {
is_loading: true,
ref title,
..
} if title == "Sync in progress"
));
app.process_pending_app_events().await;
assert!(matches!(
app.mode,
AppMode::SyncBlockedPopup {
is_loading: false,
ref title,
..
} if title == "Sync failed"
));
}
#[tokio::test]
async fn test_handle_sync_key_uses_project_name_and_branch_in_popup_message() {
let (mut app, base_dir) = new_test_app_with_git().await;
mock_sync_main_completion(
&mut app,
Err(SyncSessionStartError::MainHasUncommittedChanges {
default_branch: "develop".to_string(),
}),
);
let expected_project_name = base_dir
.path()
.file_name()
.and_then(|name| name.to_str())
.expect("expected temp dir file name")
.to_string();
app.projects.update_active_project_context(
app.active_project_id(),
app.projects.project_name().to_string(),
Some("develop".to_string()),
None,
base_dir.path().to_path_buf(),
);
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::SyncBlockedPopup {
is_loading: true,
ref title,
..
} if title == "Sync in progress"
));
app.process_pending_app_events().await;
assert!(matches!(
app.mode,
AppMode::SyncBlockedPopup {
ref default_branch,
is_loading: false,
ref title,
ref message,
ref project_name,
} if title == "Sync blocked"
&& default_branch.as_deref() == Some("develop")
&& message.contains("cannot run while `develop` has uncommitted changes")
&& project_name.as_deref() == Some(expected_project_name.as_str())
));
}
#[tokio::test]
async fn test_handle_sync_key_opens_popup_when_main_has_uncommitted_changes() {
let (mut app, _base_dir) = new_test_app_with_git().await;
mock_sync_main_completion(
&mut app,
Err(SyncSessionStartError::MainHasUncommittedChanges {
default_branch: "main".to_string(),
}),
);
let event_result = handle(
&mut app,
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
)
.await
.expect("failed to handle key");
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::SyncBlockedPopup {
is_loading: true,
ref title,
..
} if title == "Sync in progress"
));
app.process_pending_app_events().await;
assert!(matches!(
app.mode,
AppMode::SyncBlockedPopup {
ref default_branch,
is_loading: false,
ref title,
ref message,
ref project_name,
} if title == "Sync blocked" && message.contains("cannot run while `main` has uncommitted changes")
&& default_branch.as_deref() == Some("main")
&& project_name.is_some()
));
}
fn requested_review(body: &str) -> RequestedReview {
requested_review_with_audience(RequestedReviewAudience::Personal, body)
}
fn requested_review_with_audience(
audience: RequestedReviewAudience,
body: &str,
) -> RequestedReview {
RequestedReview {
audience,
author: "octocat".to_string(),
body: Some(body.to_string()),
comment_snapshot: None,
display_id: "#42".to_string(),
forge_kind: ForgeKind::GitHub,
repository: "agentty-xyz/agentty".to_string(),
status_summary: None,
title: "Add review detail page".to_string(),
updated_at: Some("2026-04-27T21:30:00Z".to_string()),
web_url: "https://example.com/42".to_string(),
}
}
}