use std::io;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::Backend;
use ratatui::layout::{Constraint, Layout, Rect};
use tracing::warn;
use crate::app::{App, ReviewCacheEntry, diff_content_hash, review_loading_message};
use crate::domain::session::SessionId;
use crate::domain::transcript_notice::TranscriptNotice;
use crate::runtime::mode::confirmation::ConfirmationDecision;
use crate::runtime::{EventResult, backend_err, mode};
use crate::ui::state::app_mode::{AppMode, ConfirmationIntent, ConfirmationViewMode};
pub(crate) async fn handle_key_event<B: Backend>(
app: &mut App,
terminal: &mut Terminal<B>,
key: KeyEvent,
) -> io::Result<EventResult>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
let result = if let AppMode::Confirmation {
selected_confirmation_index,
..
} = &mut app.mode
{
let decision = mode::confirmation::handle(selected_confirmation_index, key);
handle_confirmation_decision(app, decision).await
} else if matches!(app.mode, AppMode::SessionCreation { .. }) {
handle_session_creation_key(app, key).await
} else if matches!(app.mode, AppMode::OpenCommandSelector { .. }) {
handle_open_command_selector_key(app, key).await
} else if matches!(app.mode, AppMode::PublishBranchInput { .. }) {
Ok(handle_publish_branch_input_key(app, key))
} else {
match &app.mode {
AppMode::List => mode::list::handle(app, key).await,
AppMode::ReviewDetail { .. } => {
let size = terminal.size().map_err(backend_err)?;
let terminal_rect = Rect::new(0, 0, size.width, size.height);
let content_area = content_area_for_terminal(terminal_rect);
Ok(mode::review_detail::handle(app, content_area, key))
}
AppMode::SessionCreation { .. } => {
unreachable!("session creation mode is handled before dispatch matching")
}
AppMode::SyncBlockedPopup { .. } => Ok(mode::sync_blocked::handle(app, key)),
AppMode::ViewInfoPopup { .. } => Ok(handle_view_info_popup_key(app, key)),
AppMode::Confirmation { .. } => {
unreachable!("confirmation mode is handled before dispatch matching")
}
AppMode::View { .. } => mode::session_view::handle(app, terminal, key).await,
AppMode::Prompt { .. } => mode::prompt::handle(app, terminal, key).await,
AppMode::Question { .. } => {
let size = terminal.size().map_err(backend_err)?;
let terminal_rect = Rect::new(0, 0, size.width, size.height);
Ok(mode::question::handle(app, terminal_rect, key).await)
}
AppMode::Diff { .. } => {
let size = terminal.size().map_err(backend_err)?;
let terminal_rect = Rect::new(0, 0, size.width, size.height);
let content_area = content_area_for_terminal(terminal_rect);
Ok(mode::diff::handle(app, content_area, key))
}
AppMode::Help { .. } => Ok(mode::help::handle(app, key)),
AppMode::OpenCommandSelector { .. } => {
unreachable!("open-command selector mode is handled before dispatch matching")
}
AppMode::PublishBranchInput { .. } => {
unreachable!("publish-branch input mode is handled before dispatch matching")
}
}
};
if result.is_ok() {
app.mark_dirty();
}
result
}
fn content_area_for_terminal(terminal_rect: Rect) -> Rect {
let outer_chunks = Layout::default()
.constraints([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.split(terminal_rect);
outer_chunks[1]
}
async fn handle_session_creation_key(app: &mut App, key: KeyEvent) -> io::Result<EventResult> {
match key.code {
KeyCode::Esc => {
app.mode = AppMode::List;
}
KeyCode::Char(character) if character.eq_ignore_ascii_case(&'q') => {
app.mode = AppMode::List;
}
KeyCode::Up | KeyCode::Char('k') => {
update_session_creation_selection(app, 0);
}
KeyCode::Down | KeyCode::Char('j') => {
update_session_creation_selection(
app,
next_session_creation_selection(app).saturating_add(1),
);
}
KeyCode::Enter => {
create_selected_session(app).await?;
}
_ => {}
}
Ok(EventResult::Continue)
}
fn update_session_creation_selection(app: &mut App, selected_option_index: usize) {
let max_option_index = if selected_stacked_parent_session_id(app).is_some() {
2
} else {
1
};
if let AppMode::SessionCreation {
selected_option_index: current_index,
} = &mut app.mode
{
*current_index = selected_option_index.min(max_option_index);
}
}
async fn create_selected_session(app: &mut App) -> io::Result<()> {
let selected_option_index = current_session_creation_selection(app);
let session_id = match selected_option_index {
0 => app.create_session().await.map_err(io::Error::other)?,
1 => app.create_draft_session().await.map_err(io::Error::other)?,
2 => {
let Some(parent_session_id) = selected_stacked_parent_session_id(app) else {
return Ok(());
};
app.create_stacked_draft_session(parent_session_id.as_str())
.await
.map_err(io::Error::other)?
}
_ => return Ok(()),
};
mode::list::open_session_prompt(app, session_id);
Ok(())
}
fn current_session_creation_selection(app: &App) -> usize {
match app.mode {
AppMode::SessionCreation {
selected_option_index,
} => selected_option_index,
_ => 0,
}
}
fn next_session_creation_selection(app: &App) -> usize {
current_session_creation_selection(app)
}
fn selected_stacked_parent_session_id(app: &App) -> Option<SessionId> {
app.selected_session()
.filter(|session| session.allows_stacked_child_creation())
.map(|session| session.id.clone())
}
fn handle_view_info_popup_key(app: &mut App, key: KeyEvent) -> EventResult {
let AppMode::ViewInfoPopup {
is_loading,
restore_view,
..
} = &app.mode
else {
return EventResult::Continue;
};
if *is_loading {
return EventResult::Continue;
}
match key.code {
KeyCode::Enter | KeyCode::Esc => {
app.mode = restore_view.clone().into_view_mode();
}
KeyCode::Char(character) if character.eq_ignore_ascii_case(&'q') => {
app.mode = restore_view.clone().into_view_mode();
}
_ => {}
}
EventResult::Continue
}
fn handle_publish_branch_input_key(app: &mut App, key: KeyEvent) -> EventResult {
let publish_branch_input =
PublishBranchInputModeState::from_mode(std::mem::replace(&mut app.mode, AppMode::List));
let input_locked = publish_branch_input.locked_upstream_ref.is_some();
match key.code {
KeyCode::Esc => {
app.mode = publish_branch_input.restore_view.into_view_mode();
}
KeyCode::Enter => {
let remote_branch_name = if input_locked {
Some(publish_branch_input.input.text().trim().to_string())
} else {
(!publish_branch_input.input.text().trim().is_empty())
.then(|| publish_branch_input.input.text().trim().to_string())
};
let session_id = publish_branch_input.restore_view.session_id.clone();
app.start_publish_branch_action(
publish_branch_input.restore_view,
&session_id,
publish_branch_input.publish_branch_action,
remote_branch_name,
);
}
KeyCode::Left if !input_locked => {
app.mode =
publish_branch_input.apply_input_edit(crate::domain::input::InputState::move_left);
}
KeyCode::Right if !input_locked => {
app.mode =
publish_branch_input.apply_input_edit(crate::domain::input::InputState::move_right);
}
KeyCode::Up if !input_locked => {
app.mode =
publish_branch_input.apply_input_edit(crate::domain::input::InputState::move_up);
}
KeyCode::Down if !input_locked => {
app.mode =
publish_branch_input.apply_input_edit(crate::domain::input::InputState::move_down);
}
KeyCode::Home if !input_locked => {
app.mode =
publish_branch_input.apply_input_edit(crate::domain::input::InputState::move_home);
}
KeyCode::End if !input_locked => {
app.mode =
publish_branch_input.apply_input_edit(crate::domain::input::InputState::move_end);
}
KeyCode::Backspace if !input_locked => {
app.mode = publish_branch_input
.apply_input_edit(crate::domain::input::InputState::delete_backward);
}
KeyCode::Delete if !input_locked => {
app.mode = publish_branch_input
.apply_input_edit(crate::domain::input::InputState::delete_forward);
}
KeyCode::Char(character) if !input_locked && is_publish_branch_input_text_key(key) => {
app.mode = publish_branch_input.apply_input_edit(|input| input.insert_char(character));
}
_ => {
app.mode = publish_branch_input.into_mode();
}
}
EventResult::Continue
}
fn is_publish_branch_input_text_key(key: KeyEvent) -> bool {
key.modifiers == KeyModifiers::NONE || key.modifiers == KeyModifiers::SHIFT
}
struct PublishBranchInputModeState {
default_branch_name: String,
input: crate::domain::input::InputState,
locked_upstream_ref: Option<String>,
publish_branch_action: crate::domain::session::PublishBranchAction,
restore_view: ConfirmationViewMode,
}
impl PublishBranchInputModeState {
fn from_mode(mode: AppMode) -> Self {
let AppMode::PublishBranchInput {
default_branch_name,
input,
locked_upstream_ref,
publish_branch_action,
restore_view,
} = mode
else {
unreachable!("mode must be publish-branch input in this handler");
};
Self {
default_branch_name,
input,
locked_upstream_ref,
publish_branch_action,
restore_view,
}
}
fn apply_input_edit(
mut self,
edit: impl FnOnce(&mut crate::domain::input::InputState),
) -> AppMode {
edit(&mut self.input);
self.into_mode()
}
fn into_mode(self) -> AppMode {
AppMode::PublishBranchInput {
default_branch_name: self.default_branch_name,
input: self.input,
locked_upstream_ref: self.locked_upstream_ref,
publish_branch_action: self.publish_branch_action,
restore_view: self.restore_view,
}
}
}
async fn handle_open_command_selector_key(app: &mut App, key: KeyEvent) -> io::Result<EventResult> {
let mode = std::mem::replace(&mut app.mode, AppMode::List);
let AppMode::OpenCommandSelector {
commands,
restore_view,
selected_command_index,
} = mode
else {
unreachable!("mode must be open-command selector in this handler");
};
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.mode = restore_view.into_view_mode();
}
KeyCode::Char('j') | KeyCode::Down => {
app.mode = AppMode::OpenCommandSelector {
selected_command_index: next_open_command_index(selected_command_index, &commands),
commands,
restore_view,
};
}
KeyCode::Char('k') | KeyCode::Up => {
app.mode = AppMode::OpenCommandSelector {
selected_command_index: previous_open_command_index(
selected_command_index,
&commands,
),
commands,
restore_view,
};
}
KeyCode::Enter => {
let selected_open_command = commands
.get(selected_command_index)
.map(std::string::String::as_str);
app.mode = restore_view.into_view_mode();
app.open_session_worktree_in_tmux_with_command(selected_open_command)
.await;
}
_ => {
app.mode = AppMode::OpenCommandSelector {
commands,
restore_view,
selected_command_index,
};
}
}
Ok(EventResult::Continue)
}
fn next_open_command_index(current_index: usize, commands: &[String]) -> usize {
if commands.is_empty() {
return 0;
}
(current_index + 1) % commands.len()
}
fn previous_open_command_index(current_index: usize, commands: &[String]) -> usize {
if commands.is_empty() {
return 0;
}
if current_index == 0 {
commands.len() - 1
} else {
current_index - 1
}
}
async fn handle_confirmation_decision(
app: &mut App,
decision: ConfirmationDecision,
) -> io::Result<EventResult> {
match decision {
ConfirmationDecision::Confirm => handle_confirmation_confirm(app).await,
ConfirmationDecision::Cancel => {
app.mode = confirmation_cancel_mode(&app.mode);
Ok(EventResult::Continue)
}
ConfirmationDecision::Continue => Ok(EventResult::Continue),
}
}
fn confirmation_cancel_mode(mode: &AppMode) -> AppMode {
if let AppMode::Confirmation {
confirmation_intent:
ConfirmationIntent::ContinueSession
| ConfirmationIntent::MergeSession
| ConfirmationIntent::RegenerateReview,
restore_view: Some(restore_view),
..
} = mode
{
return restore_view.clone().into_view_mode();
}
AppMode::List
}
async fn handle_confirmation_confirm(app: &mut App) -> io::Result<EventResult> {
let (confirmation_intent, confirmation_session_id, restore_view) = match &app.mode {
AppMode::Confirmation {
confirmation_intent,
restore_view,
session_id,
..
} => (
*confirmation_intent,
session_id.clone(),
restore_view.clone(),
),
_ => return Ok(EventResult::Continue),
};
match confirmation_intent {
ConfirmationIntent::Quit => {
app.mode = AppMode::List;
Ok(EventResult::Quit)
}
ConfirmationIntent::CancelSession => {
handle_cancel_session_confirmation(app, confirmation_session_id).await
}
ConfirmationIntent::ContinueSession => {
handle_continue_session_confirmation(app, confirmation_session_id, restore_view).await
}
ConfirmationIntent::MergeSession => {
handle_merge_confirmation(app, confirmation_session_id, restore_view).await
}
ConfirmationIntent::RegenerateReview => {
handle_regenerate_review_confirmation(app, confirmation_session_id, restore_view).await
}
}
}
async fn handle_cancel_session_confirmation(
app: &mut App,
confirmation_session_id: Option<SessionId>,
) -> io::Result<EventResult> {
app.mode = AppMode::List;
if let Some(session_id) = confirmation_session_id
&& let Err(error) = app.cancel_session(&session_id).await
{
warn!(
session_id = %session_id,
error = %error,
"failed to cancel confirmed session"
);
}
Ok(EventResult::Continue)
}
async fn handle_continue_session_confirmation(
app: &mut App,
confirmation_session_id: Option<SessionId>,
restore_view: Option<ConfirmationViewMode>,
) -> io::Result<EventResult> {
let Some(session_id) = confirmation_session_id else {
app.mode = restore_view.map_or(AppMode::List, ConfirmationViewMode::into_view_mode);
return Ok(EventResult::Continue);
};
if let Err(error) = app.continue_terminal_session(&session_id).await {
app.mode = restore_view.map_or(AppMode::List, ConfirmationViewMode::into_view_mode);
app.append_output_for_session(&session_id, &TranscriptNotice::ContinueError.format(error))
.await;
}
Ok(EventResult::Continue)
}
async fn handle_merge_confirmation(
app: &mut App,
confirmation_session_id: Option<SessionId>,
restore_view: Option<ConfirmationViewMode>,
) -> io::Result<EventResult> {
app.mode = restore_view.map_or(AppMode::List, ConfirmationViewMode::into_view_mode);
if let Some(session_id) = confirmation_session_id
&& let Err(error) = app.merge_session(&session_id).await
{
app.append_output_for_session(&session_id, &TranscriptNotice::MergeError.format(error))
.await;
}
Ok(EventResult::Continue)
}
async fn handle_regenerate_review_confirmation(
app: &mut App,
confirmation_session_id: Option<SessionId>,
restore_view: Option<ConfirmationViewMode>,
) -> io::Result<EventResult> {
let Some(session_id) = confirmation_session_id else {
app.mode = AppMode::List;
return Ok(EventResult::Continue);
};
app.review_cache.remove(session_id.as_str());
let session = app
.sessions
.sessions()
.iter()
.find(|session| session.id == session_id);
let Some(session) = session else {
app.mode = restore_view.map_or(AppMode::List, ConfirmationViewMode::into_view_mode);
return Ok(EventResult::Continue);
};
let session_folder = session.folder.clone();
let session_summary = session.summary.clone();
let base_branch = session.base_branch.clone();
let diff = app
.services
.git_client()
.diff(session_folder.clone(), base_branch)
.await
.unwrap_or_else(|error| format!("Failed to run git diff: {error}"));
if diff.trim().is_empty() || diff.starts_with("Failed to run git diff:") {
let mut view_mode = restore_view.unwrap_or(ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: session_id.clone(),
});
view_mode.review_status_message = None;
view_mode.review_text = if diff.trim().is_empty() {
Some("No diff changes found for review.".to_string())
} else {
Some(diff)
};
app.mode = view_mode.into_view_mode();
return Ok(EventResult::Continue);
}
let diff_hash = diff_content_hash(&diff);
let review_model = app.settings.default_review_model;
app.review_cache
.insert(session_id.clone(), ReviewCacheEntry::Loading { diff_hash });
let _ = app
.services
.db()
.sessions()
.update_session_focused_review(session_id.as_str(), None, None)
.await;
app.start_review_assist(
session_id.as_str(),
&session_folder,
diff_hash,
&diff,
session_summary.as_deref(),
);
let mut view_mode = restore_view.unwrap_or(ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id,
});
view_mode.review_status_message = Some(review_loading_message(review_model));
view_mode.review_text = None;
app.mode = view_mode.into_view_mode();
Ok(EventResult::Continue)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use crossterm::event::KeyModifiers;
use mockall::predicate::eq;
use tempfile::tempdir;
use super::*;
use crate::app::AppClients;
use crate::db::Database;
use crate::domain::agent::AgentModel;
use crate::infra::app_server;
use crate::infra::tmux::{MockTmuxClient, TmuxClient};
use crate::ui::state::app_mode::ConfirmationViewMode;
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");
}
fn mock_app_server() -> std::sync::Arc<dyn app_server::AppServerClient> {
std::sync::Arc::new(app_server::MockAppServerClient::new())
}
fn test_app_clients() -> AppClients {
AppClients::new().with_agent_availability_probe(std::sync::Arc::new(
crate::infra::agent::StaticAgentAvailabilityProbe {
available_agent_kinds: crate::domain::agent::AgentKind::ALL.to_vec(),
},
))
}
async fn new_test_app_with_tmux_client(
tmux_client: Arc<dyn TmuxClient>,
) -> (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 clients = test_app_clients()
.with_app_server_client_override(mock_app_server())
.with_tmux_client(tmux_client);
let app = App::new_with_clients(base_path.clone(), base_path, None, database, clients)
.await
.expect("failed to build app");
(app, base_dir)
}
async fn new_test_app() -> (App, tempfile::TempDir) {
new_test_app_with_tmux_client(Arc::new(MockTmuxClient::new())).await
}
async fn new_test_app_with_git_and_tmux_client(
tmux_client: Arc<dyn TmuxClient>,
) -> (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 clients = test_app_clients()
.with_app_server_client_override(mock_app_server())
.with_tmux_client(tmux_client);
let app = App::new_with_clients(
base_path.clone(),
base_path,
Some("main".to_string()),
database,
clients,
)
.await
.expect("failed to build app");
(app, base_dir)
}
async fn new_test_app_with_git() -> (App, tempfile::TempDir) {
new_test_app_with_git_and_tmux_client(Arc::new(MockTmuxClient::new())).await
}
fn set_session_status_for_test(
app: &mut App,
session_id: &str,
status: crate::domain::session::Status,
) {
if let Some(session) = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == session_id)
{
session.status = status;
}
if let Some(handles) = app.sessions.session_handles().get(session_id)
&& let Ok(mut current_status) = handles.status.lock()
{
*current_status = status;
}
}
#[test]
fn test_content_area_for_terminal_excludes_global_bars() {
let terminal_rect = Rect::new(0, 0, 120, 30);
let content_area = content_area_for_terminal(terminal_rect);
assert_eq!(content_area, Rect::new(0, 1, 120, 28));
}
#[tokio::test]
async fn test_handle_session_creation_key_creates_regular_session() {
let (mut app, _base_dir) = new_test_app_with_git().await;
app.mode = AppMode::SessionCreation {
selected_option_index: 0,
};
let result = handle_session_creation_key(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
)
.await;
assert!(matches!(result, Ok(EventResult::Continue)));
assert_eq!(app.sessions.sessions().len(), 1);
assert!(!app.sessions.sessions()[0].is_draft_session());
assert!(matches!(
app.mode,
AppMode::Prompt {
ref session_id,
scroll_offset: None,
..
} if !session_id.is_empty()
));
}
#[tokio::test]
async fn test_handle_session_creation_key_creates_draft_session() {
let (mut app, _base_dir) = new_test_app_with_git().await;
app.mode = AppMode::SessionCreation {
selected_option_index: 0,
};
handle_session_creation_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))
.await
.expect("failed to move selection");
let result = handle_session_creation_key(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
)
.await;
assert!(matches!(result, Ok(EventResult::Continue)));
assert_eq!(app.sessions.sessions().len(), 1);
assert!(app.sessions.sessions()[0].is_draft_session());
assert!(matches!(
app.mode,
AppMode::Prompt {
ref session_id,
scroll_offset: None,
..
} if !session_id.is_empty()
));
}
#[tokio::test]
async fn test_handle_session_creation_key_escape_returns_to_list() {
let (mut app, _base_dir) = new_test_app_with_git().await;
app.mode = AppMode::SessionCreation {
selected_option_index: 0,
};
let result =
handle_session_creation_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
.await;
assert!(matches!(result, Ok(EventResult::Continue)));
assert!(app.sessions.sessions().is_empty());
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_handle_view_info_popup_key_restores_view_mode() {
let (mut app, _base_dir) = new_test_app().await;
app.mode = AppMode::ViewInfoPopup {
is_loading: false,
loading_label: "Refreshing review request...".to_string(),
message: "Review request refreshed.".to_string(),
restore_view: ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: Some(2),
session_id: "session-id".into(),
},
title: "Review request refreshed".to_string(),
};
let event_result =
handle_view_info_popup_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
scroll_offset: Some(2),
..
} if session_id == "session-id"
));
}
#[tokio::test]
async fn test_handle_confirmation_decision_confirm_quits_when_no_session_context() {
let (mut app, _base_dir) = new_test_app().await;
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: 0,
};
let event_result =
handle_confirmation_decision(&mut app, ConfirmationDecision::Confirm).await;
assert!(matches!(event_result, Ok(EventResult::Quit)));
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_handle_confirmation_decision_cancel_returns_to_list() {
let (mut app, _base_dir) = new_test_app().await;
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: 0,
};
let event_result =
handle_confirmation_decision(&mut app, ConfirmationDecision::Cancel).await;
assert!(matches!(event_result, Ok(EventResult::Continue)));
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_handle_confirmation_decision_confirm_cancels_session_when_context_exists() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let session_id = app
.create_session()
.await
.expect("failed to create session");
set_session_status_for_test(
&mut app,
&session_id,
crate::domain::session::Status::Review,
);
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::CancelSession,
confirmation_message: "Cancel session \"test\"?".to_string(),
confirmation_title: "Confirm Cancel".to_string(),
restore_view: None,
session_id: Some(session_id.clone().into()),
selected_confirmation_index: 0,
};
let event_result =
handle_confirmation_decision(&mut app, ConfirmationDecision::Confirm).await;
assert!(matches!(event_result, Ok(EventResult::Continue)));
assert!(matches!(app.mode, AppMode::List));
app.sessions.sync_from_handles();
assert!(matches!(
app.sessions.sessions().first(),
Some(session) if session.id == session_id
&& session.status == crate::domain::session::Status::Canceled
));
}
#[tokio::test]
async fn test_handle_key_event_routes_done_session_continue_shortcut() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let source_session_id = app
.create_session()
.await
.expect("failed to create source session");
app.services
.db()
.sessions()
.update_session_merged_commit_hash(&source_session_id, Some("abc1234".to_string()))
.await
.expect("failed to persist merged commit hash");
let source_session = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == source_session_id)
.expect("expected source session");
source_session.status = crate::domain::session::Status::Done;
source_session.title = Some("Done source".to_string());
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: source_session_id.clone().into(),
scroll_offset: Some(0),
};
let backend = ratatui::backend::TestBackend::new(120, 30);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let event_result = handle_key_event(
&mut app,
&mut terminal,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
)
.await;
assert!(matches!(event_result, Ok(EventResult::Continue)));
assert!(matches!(
app.mode,
AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::ContinueSession,
ref confirmation_title,
ref restore_view,
ref session_id,
..
} if confirmation_title == "Confirm Continue"
&& matches!(restore_view, Some(restore_view) if restore_view.session_id == source_session_id)
&& matches!(session_id, Some(session_id) if session_id.as_str() == source_session_id)
));
}
#[tokio::test]
async fn test_handle_confirmation_decision_cancel_restores_view_for_merge_confirmation() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let session_id = app
.create_session()
.await
.expect("failed to create session");
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::MergeSession,
confirmation_message: "Add this session to merge queue?".to_string(),
confirmation_title: "Confirm Merge".to_string(),
restore_view: Some(ConfirmationViewMode {
review_status_message: Some(review_loading_message(AgentModel::Gpt55)),
review_text: Some("Review output".to_string()),
scroll_offset: Some(6),
session_id: session_id.clone().into(),
}),
session_id: Some(session_id.clone().into()),
selected_confirmation_index: 0,
};
let event_result =
handle_confirmation_decision(&mut app, ConfirmationDecision::Cancel).await;
assert!(matches!(event_result, Ok(EventResult::Continue)));
assert!(matches!(
app.mode,
AppMode::View {
review_status_message: Some(ref review_status_message),
review_text: Some(ref review_text),
session_id: ref session_id_in_mode,
scroll_offset: Some(6),
} if session_id_in_mode == &session_id
&& review_status_message == &review_loading_message(AgentModel::Gpt55)
&& review_text == "Review output"
));
}
#[tokio::test]
async fn test_handle_confirmation_decision_cancel_restores_view_for_continue_confirmation() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let session_id = app
.create_session()
.await
.expect("failed to create session");
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::ContinueSession,
confirmation_message: "Create a new draft session with initial context from this \
session?"
.to_string(),
confirmation_title: "Confirm Continue".to_string(),
restore_view: Some(ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: Some(4),
session_id: session_id.clone().into(),
}),
session_id: Some(session_id.clone().into()),
selected_confirmation_index: 1,
};
let event_result =
handle_confirmation_decision(&mut app, ConfirmationDecision::Cancel).await;
assert!(matches!(event_result, Ok(EventResult::Continue)));
assert!(matches!(
app.mode,
AppMode::View {
session_id: ref session_id_in_mode,
scroll_offset: Some(4),
..
} if session_id_in_mode == &session_id
));
}
#[tokio::test]
async fn test_handle_confirmation_decision_confirm_opens_continuation_draft_prompt() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let merged_commit_hash = "704de31d0f4b5a1234567890abcdef1234567890";
let source_session_id = app
.create_session()
.await
.expect("failed to create session");
app.services
.db()
.sessions()
.update_session_merged_commit_hash(
&source_session_id,
Some(merged_commit_hash.to_string()),
)
.await
.expect("failed to persist merged commit hash");
set_session_status_for_test(
&mut app,
&source_session_id,
crate::domain::session::Status::Done,
);
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::ContinueSession,
confirmation_message: "Create a new draft session with initial context from this \
session?"
.to_string(),
confirmation_title: "Confirm Continue".to_string(),
restore_view: Some(ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: Some(4),
session_id: source_session_id.clone().into(),
}),
session_id: Some(source_session_id.clone().into()),
selected_confirmation_index: 0,
};
let event_result =
handle_confirmation_decision(&mut app, ConfirmationDecision::Confirm).await;
assert!(matches!(event_result, Ok(EventResult::Continue)));
assert!(matches!(
app.mode,
AppMode::Prompt {
ref input,
ref session_id,
..
} if session_id.as_str() != source_session_id
&& input.text().is_empty()
));
let continued_session_id = match &app.mode {
AppMode::Prompt { session_id, .. } => session_id.as_str().to_string(),
_ => unreachable!("expected prompt mode"),
};
let continued_session = app
.sessions
.sessions()
.iter()
.find(|session| session.id == continued_session_id)
.expect("expected created continuation draft");
assert_eq!(
continued_session.prompt,
format!(
"Summarize changes from {merged_commit_hash} to use it as an initial context for \
this session"
)
);
}
#[tokio::test]
async fn test_handle_confirmation_decision_confirm_queues_merge_with_view_restore() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let session_id = app
.create_session()
.await
.expect("failed to create session");
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::MergeSession,
confirmation_message: "Add this session to merge queue?".to_string(),
confirmation_title: "Confirm Merge".to_string(),
restore_view: Some(ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: Some(2),
session_id: session_id.clone().into(),
}),
session_id: Some(session_id.clone().into()),
selected_confirmation_index: 0,
};
let event_result =
handle_confirmation_decision(&mut app, ConfirmationDecision::Confirm).await;
assert!(matches!(event_result, Ok(EventResult::Continue)));
assert!(matches!(
app.mode,
AppMode::View {
review_status_message: None,
review_text: None,
session_id: ref session_id_in_mode,
scroll_offset: Some(2),
} if session_id_in_mode == &session_id
));
app.sessions.sync_from_handles();
let output = app.sessions.sessions()[0].output.clone();
assert!(output.contains("[Merge Error]"));
}
#[tokio::test]
async fn test_handle_publish_branch_input_key_escape_restores_view_mode() {
let (mut app, _base_dir) = new_test_app().await;
app.mode = AppMode::PublishBranchInput {
default_branch_name: "wt/session".to_string(),
input: crate::domain::input::InputState::with_text("review/custom".to_string()),
locked_upstream_ref: None,
publish_branch_action: crate::domain::session::PublishBranchAction::Push,
restore_view: ConfirmationViewMode {
review_status_message: Some(review_loading_message(AgentModel::Gpt55)),
review_text: Some("Critical finding".to_string()),
scroll_offset: Some(7),
session_id: "session-id".into(),
},
};
let event_result = handle_publish_branch_input_key(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::View {
review_status_message: Some(ref status_message),
review_text: Some(ref review_text),
ref session_id,
scroll_offset: Some(7),
} if session_id == "session-id"
&& status_message == &review_loading_message(AgentModel::Gpt55)
&& review_text == "Critical finding"
));
}
#[tokio::test]
async fn test_handle_publish_branch_input_key_enter_starts_pull_request_publish() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let session_id = app
.create_session()
.await
.expect("failed to create session");
set_session_status_for_test(
&mut app,
&session_id,
crate::domain::session::Status::Review,
);
app.mode = AppMode::PublishBranchInput {
default_branch_name: "wt/session".to_string(),
input: crate::domain::input::InputState::with_text("review/custom".to_string()),
locked_upstream_ref: None,
publish_branch_action: crate::domain::session::PublishBranchAction::PublishPullRequest,
restore_view: ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: Some(4),
session_id: session_id.into(),
},
};
let event_result = handle_publish_branch_input_key(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::ViewInfoPopup {
is_loading: true,
ref message,
ref title,
..
} if title == "Publishing review request"
&& message.contains("`review/custom`")
));
}
#[tokio::test]
async fn test_handle_publish_branch_input_key_char_updates_input_state() {
let (mut app, _base_dir) = new_test_app().await;
app.mode = AppMode::PublishBranchInput {
default_branch_name: "wt/session".to_string(),
input: crate::domain::input::InputState::default(),
locked_upstream_ref: None,
publish_branch_action: crate::domain::session::PublishBranchAction::Push,
restore_view: ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: "session-id".into(),
},
};
let event_result = handle_publish_branch_input_key(
&mut app,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::PublishBranchInput {
input: ref input_state,
..
} if input_state.cursor == 1 && input_state.text() == "r"
));
let AppMode::PublishBranchInput { input, .. } = &app.mode else {
unreachable!("mode should remain publish-branch input");
};
assert_eq!(input.text(), "r");
}
#[tokio::test]
async fn test_handle_publish_branch_input_key_shortcut_chars_are_inserted() {
let typed_shortcut_characters = ['q', 'p', 'd', 'f', 'm', 'r', 'j', 'k', 'g', 'G', '?'];
for character in typed_shortcut_characters {
let (mut app, _base_dir) = new_test_app().await;
app.mode = AppMode::PublishBranchInput {
default_branch_name: "wt/session".to_string(),
input: crate::domain::input::InputState::default(),
locked_upstream_ref: None,
publish_branch_action: crate::domain::session::PublishBranchAction::Push,
restore_view: ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: "session-id".into(),
},
};
let modifiers = if character.is_ascii_uppercase() || character == '?' {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
let event_result = handle_publish_branch_input_key(
&mut app,
KeyEvent::new(KeyCode::Char(character), modifiers),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::PublishBranchInput {
input: ref input_state,
..
} if input_state.cursor == 1 && input_state.text() == character.to_string()
));
}
}
#[tokio::test]
async fn test_handle_publish_branch_input_key_left_moves_cursor() {
let (mut app, _base_dir) = new_test_app().await;
app.mode = AppMode::PublishBranchInput {
default_branch_name: "wt/session".to_string(),
input: crate::domain::input::InputState::with_text("review/custom".to_string()),
locked_upstream_ref: None,
publish_branch_action: crate::domain::session::PublishBranchAction::Push,
restore_view: ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: "session-id".into(),
},
};
let event_result = handle_publish_branch_input_key(
&mut app,
KeyEvent::new(KeyCode::Left, KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
let AppMode::PublishBranchInput { input, .. } = &app.mode else {
unreachable!("mode should remain publish-branch input");
};
assert_eq!(input.text(), "review/custom");
assert_eq!(input.cursor, "review/custom".chars().count() - 1);
}
#[tokio::test]
async fn test_handle_publish_branch_input_key_char_keeps_locked_branch_name() {
let (mut app, _base_dir) = new_test_app().await;
app.mode = AppMode::PublishBranchInput {
default_branch_name: "wt/session".to_string(),
input: crate::domain::input::InputState::with_text("review/custom".to_string()),
locked_upstream_ref: Some("origin/review/custom".to_string()),
publish_branch_action: crate::domain::session::PublishBranchAction::Push,
restore_view: ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: "session-id".into(),
},
};
let event_result = handle_publish_branch_input_key(
&mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
let AppMode::PublishBranchInput {
input,
locked_upstream_ref,
..
} = &app.mode
else {
unreachable!("mode should remain publish-branch input");
};
assert_eq!(locked_upstream_ref.as_deref(), Some("origin/review/custom"));
assert_eq!(input.text(), "review/custom");
}
#[test]
fn test_next_open_command_index_wraps_to_start() {
let commands = vec!["cargo test".to_string(), "npm run dev".to_string()];
let index = next_open_command_index(1, &commands);
assert_eq!(index, 0);
}
#[test]
fn test_previous_open_command_index_wraps_to_end() {
let commands = vec!["cargo test".to_string(), "npm run dev".to_string()];
let index = previous_open_command_index(0, &commands);
assert_eq!(index, 1);
}
#[tokio::test]
async fn test_handle_open_command_selector_key_escape_restores_view_mode() {
let (mut app, _base_dir) = new_test_app().await;
app.mode = AppMode::OpenCommandSelector {
commands: vec!["cargo test".to_string(), "npm run dev".to_string()],
restore_view: ConfirmationViewMode {
review_status_message: Some(review_loading_message(AgentModel::Gpt55)),
review_text: Some("Critical finding".to_string()),
scroll_offset: Some(3),
session_id: "session-id".into(),
},
selected_command_index: 1,
};
let event_result = handle_open_command_selector_key(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
)
.await;
assert!(matches!(event_result, Ok(EventResult::Continue)));
assert!(matches!(
app.mode,
AppMode::View {
review_status_message: Some(ref status_message),
review_text: Some(ref review_text),
ref session_id,
scroll_offset: Some(3),
} if session_id == "session-id"
&& status_message == &review_loading_message(AgentModel::Gpt55)
&& review_text == "Critical finding"
));
}
#[tokio::test]
async fn test_handle_open_command_selector_key_j_updates_selected_index() {
let (mut app, _base_dir) = new_test_app().await;
app.mode = AppMode::OpenCommandSelector {
commands: vec!["cargo test".to_string(), "npm run dev".to_string()],
restore_view: ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: "session-id".into(),
},
selected_command_index: 0,
};
let event_result = handle_open_command_selector_key(
&mut app,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
)
.await;
assert!(matches!(event_result, Ok(EventResult::Continue)));
assert!(matches!(
app.mode,
AppMode::OpenCommandSelector {
selected_command_index: 1,
..
}
));
}
#[tokio::test]
async fn test_handle_open_command_selector_key_with_empty_commands_keeps_index_zero() {
let (mut app, _base_dir) = new_test_app().await;
app.mode = AppMode::OpenCommandSelector {
commands: Vec::new(),
restore_view: ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: "session-id".into(),
},
selected_command_index: 0,
};
let event_result = handle_open_command_selector_key(
&mut app,
KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
)
.await;
assert!(matches!(event_result, Ok(EventResult::Continue)));
assert!(matches!(
app.mode,
AppMode::OpenCommandSelector {
selected_command_index: 0,
..
}
));
}
#[tokio::test]
async fn test_handle_open_command_selector_key_enter_restores_view_without_session() {
let (mut app, _base_dir) = new_test_app().await;
app.mode = AppMode::OpenCommandSelector {
commands: vec!["cargo test".to_string()],
restore_view: ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: Some(4),
session_id: "session-id".into(),
},
selected_command_index: 0,
};
let event_result = handle_open_command_selector_key(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
)
.await;
assert!(matches!(event_result, Ok(EventResult::Continue)));
assert!(matches!(
app.mode,
AppMode::View {
review_status_message: None,
review_text: None,
ref session_id,
scroll_offset: Some(4),
} if session_id == "session-id"
));
}
#[tokio::test]
async fn test_handle_open_command_selector_key_enter_runs_selected_command_in_tmux() {
let mut mock_tmux_client = MockTmuxClient::new();
mock_tmux_client
.expect_open_window_for_folder()
.times(1)
.returning(|_| Box::pin(async { Some("@24".to_string()) }));
mock_tmux_client
.expect_run_command_in_window()
.with(eq("@24".to_string()), eq("npm run dev".to_string()))
.times(1)
.returning(|_, _| Box::pin(async {}));
let (mut app, _base_dir) =
new_test_app_with_git_and_tmux_client(Arc::new(mock_tmux_client)).await;
let expected_session_id = app
.create_session()
.await
.expect("failed to create session");
app.mode = AppMode::OpenCommandSelector {
commands: vec!["cargo test".to_string(), "npm run dev".to_string()],
restore_view: ConfirmationViewMode {
review_status_message: None,
review_text: None,
scroll_offset: Some(2),
session_id: expected_session_id.clone().into(),
},
selected_command_index: 1,
};
let event_result = handle_open_command_selector_key(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
)
.await;
assert!(matches!(event_result, Ok(EventResult::Continue)));
assert!(matches!(
app.mode,
AppMode::View {
review_status_message: None,
review_text: None,
ref session_id,
scroll_offset: Some(2),
} if session_id == &expected_session_id
));
}
#[tokio::test]
async fn test_handle_open_command_selector_key_unknown_key_preserves_state() {
let (mut app, _base_dir) = new_test_app().await;
app.mode = AppMode::OpenCommandSelector {
commands: vec!["cargo test".to_string(), "npm run dev".to_string()],
restore_view: ConfirmationViewMode {
review_status_message: Some(review_loading_message(AgentModel::Gpt55)),
review_text: Some("Critical finding".to_string()),
scroll_offset: Some(1),
session_id: "session-id".into(),
},
selected_command_index: 1,
};
let event_result = handle_open_command_selector_key(
&mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
)
.await;
assert!(matches!(event_result, Ok(EventResult::Continue)));
assert!(matches!(
app.mode,
AppMode::OpenCommandSelector {
selected_command_index: 1,
ref commands,
restore_view:
ConfirmationViewMode {
review_status_message: Some(ref status_message),
review_text: Some(ref review_text),
scroll_offset: Some(1),
ref session_id,
},
} if commands == &vec!["cargo test".to_string(), "npm run dev".to_string()]
&& session_id == "session-id"
&& status_message == &review_loading_message(AgentModel::Gpt55)
&& review_text == "Critical finding"
));
}
#[tokio::test]
async fn test_handle_confirmation_decision_cancel_restores_view_for_regenerate_confirmation() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let session_id = app
.create_session()
.await
.expect("failed to create session");
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::RegenerateReview,
confirmation_message: "Regenerate focused review?".to_string(),
confirmation_title: "Confirm Regenerate".to_string(),
restore_view: Some(ConfirmationViewMode {
review_status_message: None,
review_text: Some("Previous review".to_string()),
scroll_offset: Some(4),
session_id: session_id.clone().into(),
}),
session_id: Some(session_id.clone().into()),
selected_confirmation_index: 1,
};
let event_result =
handle_confirmation_decision(&mut app, ConfirmationDecision::Cancel).await;
assert!(matches!(event_result, Ok(EventResult::Continue)));
assert!(matches!(
app.mode,
AppMode::View {
review_text: Some(ref review_text),
scroll_offset: Some(4),
..
} if review_text == "Previous review"
));
}
#[tokio::test]
async fn test_handle_confirmation_decision_confirm_regenerates_review() {
let (mut app, _base_dir) = new_test_app_with_git().await;
let session_id = app
.create_session()
.await
.expect("failed to create session");
let session_folder = app.sessions.sessions()[0].folder.clone();
std::fs::write(session_folder.join("README.md"), "regenerate test\n")
.expect("failed to write");
app.review_cache.insert(
session_id.clone().into(),
ReviewCacheEntry::Ready {
text: "Old review".to_string(),
diff_hash: 99,
},
);
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::RegenerateReview,
confirmation_message: "Regenerate focused review?".to_string(),
confirmation_title: "Confirm Regenerate".to_string(),
restore_view: Some(ConfirmationViewMode {
review_status_message: None,
review_text: Some("Old review".to_string()),
scroll_offset: None,
session_id: session_id.clone().into(),
}),
session_id: Some(session_id.clone().into()),
selected_confirmation_index: 0,
};
let event_result =
handle_confirmation_decision(&mut app, ConfirmationDecision::Confirm).await;
assert!(matches!(event_result, Ok(EventResult::Continue)));
assert!(matches!(
app.mode,
AppMode::View {
review_status_message: Some(_),
review_text: None,
..
}
));
assert!(matches!(
app.review_cache.get(session_id.as_str()),
Some(ReviewCacheEntry::Loading { .. })
));
}
}