use crossterm::event::{self, KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use crate::app::session::SessionTaskService;
use crate::app::{self, App, AppEvent};
use crate::domain::input::InputState;
use crate::domain::session::{SessionId, Status};
use crate::infra::agent::protocol::QuestionItem;
use crate::infra::channel::TurnPrompt;
use crate::runtime::EventResult;
use crate::runtime::mode::{at_mention, input_key};
use crate::ui::component::session_output::SessionOutputLineContext;
use crate::ui::page::session_chat::SessionChatPage;
use crate::ui::state::app_mode::{AppMode, DiffRightPanel, QuestionFocus, QuestionModeSnapshot};
use crate::ui::state::prompt::PromptAtMentionState;
const NO_ANSWER: &str = "no answer";
pub(crate) async fn handle(app: &mut App, terminal_size: Rect, key: KeyEvent) -> EventResult {
if handle_focus_toggle(app, key) {
return EventResult::Continue;
}
if is_ctrl_c(key) {
end_turn_no_answer(app).await;
return EventResult::Continue;
}
if is_plain_q(key) && should_exit_to_list_on_q(app) {
app.mode = AppMode::List;
return EventResult::Continue;
}
if handle_chat_scroll(app, terminal_size, key).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
}
fn is_ctrl_c(key: KeyEvent) -> bool {
matches!(key.code, KeyCode::Char('c' | 'C')) && key.modifiers.contains(KeyModifiers::CONTROL)
}
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 == QuestionFocus::Chat || selected_option_index.is_some()
}
fn handle_focus_toggle(app: &mut App, key: KeyEvent) -> bool {
if key.code != KeyCode::Tab {
return false;
}
let AppMode::Question { focus, .. } = &mut app.mode else {
return false;
};
*focus = match *focus {
QuestionFocus::Answer => QuestionFocus::Chat,
QuestionFocus::Chat => QuestionFocus::Answer,
};
true
}
async fn handle_chat_scroll(app: &mut App, terminal_size: Rect, key: KeyEvent) -> bool {
if !matches!(
&app.mode,
AppMode::Question {
focus: QuestionFocus::Chat,
..
}
) {
return false;
}
let metrics = question_view_metrics(app, terminal_size);
let AppMode::Question {
focus,
scroll_offset,
..
} = &mut app.mode
else {
return false;
};
match key.code {
KeyCode::Enter | KeyCode::Esc => {
*focus = QuestionFocus::Answer;
}
KeyCode::Char('j') | KeyCode::Down => {
*scroll_offset = scroll_offset_down(*scroll_offset, metrics, 1);
}
KeyCode::Char('k') | KeyCode::Up => {
*scroll_offset = Some(scroll_offset_up(*scroll_offset, metrics, 1));
}
KeyCode::Char('g') => *scroll_offset = Some(0),
KeyCode::Char('G') => *scroll_offset = None,
KeyCode::Char('d') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
let step = metrics.view_height / 2;
*scroll_offset = scroll_offset_down(*scroll_offset, metrics, step);
}
KeyCode::Char('d') if !key.modifiers.contains(event::KeyModifiers::CONTROL) => {
let session_id = extract_question_session_id(app);
if let Some(session_id) = session_id {
show_question_diff(app, &session_id).await;
}
return true;
}
KeyCode::Char('u') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
let step = metrics.view_height / 2;
*scroll_offset = Some(scroll_offset_up(*scroll_offset, metrics, step));
}
_ => return false,
}
true
}
#[derive(Clone, Copy)]
struct QuestionViewMetrics {
total_lines: u16,
view_height: u16,
}
fn question_view_metrics(app: &App, terminal_size: Rect) -> QuestionViewMetrics {
let view_height = terminal_size.height.saturating_sub(5);
let output_width = terminal_size.width.saturating_sub(2);
let AppMode::Question { session_id, .. } = &app.mode else {
return QuestionViewMetrics {
total_lines: 0,
view_height,
};
};
let session_index = app
.sessions
.sessions
.iter()
.position(|session| session.id == *session_id);
let (review_status_message, review_text) = match &app.mode {
AppMode::Question {
review_status_message,
review_text,
..
} => (review_status_message.as_deref(), review_text.as_deref()),
AppMode::List
| AppMode::SessionCreation { .. }
| AppMode::Confirmation { .. }
| AppMode::SyncBlockedPopup { .. }
| AppMode::Prompt { .. }
| AppMode::View { .. }
| AppMode::Diff { .. }
| AppMode::Help { .. }
| AppMode::OpenCommandSelector { .. }
| AppMode::PublishBranchInput { .. }
| AppMode::ViewInfoPopup { .. } => (None, None),
};
let total_lines = session_index
.and_then(|index| app.sessions.sessions.get(index))
.map_or(0, |session| {
let active_progress = app.session_progress_message(session_id);
let active_prompt_output = app
.sessions
.active_prompt_outputs()
.get(session_id)
.map(std::string::String::as_str);
SessionChatPage::rendered_output_line_count(
session,
output_width,
SessionOutputLineContext {
active_prompt_output,
active_progress,
review_model: app.settings.default_review_model,
review_status_message,
review_text,
session_update_version: app.session_update_version(session_id),
},
app.markdown_render_cache(),
app.session_output_layout_cache(),
)
});
QuestionViewMetrics {
total_lines,
view_height,
}
}
fn scroll_offset_down(
scroll_offset: Option<u16>,
metrics: QuestionViewMetrics,
step: u16,
) -> Option<u16> {
let current_offset = scroll_offset?;
let next_offset = current_offset.saturating_add(step.max(1));
if next_offset >= metrics.total_lines.saturating_sub(metrics.view_height) {
return None;
}
Some(next_offset)
}
fn scroll_offset_up(scroll_offset: Option<u16>, metrics: QuestionViewMetrics, step: u16) -> u16 {
let current_offset =
scroll_offset.unwrap_or_else(|| metrics.total_lines.saturating_sub(metrics.view_height));
current_offset.saturating_sub(step.max(1))
}
fn extract_question_session_id(app: &App) -> Option<SessionId> {
if let AppMode::Question { session_id, .. } = &app.mode {
Some(session_id.clone())
} else {
None
}
}
async fn show_question_diff(app: &mut App, session_id: &str) {
let session = app
.sessions
.sessions
.iter()
.find(|session| session.id == session_id);
let Some(session) = session else {
return;
};
let session_folder = session.folder.clone();
let base_branch = session.base_branch.clone();
let diff = app
.services
.git_client()
.diff(session_folder, base_branch)
.await
.unwrap_or_else(|error| format!("Failed to run git diff: {error}"));
if diff.trim().is_empty() {
return;
}
let snapshot = take_question_snapshot(app);
app.mode = AppMode::Diff {
diff,
file_explorer_selected_index: 0,
restore_question: snapshot,
right_panel: DiffRightPanel::Diff,
scroll_cache: None,
session_id: session_id.into(),
scroll_offset: 0,
};
}
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,
review_status_message,
review_text,
responses,
scroll_offset,
selected_option_index,
session_id,
..
} = mode
{
Some(QuestionModeSnapshot {
at_mention_state,
current_index,
input,
questions,
review_status_message,
review_text,
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);
}
pub(crate) fn default_option_index(
questions: &[QuestionItem],
question_index: usize,
) -> Option<usize> {
questions
.get(question_index)
.filter(|item| !item.options.is_empty())
.map(|_| 0)
}
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 {
match key.code {
KeyCode::Backspace if input_key::is_line_delete_backspace(key) => {
input.delete_current_line();
}
KeyCode::Backspace if input_key::is_word_delete_backspace(key) => {
input_key::delete_word_backward(input);
}
KeyCode::Backspace => input.delete_backward(),
KeyCode::Delete => input.delete_forward(),
KeyCode::Left if key.modifiers.contains(event::KeyModifiers::SUPER) => {
input.move_line_start();
}
KeyCode::Left
if key
.modifiers
.intersects(event::KeyModifiers::ALT | event::KeyModifiers::SHIFT) =>
{
input_key::move_cursor_word_left(input);
}
KeyCode::Left => input.move_left(),
KeyCode::Right if key.modifiers.contains(event::KeyModifiers::SUPER) => {
input.move_line_end();
}
KeyCode::Right
if key
.modifiers
.intersects(event::KeyModifiers::ALT | event::KeyModifiers::SHIFT) =>
{
input_key::move_cursor_word_right(input);
}
KeyCode::Right => input.move_right(),
KeyCode::Up => input.move_up(),
KeyCode::Down => input.move_down(),
KeyCode::Home => input.move_home(),
KeyCode::End => input.move_end(),
KeyCode::Char('a') if input_key::is_control_key(key) => {
input.move_line_start();
}
KeyCode::Char('e') if input_key::is_control_key(key) => {
input.move_line_end();
}
KeyCode::Char('f') if input_key::is_control_key(key) => {
input.move_right();
}
KeyCode::Char('b') if input_key::is_control_key(key) => {
input.move_left();
}
KeyCode::Char('p') if input_key::is_control_key(key) => {
input.move_up();
}
KeyCode::Char('n') if input_key::is_control_key(key) => {
input.move_down();
}
KeyCode::Char('d') if input_key::is_control_key(key) => {
input.delete_forward();
}
KeyCode::Char('k') if input_key::is_control_key(key) => {
input.delete_to_line_end();
}
KeyCode::Char('w') if input_key::is_control_key(key) => {
input_key::delete_word_backward(input);
}
KeyCode::Char('b') if input_key::is_alt_key(key) => {
input_key::move_cursor_word_left(input);
}
KeyCode::Char('f') if input_key::is_alt_key(key) => {
input_key::move_cursor_word_right(input);
}
KeyCode::Char('u') if input_key::is_control_key(key) => {
input.delete_current_line();
}
KeyCode::Char(character) if input_key::is_control_newline_key(key, character) => {
input.insert_newline();
}
KeyCode::Char(character) if input_key::is_insertable_char_key(key) => {
input.insert_char(character);
}
_ => {}
}
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
.sessions
.sessions
.iter()
.find(|session| session.id == session_id)
.map_or_else(
|| app.working_dir().to_path_buf(),
|session| {
let session_folder = session.folder.clone();
let has_session_folder = app.services.fs_client().is_dir(session_folder.clone());
at_mention::lookup_root(
app.working_dir().to_path_buf(),
Some(session_folder),
has_session_folder,
)
},
);
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((session_id, questions, responses)) = store_question_response(app, response) else {
return;
};
let question_reply = build_question_reply_prompt(&questions, &responses);
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: session_id.clone(),
scroll_offset: None,
};
app.reply(&session_id, TurnPrompt::from_text(question_reply))
.await;
}
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()
.update_session_status_with_timing_at(
&session_id,
&Status::Review.to_string(),
timestamp_seconds,
)
.await
.is_err()
{
return;
}
if let Some(handles) = app.sessions.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_app_event(AppEvent::RefreshSessions);
if let Some(session) = app
.sessions
.sessions
.iter_mut()
.find(|session| session.id == session_id)
{
session.status = Status::Review;
}
let (review_status_message, review_text) = app.review_view_state(&session_id);
app.mode = AppMode::View {
review_status_message,
review_text,
session_id,
scroll_offset: None,
};
}
fn store_question_response(
app: &mut App,
response: String,
) -> Option<(SessionId, Vec<QuestionItem>, Vec<String>)> {
let AppMode::Question {
at_mention_state,
current_index,
input,
questions,
responses,
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((
session_id.clone(),
std::mem::take(questions),
std::mem::take(responses),
))
}
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 build_question_reply_prompt(questions: &[QuestionItem], responses: &[String]) -> String {
let mut lines = vec!["Clarifications:".to_string()];
for (question_index, question) in questions.iter().enumerate() {
let response = responses
.get(question_index)
.map_or(NO_ANSWER, std::string::String::as_str);
lines.push(format!("{}. Q: {}", question_index + 1, question.text));
lines.push(format!(" A: {response}"));
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use crossterm::event::KeyModifiers;
use tempfile::tempdir;
use super::*;
use crate::domain::agent::AgentModel;
use crate::domain::session::Status;
use crate::infra::db::Database;
use crate::ui::state::app_mode::QuestionFocus;
const TEST_TERMINAL_SIZE: Rect = Rect::new(0, 0, 80, 24);
fn test_app_clients() -> crate::app::AppClients {
crate::app::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() -> App {
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");
App::new_with_clients(
base_path.clone(),
base_path,
None,
database,
test_app_clients(),
)
.await
.expect("failed to build app")
}
#[tokio::test]
async fn test_question_view_metrics_uses_default_review_model_for_loading_fallback() {
let mut app = new_test_app().await;
let session_id = "session-review-model";
app.settings.default_review_model = AgentModel::ClaudeHaiku4520251001;
app.sessions.push_session(
crate::domain::session::tests::SessionFixtureBuilder::new()
.id(session_id)
.model(AgentModel::Gpt54)
.status(Status::AgentReview)
.build(),
);
app.mode = AppMode::Question {
at_mention_state: None,
current_index: 0,
focus: QuestionFocus::Answer,
input: InputState::default(),
questions: vec![QuestionItem::new("Need a target branch?")],
responses: Vec::new(),
review_status_message: None,
review_text: None,
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 session = &app.sessions.sessions[0];
let expected = SessionChatPage::rendered_output_line_count(
session,
output_width,
SessionOutputLineContext {
active_prompt_output: None,
active_progress: None,
review_model: AgentModel::ClaudeHaiku4520251001,
review_status_message: None,
review_text: None,
session_update_version: app.session_update_version(session_id),
},
app.markdown_render_cache(),
app.session_output_layout_cache(),
);
let metrics = question_view_metrics(&app, terminal_size);
assert_eq!(metrics.total_lines, expected);
}
#[tokio::test]
async fn test_handle_enter_on_type_custom_answer_with_blank_input_records_no_answer() {
let mut app = new_test_app().await;
app.mode = AppMode::Question {
at_mention_state: None,
review_status_message: None,
review_text: 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: QuestionFocus::Answer,
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 = new_test_app().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,
review_status_message: None,
review_text: 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: QuestionFocus::Answer,
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,
review_status_message: None,
review_text: Some(ref review_text),
..
} if session_id == "session-ctrl-c" && review_text == "Focused review"
));
}
#[tokio::test]
async fn test_handle_escape_no_longer_ends_turn() {
let mut app = new_test_app().await;
app.mode = AppMode::Question {
at_mention_state: None,
review_status_message: None,
review_text: None,
session_id: "session-esc-noop".into(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Q?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::Answer,
input: InputState::default(),
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 { .. }));
}
#[tokio::test]
async fn test_handle_q_returns_to_sessions_list_in_chat_focus() {
let mut app = new_test_app().await;
app.mode = AppMode::Question {
at_mention_state: None,
review_status_message: None,
review_text: None,
session_id: "session-q-chat".into(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Q?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::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 = new_test_app().await;
app.mode = AppMode::Question {
at_mention_state: None,
review_status_message: None,
review_text: 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: QuestionFocus::Answer,
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_inserts_character_in_free_text_answer() {
let mut app = new_test_app().await;
app.mode = AppMode::Question {
at_mention_state: None,
review_status_message: None,
review_text: 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: QuestionFocus::Answer,
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::agent::AgentModel;
use crate::domain::session::{Session, SessionSize, SessionStats};
let mut app = new_test_app().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,
model: AgentModel::Gemini3FlashPreview,
output: String::new(),
project_name: String::new(),
prompt: String::new(),
queued_messages: Vec::new(),
reasoning_level_override: None,
published_upstream_ref: None,
published_branch_sync_status: crate::domain::session::PublishedBranchSyncStatus::Idle,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
stats: SessionStats::default(),
status: Status::Question,
summary: None,
title: None,
updated_at: 0,
workflow_notice: None,
});
app.mode = AppMode::Question {
at_mention_state: None,
review_status_message: None,
review_text: None,
session_id: session_id.into(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Q?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::Answer,
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::agent::AgentModel;
use crate::domain::session::{Session, SessionHandles, SessionSize, SessionStats};
let mut app = new_test_app().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,
model: AgentModel::Gemini3FlashPreview,
output: String::new(),
project_name: String::new(),
prompt: String::new(),
queued_messages: Vec::new(),
reasoning_level_override: None,
published_upstream_ref: None,
published_branch_sync_status: crate::domain::session::PublishedBranchSyncStatus::Idle,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
stats: SessionStats::default(),
status: Status::Question,
summary: None,
title: None,
updated_at: 0,
workflow_notice: None,
});
app.sessions.handles.insert(
session_id.to_string().into(),
SessionHandles::new(String::new(), Status::Question),
);
app.mode = AppMode::Question {
at_mention_state: None,
review_status_message: None,
review_text: None,
session_id: session_id.into(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Q?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::Answer,
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
.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 = new_test_app().await;
let session_id = "session-timer-close";
let project_id = app
.services
.db()
.upsert_project("/tmp/test", None)
.await
.expect("failed to upsert project");
app.services
.db()
.insert_session(
session_id,
"gemini-3-flash-preview",
"main",
"InProgress",
project_id,
)
.await
.expect("failed to insert session");
app.services
.db()
.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,
review_status_message: None,
review_text: None,
session_id: session_id.into(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Q?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::Answer,
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()
.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_transitions_to_view_mode() {
let mut app = new_test_app().await;
app.mode = AppMode::Question {
at_mention_state: None,
review_status_message: None,
review_text: 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: QuestionFocus::Answer,
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::View {
ref session_id,
..
} if session_id == "missing-session"
));
}
#[tokio::test]
async fn test_handle_paste_normalizes_line_endings_in_free_text_mode() {
let mut app = new_test_app().await;
app.mode = AppMode::Question {
at_mention_state: None,
review_status_message: None,
review_text: 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: QuestionFocus::Answer,
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 = new_test_app().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,
review_status_message: None,
review_text: 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: QuestionFocus::Answer,
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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().await;
app.mode = AppMode::Question {
at_mention_state: None,
review_status_message: None,
review_text: 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: QuestionFocus::Answer,
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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().await;
app.mode = AppMode::Question {
at_mention_state: None,
review_status_message: None,
review_text: 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: QuestionFocus::Answer,
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 = new_test_app().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: QuestionFocus::Chat,
..
}
));
}
#[tokio::test]
async fn test_handle_tab_toggles_focus_from_chat_to_answer() {
let mut app = new_test_app().await;
app.mode = question_mode_with_options();
if let AppMode::Question { focus, .. } = &mut app.mode {
*focus = QuestionFocus::Chat;
}
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
focus: QuestionFocus::Answer,
..
}
));
}
#[tokio::test]
async fn test_handle_scroll_down_in_chat_focus_updates_scroll_offset() {
let mut app = new_test_app().await;
app.mode = question_mode_with_options();
if let AppMode::Question {
focus,
scroll_offset,
..
} = &mut app.mode
{
*focus = QuestionFocus::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: QuestionFocus::Chat,
..
}
));
}
#[tokio::test]
async fn test_handle_scroll_keys_ignored_in_answer_focus() {
let mut app = new_test_app().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: QuestionFocus::Answer,
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 = new_test_app().await;
app.mode = question_mode_with_options();
if let AppMode::Question {
focus,
scroll_offset,
..
} = &mut app.mode
{
*focus = QuestionFocus::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: QuestionFocus::Chat,
scroll_offset: Some(0),
..
}
));
}
#[tokio::test]
async fn test_handle_jump_to_bottom_in_chat_focus_sets_offset_none() {
let mut app = new_test_app().await;
app.mode = question_mode_with_options();
if let AppMode::Question {
focus,
scroll_offset,
..
} = &mut app.mode
{
*focus = QuestionFocus::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: QuestionFocus::Chat,
scroll_offset: None,
..
}
));
}
#[tokio::test]
async fn test_handle_enter_in_chat_focus_switches_to_answer_without_submitting() {
let mut app = new_test_app().await;
app.mode = question_mode_with_options();
if let AppMode::Question { focus, .. } = &mut app.mode {
*focus = QuestionFocus::Chat;
}
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
focus: QuestionFocus::Answer,
current_index: 0,
ref responses,
..
} if responses.is_empty()
));
}
#[test]
fn test_build_question_reply_prompt_formats_all_pairs() {
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 message = build_question_reply_prompt(&questions, &responses);
assert_eq!(
message,
"Clarifications:\n1. Q: Need target?\n A: main\n2. Q: Need tests?\n A: no answer"
);
}
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,
review_status_message: None,
review_text: None,
session_id: "session-id".into(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Question?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::Answer,
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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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 = new_test_app().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_alt_backspace_deletes_previous_word() {
let mut app = new_test_app().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 = new_test_app().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 = new_test_app().await;
app.mode = AppMode::Question {
at_mention_state: None,
review_status_message: None,
review_text: 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: QuestionFocus::Answer,
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_escape_in_chat_focus_returns_to_answer_focus() {
let mut app = new_test_app().await;
app.mode = AppMode::Question {
at_mention_state: None,
review_status_message: None,
review_text: None,
session_id: "session-esc-chat".into(),
questions: vec![QuestionItem {
options: vec!["Yes".to_string()],
text: "Continue?".to_string(),
}],
responses: Vec::new(),
current_index: 0,
focus: QuestionFocus::Chat,
input: InputState::default(),
scroll_offset: None,
selected_option_index: Some(0),
};
let _ = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
)
.await;
assert!(matches!(
app.mode,
AppMode::Question {
focus: QuestionFocus::Answer,
ref session_id,
..
} if session_id == "session-esc-chat"
));
}
#[tokio::test]
async fn test_d_key_in_chat_focus_opens_diff_with_question_snapshot() {
use crate::domain::agent::AgentModel;
use crate::domain::session::{Session, SessionSize, SessionStats};
let mut app = new_test_app().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,
model: AgentModel::Gemini3FlashPreview,
output: String::new(),
project_name: String::new(),
prompt: String::new(),
queued_messages: Vec::new(),
reasoning_level_override: None,
published_upstream_ref: None,
published_branch_sync_status: crate::domain::session::PublishedBranchSyncStatus::Idle,
questions: Vec::new(),
review_request: None,
size: SessionSize::Xs,
stats: SessionStats::default(),
status: Status::Question,
summary: None,
title: None,
updated_at: 0,
workflow_notice: None,
});
app.mode = AppMode::Question {
at_mention_state: None,
review_status_message: None,
review_text: 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: QuestionFocus::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_question: Some(_),
..
} if session_id == "session-diff-question"
));
}
}