use ag_session::{AnswerQuestionsRequest, QuestionAnswer};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use tracing::warn;
use crate::app::session::SessionTaskService;
use crate::app::{self, App, AppEvent};
use crate::domain::input::InputState;
use crate::domain::question::{QuestionItem, QuestionProgress, default_option_index};
use crate::domain::session::{SessionId, Status};
use crate::presentation::app_mode::{AppMode, ChatFocus, DiffRestoreTarget, QuestionModeSnapshot};
use crate::presentation::prompt::PromptAtMentionState;
use crate::runtime::EventResult;
use crate::runtime::mode::chat_scroll::{self, ChatScrollMetrics};
use crate::runtime::mode::{at_mention, diff, input_key};
use crate::ui::RenderCacheStore;
const NO_ANSWER: &str = "no answer";
pub(crate) async fn handle_with_cache(
app: &mut App,
render_cache_store: &RenderCacheStore,
terminal_size: Rect,
key: KeyEvent,
) -> EventResult {
if is_plain_q(key) && should_exit_to_list_on_q(app) {
exit_to_list_saving_progress(app);
return EventResult::Continue;
}
if handle_chat_focus_key(app, render_cache_store, terminal_size, key).await {
return EventResult::Continue;
}
if is_ctrl_c(key) {
if is_answer_input_focused(app) {
end_turn_no_answer(app).await;
}
return EventResult::Continue;
}
if is_active_at_mention(app) && handle_at_mention_key(app, key) {
return EventResult::Continue;
}
let Some(action) = resolve_question_action(app, key) else {
return EventResult::Continue;
};
match action {
QuestionAction::Submit(response) => submit_response(app, response).await,
QuestionAction::Continue => sync_question_at_mention_state(app),
}
EventResult::Continue
}
#[cfg(test)]
async fn handle(app: &mut App, terminal_size: Rect, key: KeyEvent) -> EventResult {
handle_with_cache(app, &RenderCacheStore::default(), terminal_size, key).await
}
fn is_ctrl_c(key: KeyEvent) -> bool {
matches!(key.code, KeyCode::Char('c' | 'C')) && key.modifiers.contains(KeyModifiers::CONTROL)
}
fn is_answer_input_focused(app: &App) -> bool {
matches!(
&app.mode,
AppMode::Question {
focus: ChatFocus::Input,
..
}
)
}
fn is_plain_q(key: KeyEvent) -> bool {
matches!(key.code, KeyCode::Char('q')) && key.modifiers.is_empty()
}
fn should_exit_to_list_on_q(app: &App) -> bool {
let AppMode::Question {
focus,
selected_option_index,
..
} = &app.mode
else {
return false;
};
*focus == ChatFocus::Chat || selected_option_index.is_some()
}
fn exit_to_list_saving_progress(app: &mut App) {
let mode = std::mem::replace(&mut app.mode, AppMode::List);
if let AppMode::Question {
current_index,
input,
responses,
selected_option_index,
session_id,
..
} = mode
{
app.question_progress.insert(
session_id,
QuestionProgress {
current_index,
input,
responses,
selected_option_index,
},
);
}
}
async fn handle_chat_focus_key(
app: &mut App,
render_cache_store: &RenderCacheStore,
terminal_size: Rect,
key: KeyEvent,
) -> bool {
let AppMode::Question {
focus, session_id, ..
} = &app.mode
else {
return false;
};
let focus = *focus;
match chat_scroll::classify_chat_focus_action(focus, key) {
None => false,
Some(chat_scroll::ChatFocusAction::ToggleFocus) => {
if let AppMode::Question { focus, .. } = &mut app.mode {
chat_scroll::toggle_chat_focus(focus);
}
true
}
Some(chat_scroll::ChatFocusAction::OpenDiff) => {
let session_id = session_id.clone();
show_question_diff(app, &session_id).await;
true
}
Some(chat_scroll::ChatFocusAction::Scroll) => {
if let Some(metrics) = question_scroll_metrics(app, render_cache_store, terminal_size)
&& let AppMode::Question { scroll_offset, .. } = &mut app.mode
{
chat_scroll::apply_scroll_key(scroll_offset, metrics, key);
}
true
}
Some(chat_scroll::ChatFocusAction::Swallow) => true,
}
}
fn question_scroll_metrics(
app: &App,
render_cache_store: &RenderCacheStore,
terminal_size: Rect,
) -> Option<ChatScrollMetrics> {
let AppMode::Question {
focus: ChatFocus::Chat,
session_id,
..
} = &app.mode
else {
return None;
};
let session_index = app
.sessions
.sessions()
.iter()
.position(|session| session.id == *session_id);
Some(session_index.map_or_else(
|| ChatScrollMetrics::empty(terminal_size),
|session_index| {
ChatScrollMetrics::new(
app,
render_cache_store,
session_id,
session_index,
terminal_size,
)
},
))
}
async fn show_question_diff(app: &mut App, session_id: &str) {
let Some(diff) = diff::session_diff(app, session_id).await else {
return;
};
let restore = take_question_snapshot(app).map(DiffRestoreTarget::Question);
diff::enter_diff_mode(app, session_id, diff, restore);
}
fn take_question_snapshot(app: &mut App) -> Option<QuestionModeSnapshot> {
let mode = std::mem::replace(&mut app.mode, AppMode::List);
if let AppMode::Question {
at_mention_state,
current_index,
input,
questions,
responses,
scroll_offset,
selected_option_index,
session_id,
..
} = mode
{
Some(QuestionModeSnapshot {
at_mention_state,
current_index,
input,
questions,
responses,
scroll_offset,
selected_option_index,
session_id,
})
} else {
app.mode = mode;
None
}
}
pub(crate) fn handle_paste(app: &mut App, pasted_text: &str) {
let normalized_text = input_key::normalize_pasted_text(pasted_text);
if normalized_text.is_empty() {
return;
}
if let AppMode::Question {
input,
selected_option_index,
..
} = &mut app.mode
{
if selected_option_index.is_some() {
return;
}
input.insert_text(&normalized_text);
}
sync_question_at_mention_state(app);
}
enum QuestionAction {
Submit(String),
Continue,
}
fn resolve_question_action(app: &mut App, key: KeyEvent) -> Option<QuestionAction> {
let action = {
let AppMode::Question {
current_index,
input,
questions,
selected_option_index,
..
} = &mut app.mode
else {
return None;
};
let option_count = questions
.get(*current_index)
.map_or(0, |item| item.options.len());
let is_navigating_options = selected_option_index.is_some();
match key.code {
KeyCode::Enter | KeyCode::Char('\r' | '\n')
if !is_navigating_options && input_key::should_insert_newline(key) =>
{
input.insert_newline();
QuestionAction::Continue
}
KeyCode::Enter => {
resolve_enter_action(input, questions, *current_index, selected_option_index)
}
KeyCode::Up | KeyCode::Char('k') if is_navigating_options => {
navigate_option_up(selected_option_index);
QuestionAction::Continue
}
KeyCode::Down | KeyCode::Char('j') if is_navigating_options => {
navigate_option_down(selected_option_index, option_count);
QuestionAction::Continue
}
KeyCode::Up
if !is_navigating_options
&& option_count > 0
&& input_key::is_cursor_on_first_line(input) =>
{
*selected_option_index = Some(option_count - 1);
QuestionAction::Continue
}
KeyCode::Down
if !is_navigating_options
&& option_count > 0
&& input_key::is_cursor_on_last_line(input) =>
{
*selected_option_index = Some(0);
QuestionAction::Continue
}
_ if !is_navigating_options => resolve_free_text_key(input, key),
_ => QuestionAction::Continue,
}
};
sync_question_at_mention_state(app);
Some(action)
}
fn resolve_enter_action(
input: &mut InputState,
questions: &[QuestionItem],
current_index: usize,
selected_option_index: &mut Option<usize>,
) -> QuestionAction {
if let Some(option_index) = *selected_option_index {
let selected_text = questions
.get(current_index)
.and_then(|item| item.options.get(option_index))
.cloned()
.unwrap_or_default();
QuestionAction::Submit(normalize_response_text(&selected_text))
} else {
let response_text = input.take_text();
QuestionAction::Submit(normalize_response_text(&response_text))
}
}
fn navigate_option_up(selected_option_index: &mut Option<usize>) {
*selected_option_index = match *selected_option_index {
Some(0) => None,
Some(index) => Some(index.saturating_sub(1)),
None => unreachable!("navigate_option_up requires selected_option_index = Some(_)"),
};
}
fn navigate_option_down(selected_option_index: &mut Option<usize>, option_count: usize) {
*selected_option_index = match *selected_option_index {
Some(index) if index + 1 >= option_count => None,
Some(index) => Some(index + 1),
None => unreachable!("navigate_option_down requires selected_option_index = Some(_)"),
};
}
fn resolve_free_text_key(input: &mut InputState, key: KeyEvent) -> QuestionAction {
if let Some(command) = input_key::command_for_key(key, input_key::InputCapabilities::MULTILINE)
{
input.apply(command);
}
QuestionAction::Continue
}
fn is_active_at_mention(app: &App) -> bool {
matches!(
&app.mode,
AppMode::Question {
at_mention_state: Some(_),
input,
selected_option_index: None,
..
} if input.at_mention_query().is_some()
)
}
fn handle_at_mention_key(app: &mut App, key: KeyEvent) -> bool {
match key.code {
KeyCode::Esc => dismiss_question_at_mention(app),
KeyCode::Enter | KeyCode::Tab => {
handle_question_at_mention_select(app);
return true;
}
KeyCode::Up => handle_question_at_mention_up(app),
KeyCode::Down => handle_question_at_mention_down(app),
_ => return false,
}
true
}
fn sync_question_at_mention_state(app: &mut App) {
let (session_id, sync_action) = match &app.mode {
AppMode::Question {
at_mention_state,
input,
selected_option_index: None,
session_id,
..
} => (
session_id.clone(),
at_mention::sync_action(input, at_mention_state.as_ref()),
),
_ => return,
};
match sync_action {
at_mention::AtMentionSyncAction::Activate => activate_question_at_mention(app, &session_id),
at_mention::AtMentionSyncAction::Dismiss => dismiss_question_at_mention(app),
at_mention::AtMentionSyncAction::KeepOpen => {
if let AppMode::Question {
at_mention_state: Some(state),
..
} = &mut app.mode
{
at_mention::reset_selection(state);
}
}
}
}
fn activate_question_at_mention(app: &mut App, session_id: &str) {
let lookup_root = app.at_mention_lookup_root(session_id);
let owned_session_id = SessionId::from(session_id);
let event_tx = app.services.event_sender();
at_mention::start_loading_entries(event_tx, lookup_root, owned_session_id, &mut app.sessions);
if let AppMode::Question {
at_mention_state, ..
} = &mut app.mode
{
*at_mention_state = Some(PromptAtMentionState::new(Vec::new()));
}
}
fn dismiss_question_at_mention(app: &mut App) {
if let AppMode::Question {
at_mention_state, ..
} = &mut app.mode
{
at_mention::dismiss(at_mention_state);
}
}
fn handle_question_at_mention_up(app: &mut App) {
if let AppMode::Question {
at_mention_state: Some(state),
..
} = &mut app.mode
{
at_mention::move_selection_up(state);
}
}
fn handle_question_at_mention_down(app: &mut App) {
if let AppMode::Question {
at_mention_state: Some(state),
input,
..
} = &mut app.mode
{
at_mention::move_selection_down(input, state);
}
}
fn handle_question_at_mention_select(app: &mut App) {
let replacement = match &app.mode {
AppMode::Question {
at_mention_state: Some(state),
input,
..
} => at_mention::selected_replacement(input, state),
_ => return,
};
if replacement.is_none() {
dismiss_question_at_mention(app);
return;
}
if let Some(selection) = replacement
&& let AppMode::Question { input, .. } = &mut app.mode
{
input.replace_range(selection.at_start, selection.cursor, &selection.text);
}
sync_question_at_mention_state(app);
}
async fn submit_response(app: &mut App, response: String) {
let Some(completed_response) = store_question_response(app, response) else {
return;
};
let session_id = completed_response.session_id.clone();
let answers =
structured_question_answers(&completed_response.questions, &completed_response.responses);
let is_orchestration_proxy = app.has_orchestration_question_proxy(&session_id).await;
app.mode = AppMode::View {
session_id: session_id.clone(),
scroll_offset: None,
};
let service = app.session_service();
let request = service.answer_questions(&session_id, AnswerQuestionsRequest { answers });
let reply_enqueued = match app.drive_session_request(request).await {
Ok(()) => true,
Err(error) => {
warn!(
session_id = %session_id,
error = %error,
"failed to send completed question response"
);
false
}
};
if reply_enqueued {
mark_answered_session(app, &session_id, is_orchestration_proxy);
} else {
restore_completed_question_response(app, completed_response);
}
}
fn mark_answered_session(app: &mut App, session_id: &str, is_orchestration_proxy: bool) {
if is_orchestration_proxy {
mark_orchestration_controller_review(app, session_id);
} else {
mark_session_in_progress(app, session_id);
}
}
struct CompletedQuestionResponse {
questions: Vec<QuestionItem>,
responses: Vec<String>,
scroll_offset: Option<u16>,
session_id: SessionId,
}
fn restore_completed_question_response(
app: &mut App,
mut completed_response: CompletedQuestionResponse,
) {
let Some(final_response) = completed_response.responses.pop() else {
return;
};
if completed_response.questions.is_empty() {
return;
}
let current_index = completed_response
.responses
.len()
.min(completed_response.questions.len().saturating_sub(1));
let selected_option_index =
completed_response
.questions
.get(current_index)
.and_then(|question| {
question
.options
.iter()
.position(|option| option == &final_response)
});
let input = if selected_option_index.is_some() || final_response == NO_ANSWER {
InputState::default()
} else {
InputState::with_text(final_response)
};
app.mode = AppMode::Question {
at_mention_state: None,
current_index,
focus: ChatFocus::Input,
input,
questions: completed_response.questions,
responses: completed_response.responses,
scroll_offset: completed_response.scroll_offset,
selected_option_index,
session_id: completed_response.session_id,
};
}
fn mark_session_in_progress(app: &mut App, session_id: &str) {
if let Some(handles) = app.sessions.session_handles().get(session_id)
&& let Ok(mut handle_status) = handles.status.lock()
{
*handle_status = Status::InProgress;
}
if let Some(session) = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == session_id)
{
session.status = Status::InProgress;
}
}
fn mark_orchestration_controller_review(app: &mut App, session_id: &str) {
if let Some(handles) = app.sessions.session_handles().get(session_id)
&& let Ok(mut handle_status) = handles.status.lock()
{
*handle_status = Status::Review;
}
if let Some(session) = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == session_id)
{
session.status = Status::Review;
session.questions.clear();
}
}
async fn end_turn_no_answer(app: &mut App) {
let AppMode::Question { session_id, .. } = &app.mode else {
return;
};
let session_id = session_id.clone();
let timestamp_seconds =
app::session::unix_timestamp_from_system_time(app.services.clock().now_system_time());
if app
.services
.db()
.sessions()
.update_session_status_with_timing_at(
&session_id,
&Status::Review.to_string(),
timestamp_seconds,
)
.await
.is_err()
{
return;
}
if let Some(handles) = app.sessions.session_handles().get(session_id.as_str())
&& let Ok(mut handle_status) = handles.status.lock()
{
*handle_status = Status::Review;
}
app.services.emit_app_event(AppEvent::SessionUpdated {
session_id: session_id.clone(),
version: SessionTaskService::next_session_update_version(
&app.services.session_update_versions(),
session_id.as_str(),
),
});
app.services.emit_session_and_project_refresh_events();
if let Some(session) = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == session_id)
{
session.status = Status::Review;
}
app.mode = AppMode::View {
session_id,
scroll_offset: None,
};
}
fn store_question_response(app: &mut App, response: String) -> Option<CompletedQuestionResponse> {
let AppMode::Question {
at_mention_state,
current_index,
input,
questions,
responses,
scroll_offset,
selected_option_index,
session_id,
..
} = &mut app.mode
else {
return None;
};
responses.push(response);
*current_index += 1;
*input = InputState::default();
*at_mention_state = None;
*selected_option_index = default_option_index(questions, *current_index);
if *current_index < questions.len() {
return None;
}
Some(CompletedQuestionResponse {
questions: std::mem::take(questions),
responses: std::mem::take(responses),
scroll_offset: *scroll_offset,
session_id: session_id.clone(),
})
}
fn normalize_response_text(response_text: &str) -> String {
let trimmed = response_text.trim();
if trimmed.is_empty() {
return NO_ANSWER.to_string();
}
trimmed.to_string()
}
fn structured_question_answers(
questions: &[QuestionItem],
responses: &[String],
) -> Vec<QuestionAnswer> {
questions
.iter()
.zip(responses)
.map(|(question, answer)| QuestionAnswer {
answer: answer.clone(),
question: question.text.clone(),
})
.collect()
}
#[cfg(test)]
mod tests {
use crossterm::event::KeyModifiers;
use tempfile::tempdir;
use super::*;
use crate::domain::agent::AgentModel;
use crate::domain::session::{SessionRole, Status};
use crate::domain::transient_message::TransientMessageStore;
use crate::presentation::app_mode::ChatFocus;
use crate::ui::component::session_output::SessionOutputLineContext;
use crate::ui::page::session_chat::SessionChatPage;
const TEST_TERMINAL_SIZE: Rect = Rect::new(0, 0, 80, 24);
#[tokio::test]
async fn test_question_at_mention_loads_parent_worktree_entries_for_stacked_session() {
let (mut app, base_dir) = crate::test_support::new_test_app().await;
let parent_session_id = SessionId::from("parent-session");
let child_session_id = SessionId::from("child-session");
let parent_folder = base_dir.path().join("parent-worktree");
let expected_path = "parent_question_target.txt";
std::fs::create_dir_all(&parent_folder).expect("failed to create parent worktree");
std::fs::write(parent_folder.join(expected_path), "parent")
.expect("failed to write parent worktree file");
app.sessions.push_session(
crate::test_support::SessionFixtureBuilder::new()
.id(parent_session_id.clone())
.folder(parent_folder)
.build(),
);
app.sessions.push_session(
crate::test_support::SessionFixtureBuilder::new()
.id(child_session_id.clone())
.folder(base_dir.path().join("missing-child-worktree"))
.parent_session_id(Some(parent_session_id))
.status(Status::Question)
.build(),
);
app.mode = AppMode::Question {
at_mention_state: None,
current_index: 0,
focus: ChatFocus::Input,
input: InputState::with_text("@parent".to_string()),
questions: vec![QuestionItem::new("Which file?")],
responses: Vec::new(),
scroll_offset: None,
selected_option_index: None,
session_id: child_session_id.clone(),
};
sync_question_at_mention_state(&mut app);
let next_event = loop {
let next_event =
tokio::time::timeout(std::time::Duration::from_secs(1), app.next_app_event())
.await
.expect("at-mention event should arrive")
.expect("at-mention event channel should stay open");
if matches!(next_event, AppEvent::AtMentionEntriesLoaded { .. }) {
break next_event;
}
};
assert!(matches!(
next_event,
AppEvent::AtMentionEntriesLoaded {
entries,
session_id,
} if session_id == child_session_id
&& entries.contains(&crate::domain::file_entry::FileEntry {
is_dir: false,
path: expected_path.to_string(),
})
));
}
#[tokio::test]
async fn test_question_scroll_metrics_uses_default_review_model_for_loading_fallback() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
let session_id = "session-review-model";
app.settings.default_review_selection = crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Claude,
AgentModel::ClaudeHaiku4520251001,
);
app.sessions.push_session(
crate::test_support::SessionFixtureBuilder::new()
.id(session_id)
.model(AgentModel::Gpt56Sol)
.status(Status::AgentReview)
.build(),
);
app.mode = AppMode::Question {
at_mention_state: None,
current_index: 0,
focus: ChatFocus::Chat,
input: InputState::default(),
questions: vec![QuestionItem::new("Need a target branch?")],
responses: Vec::new(),
scroll_offset: None,
selected_option_index: None,
session_id: session_id.into(),
};
let terminal_size = Rect::new(0, 0, 16, 24);
let output_width = terminal_size.width.saturating_sub(2);
let render_cache_store = RenderCacheStore::default();
let metrics = question_scroll_metrics(&app, &render_cache_store, terminal_size)
.expect("chat-focused question mode should have scroll metrics");
let session = &app.sessions.sessions()[0];
let expected = SessionChatPage::rendered_output_line_count(
session,
output_width,
metrics.view_height,
SessionOutputLineContext {
active_prompt_output: None,
active_progress: None,
session_update_version: app.session_update_version(session_id),
},
render_cache_store.markdown_render_cache(),
render_cache_store.session_output_layout_cache(),
);
assert_eq!(
metrics.total_lines, expected,
"chat-focused question mode must report transcript line count"
);
}
#[tokio::test]
async fn test_handle_enter_on_type_custom_answer_with_blank_input_records_no_answer() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = AppMode::Question {
at_mention_state: None,
session_id: "missing-session".into(),
questions: vec![
QuestionItem {
options: vec!["Yes".to_string(), "No".to_string()],
text: "Need a target branch?".to_string(),
},
QuestionItem {
options: vec!["Unit".to_string(), "Integration".to_string()],
text: "Need tests?".to_string(),
},
],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input: InputState::default(),
scroll_offset: None,
selected_option_index: None,
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
current_index: 1,
ref responses,
selected_option_index: Some(0),
..
} if responses == &vec![NO_ANSWER.to_string()]
));
}
#[tokio::test]
async fn test_handle_ctrl_c_ends_turn_and_transitions_to_view() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.review_cache.insert(
"session-ctrl-c".into(),
crate::app::ReviewCacheEntry::Ready {
text: "Focused review".to_string(),
diff_hash: 42,
},
);
app.mode = AppMode::Question {
at_mention_state: None,
session_id: "session-ctrl-c".into(),
questions: vec![
QuestionItem {
options: vec!["Yes".to_string(), "No".to_string()],
text: "First question?".to_string(),
},
QuestionItem {
options: vec!["A".to_string(), "B".to_string()],
text: "Second question?".to_string(),
},
],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input: InputState::with_text("partial answer".to_string()),
scroll_offset: None,
selected_option_index: Some(0),
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
)
.await;
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
..
} if session_id == "session-ctrl-c"
));
}
#[tokio::test]
async fn test_handle_ctrl_c_is_swallowed_while_chat_is_focused() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = AppMode::Question {
at_mention_state: None,
session_id: "session-chat-ctrl-c".into(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Q?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Chat,
input: InputState::with_text("partial answer".to_string()),
scroll_offset: None,
selected_option_index: None,
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
focus: ChatFocus::Chat,
ref input,
..
} if input.text() == "partial answer"
));
}
#[tokio::test]
async fn test_handle_escape_preserves_question_turn_and_draft() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = AppMode::Question {
at_mention_state: None,
session_id: "session-esc".into(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Q?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input: InputState::with_text("partial answer".to_string()),
scroll_offset: None,
selected_option_index: None,
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
ref session_id,
ref input,
ref responses,
..
} if session_id == "session-esc"
&& input.text() == "partial answer"
&& responses.is_empty()
));
}
#[tokio::test]
async fn test_handle_q_returns_to_sessions_list_in_chat_focus() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = AppMode::Question {
at_mention_state: None,
session_id: "session-q-chat".into(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Q?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Chat,
input: InputState::default(),
scroll_offset: None,
selected_option_index: None,
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
)
.await;
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_handle_q_returns_to_sessions_list_when_navigating_options() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = AppMode::Question {
at_mention_state: None,
session_id: "session-q-options".into(),
questions: vec![QuestionItem {
options: vec!["Yes".to_string(), "No".to_string()],
text: "Need a target branch?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input: InputState::default(),
scroll_offset: None,
selected_option_index: Some(0),
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
)
.await;
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_handle_q_saves_partial_answers_for_reopen() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = AppMode::Question {
at_mention_state: None,
session_id: "session-q-save".into(),
questions: vec![
QuestionItem::with_options("First?", vec!["Yes".to_string(), "No".to_string()]),
QuestionItem::with_options("Second?", vec!["A".to_string(), "B".to_string()]),
],
responses: vec!["Yes".to_string()],
current_index: 1,
focus: ChatFocus::Input,
input: InputState::default(),
scroll_offset: None,
selected_option_index: Some(1),
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
)
.await;
assert!(matches!(app.mode, AppMode::List));
let progress = app
.question_progress
.get("session-q-save")
.expect("progress should be saved");
assert_eq!(progress.responses, vec!["Yes".to_string()]);
assert_eq!(progress.current_index, 1);
assert_eq!(progress.selected_option_index, Some(1));
}
#[tokio::test]
async fn test_handle_q_inserts_character_in_free_text_answer() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = AppMode::Question {
at_mention_state: None,
session_id: "session-q-text".into(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Free text?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input: InputState::default(),
scroll_offset: None,
selected_option_index: None,
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
)
.await;
assert!(matches!(
&app.mode,
AppMode::Question { input, .. } if input.text() == "q"
));
}
#[tokio::test]
async fn test_handle_ctrl_c_sets_in_memory_session_status_to_review() {
use std::path::PathBuf;
use crate::domain::session::{Session, SessionSize, SessionStats};
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
let session_id = "session-review-check";
app.sessions.push_session(Session {
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: PathBuf::from("/tmp/test"),
follow_up_tasks: Vec::new(),
id: session_id.into(),
in_progress_started_at: None,
in_progress_total_seconds: 0,
is_draft: false,
controller_session_id: None,
orchestration_progress: None,
role: SessionRole::default(),
agent: crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Antigravity,
crate::domain::agent::AgentModel::Gemini36Flash,
),
parent_session_id: None,
personality_id: None,
project_name: String::new(),
prompt: String::new(),
queued_messages: Vec::new(),
reasoning_level_override: None,
published_upstream_ref: None,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
speed_mode: crate::domain::agent::SpeedMode::default(),
stats: SessionStats::default(),
status: Status::Question,
summary: None,
title: None,
transcript: None,
updated_at: 0,
transient_messages: TransientMessageStore::default(),
});
app.mode = AppMode::Question {
at_mention_state: None,
session_id: session_id.into(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Q?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input: InputState::default(),
scroll_offset: None,
selected_option_index: None,
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
)
.await;
let session = app
.sessions
.sessions()
.iter()
.find(|session| session.id == session_id)
.expect("session should exist");
assert_eq!(session.status, Status::Review);
}
#[tokio::test]
async fn test_handle_ctrl_c_updates_session_handle_status_to_review() {
use std::path::PathBuf;
use crate::domain::session::{Session, SessionHandles, SessionSize, SessionStats};
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
let session_id = "session-handle-review";
app.sessions.push_session(Session {
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: PathBuf::from("/tmp/test"),
follow_up_tasks: Vec::new(),
id: session_id.into(),
in_progress_started_at: None,
in_progress_total_seconds: 0,
is_draft: false,
controller_session_id: None,
orchestration_progress: None,
role: SessionRole::default(),
agent: crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Antigravity,
crate::domain::agent::AgentModel::Gemini36Flash,
),
parent_session_id: None,
personality_id: None,
project_name: String::new(),
prompt: String::new(),
queued_messages: Vec::new(),
reasoning_level_override: None,
published_upstream_ref: None,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
speed_mode: crate::domain::agent::SpeedMode::default(),
stats: SessionStats::default(),
status: Status::Question,
summary: None,
title: None,
transcript: None,
updated_at: 0,
transient_messages: TransientMessageStore::default(),
});
app.sessions.session_handles_mut().insert(
session_id.to_string().into(),
SessionHandles::new(Status::Question),
);
app.mode = AppMode::Question {
at_mention_state: None,
session_id: session_id.into(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Q?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input: InputState::default(),
scroll_offset: None,
selected_option_index: None,
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
)
.await;
let handles = app
.sessions
.session_handles()
.get(session_id)
.expect("handle should exist");
let handle_status = handles.status.lock().expect("lock should succeed");
assert_eq!(*handle_status, Status::Review);
}
#[tokio::test]
async fn test_handle_ctrl_c_closes_open_in_progress_timer_before_review() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
let session_id = "session-timer-close";
let project_id = app
.services
.db()
.projects()
.upsert_project("/tmp/test", None)
.await
.expect("failed to upsert project");
app.services
.db()
.sessions()
.insert_session(
session_id,
"gemini-3.6-flash",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
app.services
.db()
.sessions()
.update_session_status_with_timing_at(session_id, "InProgress", 0)
.await
.expect("failed to open timing window");
app.mode = AppMode::Question {
at_mention_state: None,
session_id: session_id.into(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Q?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input: InputState::default(),
scroll_offset: None,
selected_option_index: None,
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
)
.await;
let sessions = app
.services
.db()
.sessions()
.load_sessions_for_project(project_id)
.await
.expect("failed to load sessions");
let session = sessions
.iter()
.find(|session| session.id == session_id)
.expect("missing session row");
assert_eq!(session.status, "Review");
assert_eq!(session.in_progress_started_at, None);
assert!(session.in_progress_total_seconds > 0);
}
#[tokio::test]
async fn test_handle_enter_on_last_question_restores_answer_when_reply_is_not_enqueued() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = AppMode::Question {
at_mention_state: None,
session_id: "missing-session".into(),
questions: vec![QuestionItem {
options: vec!["Today".to_string(), "Tomorrow".to_string()],
text: "Need exact date?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input: InputState::with_text("March 4, 2026".to_string()),
scroll_offset: None,
selected_option_index: None,
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
current_index: 0,
ref input,
ref questions,
ref responses,
selected_option_index: None,
ref session_id,
..
} if session_id == "missing-session"
&& questions.len() == 1
&& responses.is_empty()
&& input.text() == "March 4, 2026"
));
}
#[tokio::test]
async fn mark_session_in_progress_advances_snapshot_and_handle_status() {
use crate::domain::session::SessionHandles;
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
let session_id = "session-in-progress";
app.sessions.push_session(
crate::test_support::SessionFixtureBuilder::new()
.id(session_id)
.folder(std::path::PathBuf::from("/tmp/test"))
.status(Status::Question)
.build(),
);
app.sessions.session_handles_mut().insert(
session_id.to_string().into(),
SessionHandles::new(Status::Question),
);
mark_session_in_progress(&mut app, session_id);
let session = app
.sessions
.sessions()
.iter()
.find(|session| session.id == session_id)
.expect("session should exist");
assert_eq!(session.status, Status::InProgress);
let handles = app
.sessions
.session_handles()
.get(session_id)
.expect("handle should exist");
assert_eq!(
*handles.status.lock().expect("lock should succeed"),
Status::InProgress
);
}
#[tokio::test]
async fn mark_orchestration_controller_review_clears_proxied_questions() {
use crate::domain::session::SessionHandles;
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
let session_id = "campaign-controller";
app.sessions.push_session(
crate::test_support::SessionFixtureBuilder::new()
.id(session_id)
.role(SessionRole::Orchestrator)
.status(Status::Question)
.questions(vec![QuestionItem::new("Choose one")])
.build(),
);
app.sessions.session_handles_mut().insert(
session_id.to_string().into(),
SessionHandles::new(Status::Question),
);
mark_answered_session(&mut app, session_id, true);
let session = &app.sessions.sessions()[0];
assert_eq!(session.status, Status::Review);
assert_eq!(session.questions, [] as [ag_protocol::QuestionItem; 0]);
assert_eq!(
*app.sessions
.session_handles()
.get(session_id)
.expect("handle should exist")
.status
.lock()
.expect("status should remain available"),
Status::Review
);
}
#[tokio::test]
async fn submit_response_advances_session_when_reply_is_enqueued() {
let (mut app, _base_dir) =
crate::test_support::new_git_test_app_with_mock_tmux_client().await;
let session_id = app
.create_session()
.await
.expect("session should be created");
crate::test_support::set_session_status_for_test(&mut app, &session_id, Status::Question);
let questions = vec![QuestionItem::new("Which tests should be added?")];
app.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == session_id)
.expect("session should be loaded")
.questions = questions.clone();
app.services
.db()
.sessions()
.update_session_questions(
&session_id,
&serde_json::to_string(&questions).expect("questions should serialize"),
)
.await
.expect("questions should persist");
app.mode = AppMode::Question {
at_mention_state: None,
current_index: 0,
focus: ChatFocus::Input,
input: InputState::default(),
questions,
responses: Vec::new(),
scroll_offset: None,
selected_option_index: None,
session_id: session_id.clone().into(),
};
submit_response(&mut app, "Unit and integration tests".to_string()).await;
let session = app
.sessions
.sessions()
.iter()
.find(|session| session.id == session_id)
.expect("session should exist");
assert_eq!(session.status, Status::InProgress);
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
scroll_offset: None,
} if session_id == &session.id
));
}
#[tokio::test]
async fn submit_response_keeps_question_status_when_reply_is_not_enqueued() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
let session_id = "session-no-handle";
let questions = vec![
QuestionItem::with_options(
"Use the default target branch?",
vec!["Yes".to_string(), "No".to_string()],
),
QuestionItem::new("Which tests should be added?"),
];
app.sessions.push_session(
crate::test_support::SessionFixtureBuilder::new()
.id(session_id)
.folder(std::path::PathBuf::from("/tmp/test"))
.status(Status::Question)
.questions(questions.clone())
.build(),
);
app.mode = AppMode::Question {
at_mention_state: None,
session_id: session_id.into(),
questions,
responses: vec!["Yes".to_string()],
current_index: 1,
focus: ChatFocus::Input,
input: InputState::default(),
scroll_offset: None,
selected_option_index: None,
};
submit_response(&mut app, "Unit and integration tests".to_string()).await;
let session = app
.sessions
.sessions()
.iter()
.find(|session| session.id == session_id)
.expect("session should exist");
assert_eq!(session.status, Status::Question);
assert!(matches!(
app.mode,
AppMode::Question {
current_index: 1,
ref input,
ref questions,
ref responses,
selected_option_index: None,
..
} if questions.len() == 2
&& responses == &vec!["Yes".to_string()]
&& input.text() == "Unit and integration tests"
));
}
#[tokio::test]
async fn test_handle_paste_normalizes_line_endings_in_free_text_mode() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = AppMode::Question {
at_mention_state: None,
session_id: "session-id".into(),
questions: vec![QuestionItem {
options: vec!["Default".to_string()],
text: "Question".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input: InputState::default(),
scroll_offset: None,
selected_option_index: None,
};
handle_paste(&mut app, "line1\r\nline2\rline3");
assert!(matches!(
app.mode,
AppMode::Question { ref input, .. } if input.text() == "line1\nline2\nline3"
));
}
#[tokio::test]
async fn test_handle_paste_ignored_while_navigating_options() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
handle_paste(&mut app, "pasted text");
assert!(matches!(
app.mode,
AppMode::Question {
selected_option_index: Some(0),
ref input,
..
} if input.text().is_empty()
));
}
fn question_mode_with_options() -> AppMode {
AppMode::Question {
at_mention_state: None,
session_id: "session-id".into(),
questions: vec![QuestionItem {
options: vec![
"Option A".to_string(),
"Option B".to_string(),
"Option C".to_string(),
],
text: "Pick one?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input: InputState::default(),
scroll_offset: None,
selected_option_index: Some(0),
}
}
#[tokio::test]
async fn test_handle_down_from_first_selects_second_option() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
selected_option_index: Some(1),
..
}
));
}
#[tokio::test]
async fn test_handle_up_from_first_enters_free_text_mode() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
selected_option_index: None,
..
}
));
}
#[tokio::test]
async fn test_handle_down_from_last_real_enters_free_text_mode() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
if let AppMode::Question {
selected_option_index,
..
} = &mut app.mode
{
*selected_option_index = Some(2);
}
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
selected_option_index: None,
..
}
));
}
#[tokio::test]
async fn test_handle_up_from_free_text_returns_to_last_real_option() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
if let AppMode::Question {
selected_option_index,
..
} = &mut app.mode
{
*selected_option_index = None;
}
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
selected_option_index: Some(2),
..
}
));
}
#[tokio::test]
async fn test_handle_down_from_free_text_wraps_to_first_option() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
if let AppMode::Question {
selected_option_index,
..
} = &mut app.mode
{
*selected_option_index = None;
}
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
selected_option_index: Some(0),
..
}
));
}
#[tokio::test]
async fn test_handle_enter_with_selected_option_submits_option_text() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = AppMode::Question {
at_mention_state: None,
session_id: "missing-session".into(),
questions: vec![
QuestionItem {
options: vec!["Yes".to_string(), "No".to_string()],
text: "Continue?".to_string(),
},
QuestionItem {
options: vec!["Details".to_string(), "Skip".to_string()],
text: "Follow-up?".to_string(),
},
],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input: InputState::default(),
scroll_offset: None,
selected_option_index: Some(1),
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
current_index: 1,
ref responses,
selected_option_index: Some(0),
..
} if responses == &vec!["No".to_string()]
));
}
#[tokio::test]
async fn test_handle_char_ignored_while_navigating_options() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
if let AppMode::Question {
selected_option_index,
..
} = &mut app.mode
{
*selected_option_index = Some(1);
}
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
selected_option_index: Some(1),
ref input,
..
} if input.text().is_empty()
));
}
#[tokio::test]
async fn test_handle_up_from_free_text_stays_in_free_text_when_no_options() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("some text", 4);
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
selected_option_index: None,
..
}
));
}
#[tokio::test]
async fn test_handle_up_from_free_text_stays_when_cursor_not_on_first_line() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
if let AppMode::Question {
selected_option_index,
input,
..
} = &mut app.mode
{
*selected_option_index = None;
*input = InputState::with_text("first\nsecond".to_string());
input.cursor = "first\nseco".chars().count();
}
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
selected_option_index: None,
..
}
));
}
#[tokio::test]
async fn test_handle_down_from_free_text_stays_when_cursor_not_on_last_line() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
if let AppMode::Question {
selected_option_index,
input,
..
} = &mut app.mode
{
*selected_option_index = None;
*input = InputState::with_text("first\nsecond".to_string());
input.cursor = 2;
}
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
selected_option_index: None,
..
}
));
}
#[tokio::test]
async fn test_handle_char_inserts_in_free_text_mode() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
if let AppMode::Question {
selected_option_index,
..
} = &mut app.mode
{
*selected_option_index = None;
}
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
selected_option_index: None,
ref input,
..
} if input.text() == "x"
));
}
#[tokio::test]
async fn test_handle_j_selects_next_option() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
selected_option_index: Some(1),
..
}
));
}
#[tokio::test]
async fn test_handle_k_selects_previous_option() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
if let AppMode::Question {
selected_option_index,
..
} = &mut app.mode
{
*selected_option_index = Some(2);
}
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
selected_option_index: Some(1),
..
}
));
}
#[tokio::test]
async fn test_store_question_response_defaults_to_first_option_on_next_question() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = AppMode::Question {
at_mention_state: None,
session_id: "missing-session".into(),
questions: vec![
QuestionItem {
options: vec!["Foo".to_string()],
text: "First question?".to_string(),
},
QuestionItem {
options: vec!["Alpha".to_string(), "Beta".to_string()],
text: "Pick one?".to_string(),
},
],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input: InputState::with_text("answer".to_string()),
scroll_offset: None,
selected_option_index: None,
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
current_index: 1,
selected_option_index: Some(0),
..
}
));
}
#[test]
fn test_default_option_index_returns_first_when_options_exist() {
let questions = vec![QuestionItem {
options: vec!["A".to_string(), "B".to_string()],
text: "Pick?".to_string(),
}];
assert_eq!(default_option_index(&questions, 0), Some(0));
}
#[test]
fn test_default_option_index_returns_none_for_question_without_predefined_options() {
let questions = vec![QuestionItem {
options: Vec::new(),
text: "Type something?".to_string(),
}];
assert_eq!(default_option_index(&questions, 0), None);
}
#[test]
fn test_default_option_index_returns_none_for_out_of_bounds() {
let questions: Vec<QuestionItem> = Vec::new();
assert_eq!(default_option_index(&questions, 0), None);
}
#[tokio::test]
async fn test_handle_tab_toggles_focus_from_answer_to_chat() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
focus: ChatFocus::Chat,
..
}
));
}
#[tokio::test]
async fn test_handle_tab_toggles_focus_from_chat_to_answer() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
if let AppMode::Question { focus, .. } = &mut app.mode {
*focus = ChatFocus::Chat;
}
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
focus: ChatFocus::Input,
..
}
));
}
#[tokio::test]
async fn test_chat_focus_key_ignores_non_question_mode() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = AppMode::List;
let is_consumed = handle_chat_focus_key(
&mut app,
&RenderCacheStore::default(),
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
)
.await;
assert!(!is_consumed);
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_handle_scroll_down_in_chat_focus_updates_scroll_offset() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
if let AppMode::Question {
focus,
scroll_offset,
..
} = &mut app.mode
{
*focus = ChatFocus::Chat;
*scroll_offset = Some(0);
}
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
focus: ChatFocus::Chat,
..
}
));
}
#[tokio::test]
async fn test_handle_scroll_keys_ignored_in_answer_focus() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
focus: ChatFocus::Input,
selected_option_index: Some(1),
scroll_offset: None,
..
}
));
}
#[tokio::test]
async fn test_handle_jump_to_top_in_chat_focus_sets_offset_zero() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
if let AppMode::Question {
focus,
scroll_offset,
..
} = &mut app.mode
{
*focus = ChatFocus::Chat;
*scroll_offset = None;
}
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
focus: ChatFocus::Chat,
scroll_offset: Some(0),
..
}
));
}
#[tokio::test]
async fn test_handle_jump_to_bottom_in_chat_focus_sets_offset_none() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
if let AppMode::Question {
focus,
scroll_offset,
..
} = &mut app.mode
{
*focus = ChatFocus::Chat;
*scroll_offset = Some(5);
}
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
focus: ChatFocus::Chat,
scroll_offset: None,
..
}
));
}
#[tokio::test]
async fn test_handle_chat_focus_preserves_question_state_for_enter_and_escape() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = question_mode_with_options();
if let AppMode::Question { focus, .. } = &mut app.mode {
*focus = ChatFocus::Chat;
}
for key in [KeyCode::Enter, KeyCode::Esc] {
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(key, KeyModifiers::NONE),
)
.await;
}
assert!(matches!(
app.mode,
AppMode::Question {
focus: ChatFocus::Chat,
current_index: 0,
ref responses,
..
} if responses.is_empty()
));
}
#[test]
fn test_structured_question_answers_pairs_all_responses() {
let questions = vec![
QuestionItem {
options: vec!["main".to_string(), "develop".to_string()],
text: "Need target?".to_string(),
},
QuestionItem {
options: vec!["Yes".to_string(), "No".to_string()],
text: "Need tests?".to_string(),
},
];
let responses = vec!["main".to_string(), NO_ANSWER.to_string()];
let answers = structured_question_answers(&questions, &responses);
assert_eq!(
answers,
[
QuestionAnswer {
answer: "main".to_string(),
question: "Need target?".to_string(),
},
QuestionAnswer {
answer: NO_ANSWER.to_string(),
question: "Need tests?".to_string(),
},
]
);
}
fn free_text_question_mode(text: &str, cursor: usize) -> AppMode {
let mut input = InputState::with_text(text.to_string());
input.cursor = cursor;
AppMode::Question {
at_mention_state: None,
session_id: "session-id".into(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Question?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input,
scroll_offset: None,
selected_option_index: None,
}
}
#[tokio::test]
async fn test_resolve_free_text_super_left_moves_to_line_start() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("first\nsecond\nthird", "first\nseco".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Left, KeyModifiers::SUPER),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.cursor, "first\n".chars().count());
}
}
#[tokio::test]
async fn test_resolve_free_text_super_right_moves_to_line_end() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("first\nsecond\nthird", "first\nse".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Right, KeyModifiers::SUPER),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.cursor, "first\nsecond".chars().count());
}
}
#[tokio::test]
async fn test_resolve_free_text_ctrl_a_moves_to_line_start() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("first\nsecond\nthird", "first\nseco".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.cursor, "first\n".chars().count());
}
}
#[tokio::test]
async fn test_resolve_free_text_ctrl_e_moves_to_line_end() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("first\nsecond\nthird", "first\nse".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.cursor, "first\nsecond".chars().count());
}
}
#[tokio::test]
async fn test_resolve_free_text_alt_b_moves_to_previous_word() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode =
free_text_question_mode("hello brave world", "hello brave world".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.cursor, "hello brave ".chars().count());
}
}
#[tokio::test]
async fn test_resolve_free_text_alt_f_moves_to_next_word() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("hello brave world", 0);
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::ALT),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.cursor, "hello ".chars().count());
}
}
#[tokio::test]
async fn test_resolve_free_text_alt_left_moves_to_previous_word() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode =
free_text_question_mode("hello brave world", "hello brave world".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Left, KeyModifiers::ALT),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.cursor, "hello brave ".chars().count());
}
}
#[tokio::test]
async fn test_resolve_free_text_alt_right_moves_to_next_word() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("hello brave world", 0);
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Right, KeyModifiers::ALT),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.cursor, "hello ".chars().count());
}
}
#[tokio::test]
async fn test_resolve_free_text_alt_enter_inserts_newline() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("hello", "hello".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.text(), "hello\n");
}
}
#[tokio::test]
async fn test_resolve_free_text_shift_enter_inserts_newline() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("hello", "hello".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.text(), "hello\n");
}
}
#[tokio::test]
async fn test_resolve_free_text_ctrl_j_inserts_newline() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("hello", "hello".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.text(), "hello\n");
}
}
#[tokio::test]
async fn test_resolve_free_text_ctrl_m_inserts_newline() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("hello", "hello".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.text(), "hello\n");
}
}
#[tokio::test]
async fn test_resolve_free_text_ctrl_f_moves_right() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("hello", 2);
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.cursor, 3);
}
}
#[tokio::test]
async fn test_resolve_free_text_ctrl_b_moves_left() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("hello", 3);
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.cursor, 2);
}
}
#[tokio::test]
async fn test_resolve_free_text_ctrl_p_moves_up() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("first\nsecond", "first\nseco".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert!(input.cursor < "first\n".chars().count());
}
}
#[tokio::test]
async fn test_resolve_free_text_ctrl_n_moves_down() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("first\nsecond", 2);
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert!(input.cursor >= "first\n".chars().count());
}
}
#[tokio::test]
async fn test_resolve_free_text_ctrl_d_deletes_forward() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("hello", 2);
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.text(), "helo");
assert_eq!(input.cursor, 2);
}
}
#[tokio::test]
async fn test_resolve_free_text_ctrl_k_kills_to_line_end() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("first\nsecond\nthird", "first\nse".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.text(), "first\nse\nthird");
}
}
#[tokio::test]
async fn test_resolve_free_text_ctrl_w_deletes_previous_word() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode =
free_text_question_mode("hello brave world", "hello brave world".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.text(), "hello brave");
}
}
#[tokio::test]
async fn test_resolve_free_text_ctrl_z_undoes_previous_edit() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("hello brave", "hello brave".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE),
)
.await;
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.text(), "hello brave");
assert_eq!(input.cursor, "hello brave".chars().count());
}
}
#[tokio::test]
async fn test_resolve_free_text_alt_backspace_deletes_previous_word() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode =
free_text_question_mode("hello brave world", "hello brave world".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert_eq!(input.text(), "hello brave");
}
}
#[tokio::test]
async fn test_resolve_free_text_super_backspace_deletes_current_line() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = free_text_question_mode("first\nsecond\nthird", "first\nseco".chars().count());
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Backspace, KeyModifiers::SUPER),
)
.await;
if let AppMode::Question { input, .. } = &app.mode {
assert!(!input.text().contains("second"));
}
}
#[tokio::test]
async fn test_alt_enter_ignored_while_navigating_options() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = AppMode::Question {
at_mention_state: None,
session_id: "missing-session".into(),
questions: vec![
QuestionItem {
options: vec!["Yes".to_string(), "No".to_string()],
text: "Continue?".to_string(),
},
QuestionItem {
options: vec!["A".to_string()],
text: "Follow-up?".to_string(),
},
],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input: InputState::default(),
scroll_offset: None,
selected_option_index: Some(0),
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
current_index: 1,
ref responses,
..
} if responses == &vec!["Yes".to_string()]
));
}
#[tokio::test]
async fn test_d_key_in_chat_focus_opens_diff_with_question_snapshot() {
use crate::domain::session::{Session, SessionSize, SessionStats};
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
let session_id = "session-diff-question";
let session_dir = tempdir().expect("failed to create session dir");
app.sessions.push_session(Session {
base_branch: "main".to_string(),
created_at: 0,
draft_attachments: Vec::new(),
folder: session_dir.path().to_path_buf(),
follow_up_tasks: Vec::new(),
id: session_id.into(),
in_progress_started_at: None,
in_progress_total_seconds: 0,
is_draft: false,
controller_session_id: None,
orchestration_progress: None,
role: SessionRole::default(),
agent: crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Antigravity,
crate::domain::agent::AgentModel::Gemini36Flash,
),
parent_session_id: None,
personality_id: None,
project_name: String::new(),
prompt: String::new(),
queued_messages: Vec::new(),
reasoning_level_override: None,
published_upstream_ref: None,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
speed_mode: crate::domain::agent::SpeedMode::default(),
stats: SessionStats::default(),
status: Status::Question,
summary: None,
title: None,
transcript: None,
updated_at: 0,
transient_messages: TransientMessageStore::default(),
});
app.mode = AppMode::Question {
at_mention_state: None,
session_id: session_id.into(),
questions: vec![QuestionItem {
options: vec!["A".to_string()],
text: "Pick one".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Chat,
input: InputState::default(),
scroll_offset: None,
selected_option_index: Some(0),
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Diff {
ref session_id,
restore: Some(_),
..
} if session_id == "session-diff-question"
));
}
}