use std::io;
use crossterm::event::{self, KeyCode, KeyEvent};
use ratatui::Terminal;
use ratatui::backend::Backend;
use tracing::warn;
use crate::app::{App, ReviewCacheEntry, SessionStatsUsage, diff_content_hash};
use crate::domain::agent::{AgentKind, AgentModel, ReasoningLevel};
use crate::domain::input::InputState;
use crate::domain::review;
use crate::domain::session::SessionId;
use crate::domain::transcript_notice::TranscriptNotice;
use crate::infra::channel::{TurnPrompt, TurnPromptAttachment, TurnPromptTextSource};
use crate::runtime::mode::{at_mention, input_key};
use crate::runtime::{EventResult, clipboard_image};
use crate::ui::state::app_mode::AppMode;
use crate::ui::state::prompt::{
PromptAtMentionState, PromptSlashStage, PromptSuggestionSelection,
apply_prompt_delete_range as apply_prompt_delete_range_components,
current_line_delete_range as prompt_current_line_delete_range, drain_prompt_submission,
insert_prompt_character, insert_prompt_local_image, insert_prompt_text,
prompt_slash_option_count, resolve_prompt_slash_selection,
};
use crate::ui::util::{format_token_count, move_input_cursor_down, move_input_cursor_up};
struct PromptContext {
input_mode: PromptInputMode,
scroll_offset: Option<u16>,
session_id: SessionId,
session_index: usize,
session_mode: PromptSessionMode,
}
impl PromptContext {
fn can_delete_on_cancel(&self) -> bool {
self.session_mode == PromptSessionMode::NewDeletable
}
fn is_at_mention(&self) -> bool {
self.input_mode == PromptInputMode::AtMention
}
fn is_draft_session(&self) -> bool {
self.session_mode == PromptSessionMode::NewDraft
}
fn is_new_session(&self) -> bool {
self.session_mode != PromptSessionMode::Existing
}
fn is_slash_command(&self) -> bool {
self.input_mode == PromptInputMode::SlashCommand
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PromptInputMode {
AtMention,
SlashCommand,
Text,
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum PromptSessionMode {
Existing,
NewDeletable,
NewDraft,
NewRegular,
}
pub(crate) async fn handle<B: Backend>(
app: &mut App,
terminal: &mut Terminal<B>,
key: KeyEvent,
) -> io::Result<EventResult>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
let Some(prompt_context) = prompt_context(app) else {
return Ok(EventResult::Continue);
};
if !prompt_context.is_slash_command() {
reset_prompt_slash_state(app);
}
if prompt_context.is_at_mention() && handle_at_mention_key(app, key) {
return Ok(EventResult::Continue);
}
handle_editing_key(app, terminal, key, &prompt_context).await?;
Ok(EventResult::Continue)
}
fn handle_at_mention_key(app: &mut App, key: KeyEvent) -> bool {
match key.code {
KeyCode::Esc => dismiss_at_mention(app),
KeyCode::Enter if !input_key::should_insert_newline(key) => handle_at_mention_select(app),
KeyCode::Tab => handle_at_mention_select(app),
KeyCode::Up => handle_at_mention_up(app),
KeyCode::Down => handle_at_mention_down(app),
_ => return false,
}
true
}
async fn handle_editing_key<B: Backend>(
app: &mut App,
terminal: &mut Terminal<B>,
key: KeyEvent,
prompt_context: &PromptContext,
) -> io::Result<()>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
match key.code {
KeyCode::Enter | KeyCode::Char('\r' | '\n') if input_key::should_insert_newline(key) => {
reset_prompt_history_navigation(app);
if let AppMode::Prompt { input, .. } = &mut app.mode {
input.insert_newline();
}
}
KeyCode::Enter => handle_prompt_submit_key(app, prompt_context).await,
KeyCode::Esc | KeyCode::Char('c') if is_prompt_cancel_key(key) => {
handle_prompt_cancel_key(app, prompt_context).await;
}
KeyCode::Left => handle_prompt_left(app, key),
KeyCode::Right => handle_prompt_right(app, key),
KeyCode::Up => handle_prompt_up_key(app, terminal, prompt_context)?,
KeyCode::Down => handle_prompt_down_key(app, terminal, prompt_context)?,
KeyCode::Char('k') if prompt_context.is_slash_command() && is_plain_char_key(key, 'k') => {
handle_prompt_up_key(app, terminal, prompt_context)?;
}
KeyCode::Char('j') if prompt_context.is_slash_command() && is_plain_char_key(key, 'j') => {
handle_prompt_down_key(app, terminal, prompt_context)?;
}
KeyCode::Home => handle_prompt_input(app, InputState::move_home),
KeyCode::End => handle_prompt_input(app, InputState::move_end),
KeyCode::Backspace => handle_prompt_backspace(app, key),
KeyCode::Delete => handle_prompt_delete(app),
KeyCode::Char(character) if input_key::is_control_newline_key(key, character) => {
reset_prompt_history_navigation(app);
if let AppMode::Prompt { input, .. } = &mut app.mode {
input.insert_newline();
}
}
KeyCode::Char('u') if input_key::is_control_key(key) => handle_prompt_line_delete(app),
KeyCode::Char('v') if is_prompt_image_paste_key(key) => {
handle_prompt_image_paste(app, prompt_context).await;
}
KeyCode::Char('a') if input_key::is_control_key(key) => {
handle_prompt_input(app, InputState::move_line_start);
}
KeyCode::Char('e') if input_key::is_control_key(key) => {
handle_prompt_input(app, InputState::move_line_end);
}
KeyCode::Char('f') if input_key::is_control_key(key) => {
handle_prompt_input(app, InputState::move_right);
}
KeyCode::Char('b') if input_key::is_control_key(key) => {
handle_prompt_input(app, InputState::move_left);
}
KeyCode::Char('p') if input_key::is_control_key(key) => {
handle_prompt_up_key(app, terminal, prompt_context)?;
}
KeyCode::Char('n') if input_key::is_control_key(key) => {
handle_prompt_down_key(app, terminal, prompt_context)?;
}
KeyCode::Char('d') if input_key::is_control_key(key) => handle_prompt_delete(app),
KeyCode::Char('k') if input_key::is_control_key(key) => handle_prompt_kill_to_line_end(app),
KeyCode::Char('w') if input_key::is_control_key(key) => handle_prompt_word_delete(app),
KeyCode::Char('b') if input_key::is_alt_key(key) => {
if let AppMode::Prompt { input, .. } = &mut app.mode {
input_key::move_cursor_word_left(input);
}
sync_prompt_at_mention_state(app);
}
KeyCode::Char('f') if input_key::is_alt_key(key) => {
if let AppMode::Prompt { input, .. } = &mut app.mode {
input_key::move_cursor_word_right(input);
}
sync_prompt_at_mention_state(app);
}
KeyCode::Char(character) => handle_prompt_char(app, character),
_ => {}
}
Ok(())
}
fn handle_prompt_input(app: &mut App, action: fn(&mut InputState)) {
if let AppMode::Prompt { input, .. } = &mut app.mode {
action(input);
}
sync_prompt_at_mention_state(app);
}
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::Prompt {
history_state,
input,
slash_state,
..
} = &mut app.mode
{
insert_prompt_text(input, history_state, slash_state, &normalized_text);
}
sync_prompt_at_mention_state(app);
}
fn prompt_context(app: &mut App) -> Option<PromptContext> {
let (is_at_mention, is_slash_command, scroll_offset, session_id) = match &app.mode {
AppMode::Prompt {
at_mention_state,
input,
scroll_offset,
session_id,
..
} => (
is_active_at_mention(at_mention_state.as_ref(), input),
input.text().starts_with('/'),
*scroll_offset,
session_id.clone(),
),
_ => return None,
};
let Some(session_index) = app.session_index_for_id(&session_id) else {
app.mode = AppMode::List;
return None;
};
let session = app.sessions.sessions.get(session_index);
let session_mode = session.map_or(PromptSessionMode::Existing, |session| {
let is_new_session = session.status == crate::domain::session::Status::Draft;
match (
is_new_session,
session.is_draft_session(),
session.has_staged_drafts(),
) {
(true, true, _) => PromptSessionMode::NewDraft,
(true, false, false) => PromptSessionMode::NewDeletable,
(true, false, true) => PromptSessionMode::NewRegular,
(false, _, _) => PromptSessionMode::Existing,
}
});
let session_is_in_progress =
session.is_some_and(|session| session.status == crate::domain::session::Status::InProgress);
let input_mode = match (is_at_mention, is_slash_command, session_is_in_progress) {
(true, _, _) => PromptInputMode::AtMention,
(false, true, false) => PromptInputMode::SlashCommand,
(false, _, _) => PromptInputMode::Text,
};
Some(PromptContext {
input_mode,
scroll_offset,
session_id,
session_index,
session_mode,
})
}
fn is_active_at_mention(
at_mention_state: Option<&PromptAtMentionState>,
input: &InputState,
) -> bool {
at_mention_state.is_some() && input.at_mention_query().is_some()
}
fn sync_prompt_at_mention_state(app: &mut App) {
let Some(prompt_context) = prompt_context(app) else {
return;
};
let sync_action = match &app.mode {
AppMode::Prompt {
at_mention_state,
input,
..
} => at_mention::sync_action(input, at_mention_state.as_ref()),
_ => return,
};
match sync_action {
at_mention::AtMentionSyncAction::Activate if !prompt_context.is_slash_command() => {
activate_at_mention(app, &prompt_context);
}
at_mention::AtMentionSyncAction::Dismiss => dismiss_at_mention(app),
at_mention::AtMentionSyncAction::KeepOpen => {
if let AppMode::Prompt {
at_mention_state: Some(state),
..
} = &mut app.mode
{
at_mention::reset_selection(state);
}
}
at_mention::AtMentionSyncAction::Activate => {}
}
}
fn reset_prompt_slash_state(app: &mut App) {
if let AppMode::Prompt { slash_state, .. } = &mut app.mode {
slash_state.reset();
}
}
fn reset_prompt_history_navigation(app: &mut App) {
if let AppMode::Prompt { history_state, .. } = &mut app.mode {
history_state.reset_navigation();
}
}
fn is_prompt_cancel_key(key: KeyEvent) -> bool {
key.code == KeyCode::Esc || key.modifiers.contains(event::KeyModifiers::CONTROL)
}
fn is_plain_char_key(key: KeyEvent, character: char) -> bool {
key.code == KeyCode::Char(character) && key.modifiers == event::KeyModifiers::NONE
}
fn is_prompt_image_paste_key(key: KeyEvent) -> bool {
key.code == KeyCode::Char('v')
&& key
.modifiers
.intersects(event::KeyModifiers::ALT | event::KeyModifiers::CONTROL)
}
fn handle_prompt_up_key<B: Backend>(
app: &mut App,
terminal: &Terminal<B>,
prompt_context: &PromptContext,
) -> io::Result<()>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
if prompt_context.is_slash_command() {
if let AppMode::Prompt { slash_state, .. } = &mut app.mode {
slash_state.selected_index = slash_state.selected_index.saturating_sub(1);
}
return Ok(());
}
let input_width = prompt_input_width(terminal)?;
if let AppMode::Prompt { input, .. } = &mut app.mode {
let next_cursor = move_input_cursor_up(input.text(), input_width, input.cursor);
if next_cursor != input.cursor {
input.cursor = next_cursor;
sync_prompt_at_mention_state(app);
return Ok(());
}
}
navigate_prompt_history_up(app);
sync_prompt_at_mention_state(app);
Ok(())
}
fn handle_prompt_down_key<B: Backend>(
app: &mut App,
terminal: &Terminal<B>,
prompt_context: &PromptContext,
) -> io::Result<()>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
if prompt_context.is_slash_command() {
advance_prompt_slash_selection(app);
return Ok(());
}
let input_width = prompt_input_width(terminal)?;
if let AppMode::Prompt { input, .. } = &mut app.mode {
let next_cursor = move_input_cursor_down(input.text(), input_width, input.cursor);
if next_cursor != input.cursor {
input.cursor = next_cursor;
sync_prompt_at_mention_state(app);
return Ok(());
}
}
navigate_prompt_history_down(app);
sync_prompt_at_mention_state(app);
Ok(())
}
fn navigate_prompt_history_up(app: &mut App) {
if let AppMode::Prompt {
history_state,
input,
..
} = &mut app.mode
{
if history_state.entries.is_empty() {
return;
}
let next_index = if let Some(selected_index) = history_state.selected_index {
selected_index.saturating_sub(1)
} else {
history_state.draft_text = Some(input.text().to_string());
history_state.entries.len().saturating_sub(1)
};
history_state.selected_index = Some(next_index);
*input = InputState::with_text(history_state.entries[next_index].clone());
}
}
fn navigate_prompt_history_down(app: &mut App) {
if let AppMode::Prompt {
history_state,
input,
..
} = &mut app.mode
{
let Some(selected_index) = history_state.selected_index else {
return;
};
if selected_index + 1 < history_state.entries.len() {
let next_index = selected_index + 1;
history_state.selected_index = Some(next_index);
*input = InputState::with_text(history_state.entries[next_index].clone());
return;
}
history_state.selected_index = None;
*input = InputState::with_text(history_state.draft_text.take().unwrap_or_default());
}
}
fn advance_prompt_slash_selection(app: &mut App) {
let allow_apply_command = prompt_apply_command_is_available(app);
let (
available_agent_kinds,
input_text,
selected_agent,
selected_index,
session_agent_kind,
stage,
) = match &app.mode {
AppMode::Prompt {
input, slash_state, ..
} => (
slash_state.available_agent_kinds.clone(),
input.text().to_string(),
slash_state.selected_agent,
slash_state.selected_index,
app.selected_session()
.map_or(AgentKind::Codex, |session| session.model.kind()),
slash_state.stage,
),
_ => return,
};
let option_count = prompt_slash_option_count(
&input_text,
stage,
selected_agent,
&available_agent_kinds,
session_agent_kind,
allow_apply_command,
);
if option_count == 0 {
return;
}
if let AppMode::Prompt { slash_state, .. } = &mut app.mode {
let max_index = option_count.saturating_sub(1);
slash_state.selected_index = (selected_index + 1).min(max_index);
}
}
fn session_is_in_progress(app: &App, session_id: &str) -> bool {
app.sessions
.sessions
.iter()
.find(|session| session.id == session_id)
.is_some_and(|session| session.status == crate::domain::session::Status::InProgress)
}
async fn handle_prompt_submit_key(app: &mut App, prompt_context: &PromptContext) {
if prompt_context.is_slash_command() {
handle_prompt_slash_submit(app, prompt_context).await;
return;
}
let prompt = take_submitted_turn_prompt(app);
if prompt.is_empty() {
return;
}
if prompt_context.is_draft_session() {
if let Err(error) = app
.stage_draft_message(&prompt_context.session_id, prompt)
.await
{
append_output_for_session(
app,
&prompt_context.session_id,
&TranscriptNotice::Error.format(error),
)
.await;
}
} else if prompt_context.is_new_session() {
if let Err(error) = app.start_session(&prompt_context.session_id, prompt).await {
append_output_for_session(
app,
&prompt_context.session_id,
&TranscriptNotice::Error.format(error),
)
.await;
}
} else if session_is_in_progress(app, &prompt_context.session_id) {
if let Err(error) = app.enqueue_message(&prompt_context.session_id, prompt) {
append_output_for_session(
app,
&prompt_context.session_id,
&TranscriptNotice::QueueError.format(error),
)
.await;
}
} else {
app.reply(&prompt_context.session_id, prompt).await;
}
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: prompt_context.session_id.clone(),
scroll_offset: None,
};
}
async fn handle_prompt_image_paste(app: &mut App, prompt_context: &PromptContext) {
let attachment_number = match &app.mode {
AppMode::Prompt {
attachment_state, ..
} => attachment_state.next_attachment_number,
_ => return,
};
match clipboard_image::persist_clipboard_image(
&prompt_context.session_id,
attachment_number,
app.services.fs_client().as_ref(),
)
.await
{
Ok(persisted_image) => {
insert_pasted_image_placeholder(app, persisted_image.local_image_path);
}
Err(error) => {
append_prompt_status_line(
app,
&prompt_context.session_id,
"Paste Image Error",
&clipboard_image::normalize_clipboard_image_error(&error),
)
.await;
}
}
}
fn insert_pasted_image_placeholder(app: &mut App, local_image_path: std::path::PathBuf) {
if let AppMode::Prompt {
attachment_state,
history_state,
input,
slash_state,
..
} = &mut app.mode
{
insert_prompt_local_image(
attachment_state,
history_state,
input,
slash_state,
local_image_path,
);
}
sync_prompt_at_mention_state(app);
}
fn take_submitted_turn_prompt(app: &mut App) -> TurnPrompt {
match &mut app.mode {
AppMode::Prompt {
attachment_state,
input,
..
} => {
let submission = drain_prompt_submission(attachment_state, input);
let attachments = submission
.attachments
.into_iter()
.map(|attachment| TurnPromptAttachment {
placeholder: attachment.placeholder,
local_image_path: attachment.local_image_path,
})
.collect();
TurnPrompt {
attachments,
text: submission.text,
text_source: TurnPromptTextSource::UserPrompt,
}
}
_ => TurnPrompt::from_text(String::new()),
}
}
async fn handle_prompt_slash_submit(app: &mut App, prompt_context: &PromptContext) {
let session_agent_kind = app
.session_at(prompt_context.session_index)
.map_or(AgentKind::Codex, |session| session.model.kind());
let selection = match &app.mode {
AppMode::Prompt {
input, slash_state, ..
} => resolve_prompt_slash_selection(
input.text(),
slash_state,
session_agent_kind,
apply_command_has_actionable_review_suggestions(app, prompt_context),
),
_ => None,
};
match selection {
Some(PromptSuggestionSelection::Command("/apply")) => {
reset_prompt_slash_input(app);
handle_apply_command(app, prompt_context).await;
}
Some(PromptSuggestionSelection::Command("/stats")) => {
reset_prompt_slash_input(app);
handle_stats_command(app, prompt_context).await;
}
Some(PromptSuggestionSelection::Command("/reasoning")) => {
let selected_reasoning_level = app
.session_at(prompt_context.session_index)
.map_or(app.settings.reasoning_level, |session| {
session.effective_reasoning_level(app.settings.reasoning_level)
});
let selected_index = ReasoningLevel::ALL
.iter()
.position(|level| *level == selected_reasoning_level)
.unwrap_or(0);
if let AppMode::Prompt { slash_state, .. } = &mut app.mode {
slash_state.stage = PromptSlashStage::Reasoning;
slash_state.selected_agent = None;
slash_state.selected_index = selected_index;
}
}
Some(PromptSuggestionSelection::Command(_)) => {
if let AppMode::Prompt { slash_state, .. } = &mut app.mode {
slash_state.stage = PromptSlashStage::Agent;
slash_state.selected_agent = None;
slash_state.selected_index = 0;
}
}
Some(PromptSuggestionSelection::Agent(selected_agent)) => {
if let AppMode::Prompt { slash_state, .. } = &mut app.mode {
slash_state.selected_agent = Some(selected_agent);
slash_state.stage = PromptSlashStage::Model;
slash_state.selected_index = 0;
}
}
Some(PromptSuggestionSelection::Model(selected_model)) => {
reset_prompt_slash_input(app);
update_prompt_session_model(app, prompt_context, selected_model).await;
}
Some(PromptSuggestionSelection::Reasoning(reasoning_level)) => {
reset_prompt_slash_input(app);
update_prompt_session_reasoning_level(app, prompt_context, reasoning_level).await;
}
None => {}
}
}
fn reset_prompt_slash_input(app: &mut App) {
if let AppMode::Prompt {
input, slash_state, ..
} = &mut app.mode
{
input.take_text();
slash_state.reset();
}
}
async fn update_prompt_session_model(
app: &mut App,
prompt_context: &PromptContext,
selected_model: AgentModel,
) {
if let Err(error) = app
.set_session_model(&prompt_context.session_id, selected_model)
.await
{
warn!(
session_id = %prompt_context.session_id,
model = %selected_model.as_str(),
error = %error,
"failed to switch session model from prompt slash command"
);
}
}
async fn update_prompt_session_reasoning_level(
app: &mut App,
prompt_context: &PromptContext,
reasoning_level: ReasoningLevel,
) {
if let Err(error) = app
.set_session_reasoning_level(&prompt_context.session_id, Some(reasoning_level))
.await
{
warn!(
session_id = %prompt_context.session_id,
reasoning_level = ?reasoning_level,
error = %error,
"failed to update session reasoning level from prompt slash command"
);
}
}
async fn handle_prompt_cancel_key(app: &mut App, prompt_context: &PromptContext) {
if prompt_context.is_slash_command() {
if let AppMode::Prompt {
input, slash_state, ..
} = &mut app.mode
{
input.take_text();
slash_state.reset();
}
return;
}
cleanup_prompt_attachment_state(app).await;
if prompt_context.can_delete_on_cancel() {
app.delete_selected_session_deferred_cleanup().await;
app.mode = AppMode::List;
return;
}
app.mode = AppMode::View {
review_status_message: prompt_review_status_message(app),
review_text: prompt_review_text(app),
session_id: prompt_context.session_id.clone(),
scroll_offset: prompt_context.scroll_offset,
};
}
fn prompt_review_status_message(app: &App) -> Option<String> {
match &app.mode {
AppMode::Prompt {
review_status_message,
..
} => review_status_message.clone(),
_ => None,
}
}
fn prompt_review_text(app: &App) -> Option<String> {
match &app.mode {
AppMode::Prompt { review_text, .. } => review_text.clone(),
_ => None,
}
}
fn clear_prompt_review_state(app: &mut App) {
if let AppMode::Prompt {
review_status_message,
review_text,
..
} = &mut app.mode
{
*review_status_message = None;
*review_text = None;
}
}
async fn append_output_for_session(app: &App, session_id: &str, output: &str) {
app.append_output_for_session(session_id, output).await;
}
fn apply_command_has_actionable_review_suggestions(
app: &App,
prompt_context: &PromptContext,
) -> bool {
review_cache_has_actionable_suggestions(app, &prompt_context.session_id)
}
fn prompt_apply_command_is_available(app: &App) -> bool {
let Some(session_id) = prompt_session_id(app) else {
return false;
};
review_cache_has_actionable_suggestions(app, &session_id)
}
fn prompt_session_id(app: &App) -> Option<SessionId> {
match &app.mode {
AppMode::Prompt { session_id, .. } => Some(session_id.clone()),
_ => None,
}
}
fn review_cache_has_actionable_suggestions(app: &App, session_id: &str) -> bool {
let Some(ReviewCacheEntry::Ready { text, .. }) = app.review_cache.get(session_id) else {
return false;
};
review::has_actionable_review_suggestions(Some(text))
}
async fn append_prompt_status_line(app: &App, session_id: &str, label: &str, message: &str) {
append_output_for_session(app, session_id, &format!("\n[{label}] {message}\n")).await;
}
async fn cleanup_prompt_attachment_state(app: &mut App) {
let prompt = match &mut app.mode {
AppMode::Prompt {
attachment_state, ..
} => {
let attachments = attachment_state
.attachments
.iter()
.map(|attachment| TurnPromptAttachment {
placeholder: attachment.placeholder.clone(),
local_image_path: attachment.local_image_path.clone(),
})
.collect::<Vec<_>>();
attachment_state.reset();
TurnPrompt {
attachments,
text: String::new(),
text_source: TurnPromptTextSource::UserPrompt,
}
}
_ => return,
};
app.cleanup_prompt_attachment_files(&prompt).await;
}
async fn handle_apply_command(app: &mut App, prompt_context: &PromptContext) {
let Some((session_status, session_folder, base_branch)) =
app.session_at(prompt_context.session_index).map(|session| {
(
session.status,
session.folder.clone(),
session.base_branch.clone(),
)
})
else {
return;
};
if session_status != crate::domain::session::Status::Review {
append_prompt_status_line(
app,
&prompt_context.session_id,
"Apply",
"Apply is only available after a focused review completes (session status must be \
Review).",
)
.await;
return;
}
let (cached_hash, cached_text) = if let Some(ReviewCacheEntry::Ready { diff_hash, text }) =
app.review_cache.get(prompt_context.session_id.as_str())
{
(*diff_hash, text.clone())
} else {
append_prompt_status_line(
app,
&prompt_context.session_id,
"Apply",
"No actionable suggestions available. Run a focused review first (f key).",
)
.await;
return;
};
let current_diff = match app
.services
.git_client()
.diff(session_folder, base_branch)
.await
{
Ok(diff) => diff,
Err(err) => {
append_prompt_status_line(
app,
&prompt_context.session_id,
"Apply",
&format!(
"Failed to read worktree diff: {err}. Review cache preserved; try /apply \
again."
),
)
.await;
return;
}
};
let current_hash = diff_content_hash(¤t_diff);
if current_hash != cached_hash {
app.review_cache.remove(prompt_context.session_id.as_str());
clear_prompt_review_state(app);
append_prompt_status_line(
app,
&prompt_context.session_id,
"Apply",
"Review is stale; the worktree changed since it was generated. Run focused review \
again (f key).",
)
.await;
return;
}
let Some(suggestions) = review::review_suggestions(&cached_text) else {
append_prompt_status_line(
app,
&prompt_context.session_id,
"Apply",
"No actionable suggestions found in the current review.",
)
.await;
return;
};
let prompt = build_apply_review_prompt(&suggestions);
cleanup_prompt_attachment_state(app).await;
app.review_cache.remove(prompt_context.session_id.as_str());
app.reply(&prompt_context.session_id, prompt).await;
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: prompt_context.session_id.clone(),
scroll_offset: None,
};
}
fn build_apply_review_prompt(suggestions: &str) -> TurnPrompt {
TurnPrompt::from_text(format!(
"Verify the following focused-review suggestions against the current code before changing \
anything. Apply only the suggestions that are still correct and relevant; explain any \
suggestions you leave unapplied.\n\n{suggestions}"
))
}
async fn handle_stats_command(app: &App, prompt_context: &PromptContext) {
let session_stats = app.stats_for_session(&prompt_context.session_id).await;
let session_time = session_stats
.session_duration_seconds
.map_or_else(|| "Unavailable".to_string(), format_duration);
let usage_rows_result = build_token_usage_rows(session_stats.usage_rows_result);
let stats_output =
build_stats_markdown(&prompt_context.session_id, &session_time, usage_rows_result);
append_output_for_session(app, &prompt_context.session_id, &stats_output).await;
}
struct TokenUsageRow {
in_tokens: String,
model: String,
out_tokens: String,
}
fn build_token_usage_rows(
usage_rows_result: Result<Vec<SessionStatsUsage>, String>,
) -> Result<Vec<TokenUsageRow>, String> {
match usage_rows_result {
Ok(usage_rows) => {
let rows = usage_rows
.into_iter()
.map(|row| TokenUsageRow {
in_tokens: format_token_count(row.input_tokens),
model: row.model,
out_tokens: format_token_count(row.output_tokens),
})
.collect();
Ok(rows)
}
Err(error) => Err(error),
}
}
fn build_stats_markdown(
session_id: &str,
session_time: &str,
usage_rows_result: Result<Vec<TokenUsageRow>, String>,
) -> String {
let mut lines = vec![
format_stats_metric_line("Session ID", session_id),
format_stats_metric_line("Session Time", session_time),
String::new(),
"Tokens Usage".to_string(),
];
lines.extend(build_token_usage_lines(usage_rows_result));
format!(
"\n## Session Stats\n\n```stats\n{}\n```\n",
lines.join("\n")
)
}
fn format_stats_metric_line(metric: &str, value: &str) -> String {
format!("{metric}\t{value}")
}
fn build_token_usage_lines(usage_rows_result: Result<Vec<TokenUsageRow>, String>) -> Vec<String> {
match usage_rows_result {
Ok(usage_rows) if usage_rows.is_empty() => vec!["No token usage recorded.".to_string()],
Ok(usage_rows) => render_token_usage_table_lines(&usage_rows),
Err(error) => vec![
"Usage unavailable.".to_string(),
format_stats_metric_line("Error", &error),
],
}
}
fn render_token_usage_table_lines(usage_rows: &[TokenUsageRow]) -> Vec<String> {
let model_width = usage_rows
.iter()
.map(|row| row.model.chars().count())
.max()
.unwrap_or_default()
.max("Model".chars().count());
let in_width = usage_rows
.iter()
.map(|row| row.in_tokens.chars().count())
.max()
.unwrap_or_default()
.max("In".chars().count());
let out_width = usage_rows
.iter()
.map(|row| row.out_tokens.chars().count())
.max()
.unwrap_or_default()
.max("Out".chars().count());
let mut lines = vec![format!(
"{:<model_width$} {:>in_width$} {:>out_width$}",
"Model", "In", "Out"
)];
lines.extend(usage_rows.iter().map(|row| {
format!(
"{:<model_width$} {:>in_width$} {:>out_width$}",
row.model, row.in_tokens, row.out_tokens
)
}));
lines
}
fn format_duration(total_seconds: i64) -> String {
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
format!("{hours:02}:{minutes:02}:{seconds:02}")
}
fn prompt_input_width<B: Backend>(terminal: &Terminal<B>) -> io::Result<u16>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
let terminal_width = terminal.size().map_err(crate::runtime::backend_err)?.width;
Ok(terminal_width.saturating_sub(2))
}
fn handle_prompt_left(app: &mut App, key: KeyEvent) {
if let AppMode::Prompt { input, .. } = &mut app.mode {
if key.modifiers.contains(event::KeyModifiers::SUPER) {
input.move_line_start();
} else if key
.modifiers
.intersects(event::KeyModifiers::ALT | event::KeyModifiers::SHIFT)
{
input_key::move_cursor_word_left(input);
} else {
input.move_left();
}
}
sync_prompt_at_mention_state(app);
}
fn handle_prompt_right(app: &mut App, key: KeyEvent) {
if let AppMode::Prompt { input, .. } = &mut app.mode {
if key.modifiers.contains(event::KeyModifiers::SUPER) {
input.move_line_end();
} else if key
.modifiers
.intersects(event::KeyModifiers::ALT | event::KeyModifiers::SHIFT)
{
input_key::move_cursor_word_right(input);
} else {
input.move_right();
}
}
sync_prompt_at_mention_state(app);
}
fn handle_prompt_line_delete(app: &mut App) {
if let AppMode::Prompt { input, .. } = &app.mode
&& let Some((start, end)) = prompt_current_line_delete_range(input)
{
apply_prompt_delete_range(app, start, end);
}
}
fn handle_prompt_kill_to_line_end(app: &mut App) {
if let AppMode::Prompt { input, .. } = &mut app.mode {
input.delete_to_line_end();
}
}
fn handle_prompt_word_delete(app: &mut App) {
if let AppMode::Prompt { input, .. } = &app.mode
&& let Some((start, end)) = input_key::word_delete_range(input.text(), input.cursor)
{
apply_prompt_delete_range(app, start, end);
}
}
fn handle_prompt_backspace(app: &mut App, key: KeyEvent) {
let Some(delete_range) = prompt_backspace_range(app, key) else {
return;
};
apply_prompt_delete_range(app, delete_range.0, delete_range.1);
}
fn prompt_backspace_range(app: &App, key: KeyEvent) -> Option<(usize, usize)> {
let AppMode::Prompt { input, .. } = &app.mode else {
return None;
};
if input_key::is_line_delete_backspace(key) {
return prompt_current_line_delete_range(input);
}
if input_key::is_word_delete_backspace(key) {
return input_key::word_delete_range(input.text(), input.cursor);
}
if input.cursor == 0 {
return None;
}
Some((input.cursor - 1, input.cursor))
}
fn handle_prompt_delete(app: &mut App) {
let Some(delete_range) = prompt_delete_range(app) else {
return;
};
apply_prompt_delete_range(app, delete_range.0, delete_range.1);
}
fn prompt_delete_range(app: &App) -> Option<(usize, usize)> {
let AppMode::Prompt { input, .. } = &app.mode else {
return None;
};
let char_count = input.text().chars().count();
if input.cursor >= char_count {
return None;
}
Some((input.cursor, input.cursor + 1))
}
fn apply_prompt_delete_range(app: &mut App, start: usize, end: usize) {
if let AppMode::Prompt {
attachment_state,
history_state,
input,
slash_state,
..
} = &mut app.mode
{
apply_prompt_delete_range_components(
attachment_state,
history_state,
input,
slash_state,
start,
end,
);
}
sync_prompt_at_mention_state(app);
}
fn handle_prompt_char(app: &mut App, character: char) {
if let AppMode::Prompt {
input,
history_state,
slash_state,
..
} = &mut app.mode
{
insert_prompt_character(input, history_state, slash_state, character);
}
sync_prompt_at_mention_state(app);
}
fn activate_at_mention(app: &mut App, prompt_context: &PromptContext) {
let lookup_root = app
.sessions
.sessions
.get(prompt_context.session_index)
.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 session_id = prompt_context.session_id.clone();
let event_tx = app.services.event_sender();
at_mention::start_loading_entries(event_tx, lookup_root, session_id, &mut app.sessions);
if let AppMode::Prompt {
at_mention_state, ..
} = &mut app.mode
{
*at_mention_state = Some(PromptAtMentionState::new(Vec::new()));
}
}
fn dismiss_at_mention(app: &mut App) {
if let AppMode::Prompt {
at_mention_state, ..
} = &mut app.mode
{
at_mention::dismiss(at_mention_state);
}
}
fn handle_at_mention_up(app: &mut App) {
if let AppMode::Prompt {
at_mention_state: Some(state),
..
} = &mut app.mode
{
at_mention::move_selection_up(state);
}
}
fn handle_at_mention_down(app: &mut App) {
if let AppMode::Prompt {
at_mention_state: Some(state),
input,
..
} = &mut app.mode
{
at_mention::move_selection_down(input, state);
}
}
fn handle_at_mention_select(app: &mut App) {
let replacement = match &app.mode {
AppMode::Prompt {
at_mention_state: Some(state),
input,
..
} => at_mention::selected_replacement(input, state),
_ => return,
};
if replacement.is_none() {
dismiss_at_mention(app);
return;
}
if let Some(selection) = replacement
&& let AppMode::Prompt { input, .. } = &mut app.mode
{
input.replace_range(selection.at_start, selection.cursor, &selection.text);
}
dismiss_at_mention(app);
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::process::Command;
use tempfile::tempdir;
use super::*;
use crate::infra::db::Database;
use crate::infra::file_index::FileEntry;
use crate::ui::state::prompt::{
PromptAtMentionState, PromptAttachmentState, PromptHistoryState, PromptSlashState,
};
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(),
},
))
}
fn install_mock_git_client(app: &mut App, mock_git_client: crate::infra::git::MockGitClient) {
let mock_git_client: std::sync::Arc<dyn crate::infra::git::GitClient> =
std::sync::Arc::new(mock_git_client);
let base_path = app.services.base_path().to_path_buf();
let db = app.services.db().clone();
let event_sender = app.services.event_sender();
let available_agent_kinds = app.services.available_agent_kinds();
let app_server_client_override = app.services.app_server_client_override();
let fs_client = app.services.fs_client();
let review_request_client = app.services.review_request_client();
app.services = crate::app::AppServices::new(
base_path,
app.services.clock(),
event_sender,
crate::app::AppServiceDeps {
app_server_client_override,
available_agent_kinds,
fs_client,
git_client: mock_git_client,
repositories: db,
review_request_client,
},
);
}
fn setup_test_git_repo(path: &Path) {
Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.expect("git init failed");
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()
.expect("git config failed");
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()
.expect("git config failed");
std::fs::write(path.join("README.md"), "test").expect("write failed");
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()
.expect("git add failed");
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(path)
.output()
.expect("git commit failed");
Command::new("git")
.args(["branch", "-M", "main"])
.current_dir(path)
.output()
.expect("git branch failed");
}
async fn new_test_prompt_app(
input_text: &str,
at_mention_state: Option<PromptAtMentionState>,
) -> (App, tempfile::TempDir) {
new_test_prompt_app_with_session_mode(input_text, at_mention_state, false).await
}
async fn new_test_prompt_app_with_session_mode(
input_text: &str,
at_mention_state: Option<PromptAtMentionState>,
is_draft_session: bool,
) -> (App, tempfile::TempDir) {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
setup_test_git_repo(base_dir.path());
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
let mut app = App::new_with_clients(
base_path.clone(),
base_path,
Some("main".to_string()),
database,
test_app_clients(),
)
.await
.expect("failed to build app");
let session_id = if is_draft_session {
app.create_draft_session()
.await
.expect("failed to create draft session")
} else {
app.create_session()
.await
.expect("failed to create session")
};
app.mode = AppMode::Prompt {
at_mention_state,
attachment_state: PromptAttachmentState::default(),
history_state: PromptHistoryState::new(Vec::new()),
review_status_message: None,
review_text: None,
slash_state: PromptSlashState::default(),
session_id: session_id.into(),
input: InputState::with_text(input_text.to_string()),
scroll_offset: None,
};
(app, base_dir)
}
async fn new_test_draft_prompt_app(
input_text: &str,
at_mention_state: Option<PromptAtMentionState>,
) -> (App, tempfile::TempDir) {
new_test_prompt_app_with_session_mode(input_text, at_mention_state, true).await
}
async fn wait_for_at_mention_entries_event(app: &mut App) -> crate::app::AppEvent {
let timeout = std::time::Duration::from_secs(1);
let deadline = tokio::time::Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
let next_event = tokio::time::timeout(remaining, app.next_app_event())
.await
.expect("at-mention event should arrive")
.expect("at-mention event channel closed unexpectedly");
if matches!(
next_event,
crate::app::AppEvent::AtMentionEntriesLoaded { .. }
) {
return next_event;
}
}
}
#[test]
fn test_is_plain_char_key_for_plain_character() {
let key = KeyEvent::new(KeyCode::Char('j'), event::KeyModifiers::NONE);
let result = is_plain_char_key(key, 'j');
assert!(result);
}
#[test]
fn test_is_plain_char_key_rejects_modifier_keys() {
let key = KeyEvent::new(KeyCode::Char('k'), event::KeyModifiers::SHIFT);
let result = is_plain_char_key(key, 'k');
assert!(!result);
}
#[test]
fn test_is_plain_char_key_rejects_other_character() {
let key = KeyEvent::new(KeyCode::Char('j'), event::KeyModifiers::NONE);
let result = is_plain_char_key(key, 'k');
assert!(!result);
}
#[test]
fn test_is_prompt_image_paste_key_accepts_alt_v() {
let key = KeyEvent::new(KeyCode::Char('v'), event::KeyModifiers::ALT);
let result = is_prompt_image_paste_key(key);
assert!(result);
}
#[test]
fn test_is_prompt_image_paste_key_accepts_ctrl_v() {
let key = KeyEvent::new(KeyCode::Char('v'), event::KeyModifiers::CONTROL);
let result = is_prompt_image_paste_key(key);
assert!(result);
}
#[test]
fn test_is_prompt_image_paste_key_rejects_plain_v() {
let key = KeyEvent::new(KeyCode::Char('v'), event::KeyModifiers::NONE);
let result = is_prompt_image_paste_key(key);
assert!(!result);
}
#[tokio::test]
async fn test_handle_paste_inserts_multiline_content_with_normalized_newlines() {
let (mut app, _base_dir) = new_test_prompt_app("prefix ", None).await;
handle_paste(&mut app, "line 1\r\nline 2\rline 3");
if let AppMode::Prompt { input, .. } = &app.mode {
assert_eq!(input.text(), "prefix line 1\nline 2\nline 3");
assert_eq!(
input.cursor,
"prefix line 1\nline 2\nline 3".chars().count()
);
}
}
#[tokio::test]
async fn test_insert_pasted_image_placeholder_records_attachment_and_resets_prompt_state() {
let mut at_mention_state = PromptAtMentionState::new(vec![FileEntry {
is_dir: false,
path: "src/main.rs".to_string(),
}]);
at_mention_state.selected_index = 4;
let (mut app, _base_dir) = new_test_prompt_app("Review ", Some(at_mention_state)).await;
if let AppMode::Prompt {
history_state,
slash_state,
..
} = &mut app.mode
{
history_state.selected_index = Some(0);
history_state.draft_text = Some("draft".to_string());
slash_state.selected_index = 2;
}
insert_pasted_image_placeholder(&mut app, std::path::PathBuf::from("/tmp/image-1.png"));
if let AppMode::Prompt {
at_mention_state,
attachment_state,
history_state,
input,
slash_state,
..
} = &app.mode
{
assert_eq!(input.text(), "Review [Image #1]");
assert_eq!(attachment_state.attachments.len(), 1);
assert_eq!(
attachment_state.attachments[0].local_image_path,
std::path::PathBuf::from("/tmp/image-1.png")
);
assert_eq!(history_state.selected_index, None);
assert_eq!(history_state.draft_text, None);
assert_eq!(*slash_state, PromptSlashState::default());
assert!(at_mention_state.is_none());
}
}
#[tokio::test]
async fn test_take_submitted_turn_prompt_drains_text_and_attachment_state() {
let (mut app, _base_dir) = new_test_prompt_app("Review ", None).await;
insert_pasted_image_placeholder(&mut app, std::path::PathBuf::from("/tmp/image-1.png"));
let prompt = take_submitted_turn_prompt(&mut app);
assert_eq!(prompt.text, "Review [Image #1]");
assert_eq!(prompt.attachments.len(), 1);
assert_eq!(prompt.attachments[0].placeholder, "[Image #1]");
assert_eq!(
prompt.attachments[0].local_image_path,
std::path::PathBuf::from("/tmp/image-1.png")
);
if let AppMode::Prompt {
attachment_state,
input,
..
} = &app.mode
{
assert!(input.text().is_empty());
assert!(attachment_state.attachments.is_empty());
assert_eq!(attachment_state.next_attachment_number, 1);
}
}
#[tokio::test]
async fn test_take_submitted_turn_prompt_filters_deleted_attachment_placeholders() {
let (mut app, _base_dir) = new_test_prompt_app("Review ", None).await;
insert_pasted_image_placeholder(&mut app, std::path::PathBuf::from("/tmp/image-1.png"));
insert_pasted_image_placeholder(&mut app, std::path::PathBuf::from("/tmp/image-2.png"));
if let AppMode::Prompt { input, .. } = &mut app.mode {
*input = InputState::with_text("Review [Image #2]".to_string());
}
let prompt = take_submitted_turn_prompt(&mut app);
assert_eq!(prompt.text, "Review [Image #2]");
assert_eq!(prompt.attachments.len(), 1);
assert_eq!(prompt.attachments[0].placeholder, "[Image #2]");
assert_eq!(
prompt.attachments[0].local_image_path,
std::path::PathBuf::from("/tmp/image-2.png")
);
if let AppMode::Prompt {
attachment_state,
input,
..
} = &app.mode
{
assert!(input.text().is_empty());
assert!(attachment_state.attachments.is_empty());
assert_eq!(attachment_state.next_attachment_number, 1);
}
}
#[tokio::test]
async fn test_take_submitted_turn_prompt_sorts_attachments_by_text_position() {
let (mut app, _base_dir) = new_test_prompt_app("", None).await;
insert_pasted_image_placeholder(&mut app, std::path::PathBuf::from("/tmp/image-1.png"));
insert_pasted_image_placeholder(&mut app, std::path::PathBuf::from("/tmp/image-2.png"));
if let AppMode::Prompt { input, .. } = &mut app.mode {
*input = InputState::with_text("[Image #2] then [Image #1]".to_string());
}
let prompt = take_submitted_turn_prompt(&mut app);
assert_eq!(prompt.attachments.len(), 2);
assert_eq!(prompt.attachments[0].placeholder, "[Image #2]");
assert_eq!(prompt.attachments[1].placeholder, "[Image #1]");
}
#[test]
fn test_prompt_slash_commands_match_model() {
let suggestion_list = crate::ui::state::prompt::build_prompt_slash_suggestion_list(
"/m",
&PromptSlashState::default(),
AgentKind::Codex,
true,
)
.expect("expected suggestion list");
let commands = suggestion_list
.items
.into_iter()
.map(|item| item.label)
.collect::<Vec<_>>();
assert_eq!(commands, vec!["/model"]);
}
#[test]
fn test_prompt_slash_commands_lists_all_commands() {
let suggestion_list = crate::ui::state::prompt::build_prompt_slash_suggestion_list(
"/",
&PromptSlashState::default(),
AgentKind::Codex,
true,
)
.expect("expected suggestion list");
let commands = suggestion_list
.items
.into_iter()
.map(|item| item.label)
.collect::<Vec<_>>();
assert_eq!(commands, vec!["/apply", "/model", "/reasoning", "/stats"]);
}
#[test]
fn test_prompt_slash_commands_match_stats() {
let suggestion_list = crate::ui::state::prompt::build_prompt_slash_suggestion_list(
"/s",
&PromptSlashState::default(),
AgentKind::Codex,
true,
)
.expect("expected suggestion list");
let commands = suggestion_list
.items
.into_iter()
.map(|item| item.label)
.collect::<Vec<_>>();
assert_eq!(commands, vec!["/stats"]);
}
#[test]
fn test_prompt_slash_commands_no_match() {
let commands = crate::ui::state::prompt::build_prompt_slash_suggestion_list(
"/x",
&PromptSlashState::default(),
AgentKind::Codex,
true,
);
assert!(commands.is_none());
}
#[test]
fn test_prompt_slash_option_count_for_agent_stage() {
let count = prompt_slash_option_count(
"/model",
PromptSlashStage::Agent,
None,
AgentKind::ALL,
AgentKind::Codex,
true,
);
assert_eq!(count, AgentKind::ALL.len());
}
#[test]
fn test_prompt_slash_option_count_for_model_stage() {
let count = prompt_slash_option_count(
"/model",
PromptSlashStage::Model,
Some(AgentKind::Claude),
AgentKind::ALL,
AgentKind::Codex,
true,
);
assert_eq!(count, AgentKind::Claude.models().len());
}
#[test]
fn test_prompt_slash_option_count_for_agent_stage_uses_available_agent_kinds() {
let available_agent_kinds = [AgentKind::Codex];
let count = prompt_slash_option_count(
"/model",
PromptSlashStage::Agent,
None,
&available_agent_kinds,
AgentKind::Codex,
true,
);
assert_eq!(count, 1);
}
#[tokio::test]
async fn test_navigate_prompt_history_up_stays_on_first_entry() {
let (mut app, _base_dir) = new_test_prompt_app("draft", None).await;
if let AppMode::Prompt {
history_state,
input,
..
} = &mut app.mode
{
history_state.entries = vec!["first".to_string(), "second".to_string()];
history_state.selected_index = Some(0);
*input = InputState::with_text("first".to_string());
}
navigate_prompt_history_up(&mut app);
if let AppMode::Prompt {
history_state,
input,
..
} = &app.mode
{
assert_eq!(input.text(), "first");
assert_eq!(history_state.selected_index, Some(0));
assert_eq!(history_state.draft_text, None);
}
}
#[tokio::test]
async fn test_navigate_prompt_history_up_selects_latest_entry_and_saves_draft() {
let (mut app, _base_dir) = new_test_prompt_app("draft", None).await;
if let AppMode::Prompt { history_state, .. } = &mut app.mode {
history_state.entries = vec!["first".to_string(), "second".to_string()];
}
navigate_prompt_history_up(&mut app);
if let AppMode::Prompt {
history_state,
input,
..
} = &app.mode
{
assert_eq!(input.text(), "second");
assert_eq!(history_state.selected_index, Some(1));
assert_eq!(history_state.draft_text.as_deref(), Some("draft"));
}
}
#[tokio::test]
async fn test_navigate_prompt_history_down_restores_draft_after_latest_entry() {
let (mut app, _base_dir) = new_test_prompt_app("draft", None).await;
if let AppMode::Prompt { history_state, .. } = &mut app.mode {
history_state.entries = vec!["first".to_string(), "second".to_string()];
}
navigate_prompt_history_up(&mut app);
navigate_prompt_history_down(&mut app);
if let AppMode::Prompt {
history_state,
input,
..
} = &app.mode
{
assert_eq!(input.text(), "draft");
assert_eq!(history_state.selected_index, None);
assert_eq!(history_state.draft_text, None);
}
}
#[tokio::test]
async fn test_advance_prompt_slash_selection_stays_on_last_agent() {
let (mut app, _base_dir) = new_test_prompt_app("/model", None).await;
if let AppMode::Prompt { slash_state, .. } = &mut app.mode {
slash_state.stage = PromptSlashStage::Agent;
slash_state.selected_index = AgentKind::ALL.len().saturating_sub(1);
}
advance_prompt_slash_selection(&mut app);
if let AppMode::Prompt { slash_state, .. } = &app.mode {
assert_eq!(slash_state.stage, PromptSlashStage::Agent);
assert_eq!(
slash_state.selected_index,
AgentKind::ALL.len().saturating_sub(1)
);
}
}
#[tokio::test]
async fn test_advance_prompt_slash_selection_ignores_empty_command_matches() {
let (mut app, _base_dir) = new_test_prompt_app("/x", None).await;
if let AppMode::Prompt { slash_state, .. } = &mut app.mode {
slash_state.selected_index = 2;
}
advance_prompt_slash_selection(&mut app);
if let AppMode::Prompt { slash_state, .. } = &app.mode {
assert_eq!(slash_state.selected_index, 2);
}
}
#[tokio::test]
async fn test_handle_prompt_slash_submit_advances_model_command_to_agent_stage() {
let (mut app, _base_dir) = new_test_prompt_app("/model", None).await;
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_slash_submit(&mut app, &prompt_context).await;
if let AppMode::Prompt {
input, slash_state, ..
} = &app.mode
{
assert_eq!(input.text(), "/model");
assert_eq!(slash_state.stage, PromptSlashStage::Agent);
assert_eq!(slash_state.selected_agent, None);
assert_eq!(slash_state.selected_index, 0);
}
}
#[tokio::test]
async fn test_handle_prompt_slash_submit_maps_filtered_first_command_to_model() {
let (mut app, _base_dir) = new_test_prompt_app("/", None).await;
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_slash_submit(&mut app, &prompt_context).await;
if let AppMode::Prompt { slash_state, .. } = &app.mode {
assert_eq!(slash_state.stage, PromptSlashStage::Agent);
assert_eq!(slash_state.selected_agent, None);
assert_eq!(slash_state.selected_index, 0);
}
}
#[tokio::test]
async fn test_handle_prompt_slash_submit_selects_agent_and_advances_to_model_stage() {
let (mut app, _base_dir) = new_test_prompt_app("/model", None).await;
let selected_index = AgentKind::ALL
.iter()
.position(|agent_kind| *agent_kind == AgentKind::Claude)
.expect("expected Claude agent");
if let AppMode::Prompt { slash_state, .. } = &mut app.mode {
slash_state.stage = PromptSlashStage::Agent;
slash_state.selected_index = selected_index;
}
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_slash_submit(&mut app, &prompt_context).await;
if let AppMode::Prompt { slash_state, .. } = &app.mode {
assert_eq!(slash_state.stage, PromptSlashStage::Model);
assert_eq!(slash_state.selected_agent, Some(AgentKind::Claude));
assert_eq!(slash_state.selected_index, 0);
}
}
#[tokio::test]
async fn test_handle_prompt_slash_submit_sets_selected_model_and_resets_input() {
let (mut app, _base_dir) = new_test_prompt_app("/model", None).await;
let expected_model = AgentKind::Claude.models()[0];
if let AppMode::Prompt { slash_state, .. } = &mut app.mode {
slash_state.stage = PromptSlashStage::Model;
slash_state.selected_agent = Some(AgentKind::Claude);
slash_state.selected_index = 0;
}
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_slash_submit(&mut app, &prompt_context).await;
app.process_pending_app_events().await;
if let AppMode::Prompt {
input, slash_state, ..
} = &app.mode
{
assert_eq!(input.text(), "");
assert_eq!(*slash_state, PromptSlashState::default());
}
assert_eq!(app.sessions.sessions[0].model, expected_model);
}
#[tokio::test]
async fn test_handle_prompt_slash_submit_runs_stats_command_and_resets_slash_state() {
let (mut app, _base_dir) = new_test_prompt_app("/stats", None).await;
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_slash_submit(&mut app, &prompt_context).await;
app.sessions.sync_from_handles();
if let AppMode::Prompt {
input, slash_state, ..
} = &app.mode
{
assert_eq!(input.text(), "");
assert_eq!(*slash_state, PromptSlashState::default());
}
assert!(app.sessions.sessions[0].output.contains("## Session Stats"));
}
#[tokio::test]
async fn test_handle_prompt_slash_submit_prefills_reasoning_selection_from_default_setting() {
let (mut app, _base_dir) = new_test_prompt_app("/reasoning", None).await;
app.settings.reasoning_level = ReasoningLevel::Medium;
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_slash_submit(&mut app, &prompt_context).await;
if let AppMode::Prompt { slash_state, .. } = &app.mode {
assert_eq!(slash_state.stage, PromptSlashStage::Reasoning);
assert_eq!(slash_state.selected_agent, None);
assert_eq!(slash_state.selected_index, 1);
}
}
#[tokio::test]
async fn test_handle_prompt_slash_submit_prefills_reasoning_selection_from_session_override() {
let (mut app, _base_dir) = new_test_prompt_app("/reasoning", None).await;
app.sessions.sessions[0].reasoning_level_override = Some(ReasoningLevel::High);
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_slash_submit(&mut app, &prompt_context).await;
if let AppMode::Prompt { slash_state, .. } = &app.mode {
assert_eq!(slash_state.stage, PromptSlashStage::Reasoning);
assert_eq!(slash_state.selected_agent, None);
assert_eq!(slash_state.selected_index, 2);
}
}
#[tokio::test]
async fn test_handle_prompt_slash_submit_clamps_stale_command_selection() {
let (mut app, _base_dir) = new_test_prompt_app("/stats", None).await;
if let AppMode::Prompt { slash_state, .. } = &mut app.mode {
slash_state.selected_index = 99;
}
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_slash_submit(&mut app, &prompt_context).await;
app.sessions.sync_from_handles();
if let AppMode::Prompt {
input, slash_state, ..
} = &app.mode
{
assert_eq!(input.text(), "");
assert_eq!(*slash_state, PromptSlashState::default());
}
assert!(app.sessions.sessions[0].output.contains("## Session Stats"));
}
#[tokio::test]
async fn test_handle_prompt_slash_submit_ignores_unknown_command() {
let (mut app, _base_dir) = new_test_prompt_app("/x", None).await;
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_slash_submit(&mut app, &prompt_context).await;
if let AppMode::Prompt {
input, slash_state, ..
} = &app.mode
{
assert_eq!(input.text(), "/x");
assert_eq!(*slash_state, PromptSlashState::default());
}
}
#[tokio::test]
async fn test_handle_prompt_left_with_shift_moves_cursor_to_previous_word_start() {
let (mut app, _base_dir) = new_test_prompt_app("hello brave world", None).await;
if let AppMode::Prompt { input, .. } = &mut app.mode {
input.cursor = "hello brave world".chars().count();
}
let key = KeyEvent::new(KeyCode::Left, event::KeyModifiers::SHIFT);
handle_prompt_left(&mut app, key);
if let AppMode::Prompt { input, .. } = &app.mode {
assert_eq!(input.cursor, "hello brave ".chars().count());
}
}
#[tokio::test]
async fn test_handle_prompt_left_with_shift_skips_whitespace_separators() {
let (mut app, _base_dir) = new_test_prompt_app("hello\t \nworld", None).await;
if let AppMode::Prompt { input, .. } = &mut app.mode {
input.cursor = "hello\t \nworld".chars().count();
}
let key = KeyEvent::new(KeyCode::Left, event::KeyModifiers::SHIFT);
handle_prompt_left(&mut app, key);
if let AppMode::Prompt { input, .. } = &app.mode {
assert_eq!(input.cursor, "hello\t \n".chars().count());
}
}
#[tokio::test]
async fn test_handle_prompt_right_with_shift_moves_cursor_to_next_word_start() {
let (mut app, _base_dir) = new_test_prompt_app("hello brave world", None).await;
if let AppMode::Prompt { input, .. } = &mut app.mode {
input.cursor = 0;
}
let key = KeyEvent::new(KeyCode::Right, event::KeyModifiers::SHIFT);
handle_prompt_right(&mut app, key);
if let AppMode::Prompt { input, .. } = &app.mode {
assert_eq!(input.cursor, "hello ".chars().count());
}
}
#[tokio::test]
async fn test_handle_prompt_left_with_alt_moves_cursor_to_previous_word_start() {
let (mut app, _base_dir) = new_test_prompt_app("hello brave world", None).await;
if let AppMode::Prompt { input, .. } = &mut app.mode {
input.cursor = "hello brave world".chars().count();
}
let key = KeyEvent::new(KeyCode::Left, event::KeyModifiers::ALT);
handle_prompt_left(&mut app, key);
if let AppMode::Prompt { input, .. } = &app.mode {
assert_eq!(input.cursor, "hello brave ".chars().count());
}
}
#[tokio::test]
async fn test_handle_prompt_right_with_alt_moves_cursor_to_next_word_start() {
let (mut app, _base_dir) = new_test_prompt_app("hello brave world", None).await;
if let AppMode::Prompt { input, .. } = &mut app.mode {
input.cursor = 0;
}
let key = KeyEvent::new(KeyCode::Right, event::KeyModifiers::ALT);
handle_prompt_right(&mut app, key);
if let AppMode::Prompt { input, .. } = &app.mode {
assert_eq!(input.cursor, "hello ".chars().count());
}
}
#[tokio::test]
async fn test_handle_prompt_left_with_super_moves_cursor_to_line_start() {
let (mut app, _base_dir) = new_test_prompt_app("first\nsecond\nthird", None).await;
if let AppMode::Prompt { input, .. } = &mut app.mode {
input.cursor = "first\nseco".chars().count();
}
let key = KeyEvent::new(KeyCode::Left, event::KeyModifiers::SUPER);
handle_prompt_left(&mut app, key);
if let AppMode::Prompt { input, .. } = &app.mode {
assert_eq!(input.cursor, "first\n".chars().count());
}
}
#[tokio::test]
async fn test_handle_prompt_right_with_super_moves_cursor_to_line_end() {
let (mut app, _base_dir) = new_test_prompt_app("first\nsecond\nthird", None).await;
if let AppMode::Prompt { input, .. } = &mut app.mode {
input.cursor = "first\nse".chars().count();
}
let key = KeyEvent::new(KeyCode::Right, event::KeyModifiers::SUPER);
handle_prompt_right(&mut app, key);
if let AppMode::Prompt { input, .. } = &app.mode {
assert_eq!(input.cursor, "first\nsecond".chars().count());
}
}
#[tokio::test]
async fn test_handle_prompt_backspace_resets_history_navigation() {
let (mut app, _base_dir) = new_test_prompt_app("second", None).await;
if let AppMode::Prompt { history_state, .. } = &mut app.mode {
history_state.draft_text = Some("draft".to_string());
history_state.entries = vec!["first".to_string(), "second".to_string()];
history_state.selected_index = Some(1);
}
let key = KeyEvent::new(KeyCode::Backspace, event::KeyModifiers::NONE);
handle_prompt_backspace(&mut app, key);
if let AppMode::Prompt {
history_state,
input,
..
} = &app.mode
{
assert_eq!(input.text(), "secon");
assert_eq!(history_state.selected_index, None);
assert_eq!(history_state.draft_text, None);
}
}
#[tokio::test]
async fn test_handle_prompt_backspace_removes_whole_image_token_and_attachment() {
let (mut app, _base_dir) = new_test_prompt_app("Review ", None).await;
insert_pasted_image_placeholder(&mut app, std::path::PathBuf::from("/tmp/image-1.png"));
if let AppMode::Prompt {
history_state,
input,
..
} = &mut app.mode
{
history_state.selected_index = Some(0);
history_state.draft_text = Some("draft".to_string());
input.cursor = input.text().chars().count();
}
let key = KeyEvent::new(KeyCode::Backspace, event::KeyModifiers::NONE);
handle_prompt_backspace(&mut app, key);
if let AppMode::Prompt {
attachment_state,
history_state,
input,
..
} = &app.mode
{
assert_eq!(input.text(), "Review ");
assert!(attachment_state.attachments.is_empty());
assert_eq!(attachment_state.next_attachment_number, 1);
assert_eq!(history_state.selected_index, None);
assert_eq!(history_state.draft_text, None);
}
}
#[tokio::test]
async fn test_handle_prompt_backspace_with_shift_removes_whole_word() {
let (mut app, _base_dir) = new_test_prompt_app("hello brave world", None).await;
if let AppMode::Prompt { history_state, .. } = &mut app.mode {
history_state.draft_text = Some("draft".to_string());
history_state.entries = vec!["first".to_string(), "second".to_string()];
history_state.selected_index = Some(1);
}
let key = KeyEvent::new(KeyCode::Backspace, event::KeyModifiers::SHIFT);
handle_prompt_backspace(&mut app, key);
if let AppMode::Prompt {
history_state,
input,
..
} = &app.mode
{
assert_eq!(input.text(), "hello brave");
assert_eq!(history_state.selected_index, None);
assert_eq!(history_state.draft_text, None);
}
}
#[tokio::test]
async fn test_handle_prompt_delete_removes_whole_image_token_and_attachment() {
let (mut app, _base_dir) = new_test_prompt_app("Review ", None).await;
insert_pasted_image_placeholder(&mut app, std::path::PathBuf::from("/tmp/image-1.png"));
if let AppMode::Prompt {
history_state,
input,
..
} = &mut app.mode
{
history_state.selected_index = Some(0);
history_state.draft_text = Some("draft".to_string());
input.cursor = "Review ".chars().count();
}
handle_prompt_delete(&mut app);
if let AppMode::Prompt {
attachment_state,
history_state,
input,
..
} = &app.mode
{
assert_eq!(input.text(), "Review ");
assert!(attachment_state.attachments.is_empty());
assert_eq!(attachment_state.next_attachment_number, 1);
assert_eq!(history_state.selected_index, None);
assert_eq!(history_state.draft_text, None);
}
}
#[tokio::test]
async fn test_handle_prompt_delete_reuses_deleted_image_number_on_next_paste() {
let (mut app, _base_dir) = new_test_prompt_app("", None).await;
insert_pasted_image_placeholder(&mut app, std::path::PathBuf::from("/tmp/image-1.png"));
insert_pasted_image_placeholder(&mut app, std::path::PathBuf::from("/tmp/image-2.png"));
insert_pasted_image_placeholder(&mut app, std::path::PathBuf::from("/tmp/image-3.png"));
if let AppMode::Prompt { input, .. } = &mut app.mode {
input.cursor = "[Image #1][Image #2]".chars().count();
}
handle_prompt_delete(&mut app);
insert_pasted_image_placeholder(&mut app, std::path::PathBuf::from("/tmp/image-4.png"));
if let AppMode::Prompt {
attachment_state,
input,
..
} = &app.mode
{
assert_eq!(input.text(), "[Image #1][Image #2][Image #3]");
assert_eq!(attachment_state.attachments.len(), 3);
assert_eq!(attachment_state.next_attachment_number, 4);
assert_eq!(attachment_state.attachments[2].placeholder, "[Image #3]");
assert_eq!(
attachment_state.attachments[2].local_image_path,
std::path::PathBuf::from("/tmp/image-4.png")
);
}
}
#[tokio::test]
async fn test_handle_prompt_backspace_with_alt_removes_whole_word() {
let (mut app, _base_dir) = new_test_prompt_app("hello brave world", None).await;
if let AppMode::Prompt { history_state, .. } = &mut app.mode {
history_state.draft_text = Some("draft".to_string());
history_state.entries = vec!["first".to_string(), "second".to_string()];
history_state.selected_index = Some(1);
}
let key = KeyEvent::new(KeyCode::Backspace, event::KeyModifiers::ALT);
handle_prompt_backspace(&mut app, key);
if let AppMode::Prompt {
history_state,
input,
..
} = &app.mode
{
assert_eq!(input.text(), "hello brave");
assert_eq!(history_state.selected_index, None);
assert_eq!(history_state.draft_text, None);
}
}
#[tokio::test]
async fn test_handle_prompt_backspace_with_super_deletes_full_line() {
let (mut app, _base_dir) = new_test_prompt_app("first line\nsecond line", None).await;
if let AppMode::Prompt { history_state, .. } = &mut app.mode {
history_state.draft_text = Some("draft".to_string());
history_state.entries = vec!["first".to_string(), "second".to_string()];
history_state.selected_index = Some(1);
}
if let AppMode::Prompt { input, .. } = &mut app.mode {
input.cursor = "first line\nsecond".chars().count();
}
let key = KeyEvent::new(KeyCode::Backspace, event::KeyModifiers::SUPER);
handle_prompt_backspace(&mut app, key);
if let AppMode::Prompt {
history_state,
input,
..
} = &app.mode
{
assert_eq!(input.text(), "first line");
assert_eq!(history_state.selected_index, None);
assert_eq!(history_state.draft_text, None);
}
}
#[tokio::test]
async fn test_handle_prompt_line_delete_with_ctrl_u_deletes_full_line() {
let (mut app, _base_dir) = new_test_prompt_app("first line\nsecond line", None).await;
if let AppMode::Prompt { history_state, .. } = &mut app.mode {
history_state.draft_text = Some("draft".to_string());
history_state.entries = vec!["first".to_string(), "second".to_string()];
history_state.selected_index = Some(1);
}
if let AppMode::Prompt { input, .. } = &mut app.mode {
input.cursor = "first line\nsecond".chars().count();
}
handle_prompt_line_delete(&mut app);
if let AppMode::Prompt {
history_state,
input,
..
} = &app.mode
{
assert_eq!(input.text(), "first line");
assert_eq!(history_state.selected_index, None);
assert_eq!(history_state.draft_text, None);
}
}
#[tokio::test]
async fn test_handle_prompt_backspace_with_shift_removes_whitespace_separators() {
let (mut app, _base_dir) = new_test_prompt_app("hello\t \nworld", None).await;
if let AppMode::Prompt { history_state, .. } = &mut app.mode {
history_state.draft_text = Some("draft".to_string());
history_state.entries = vec!["first".to_string(), "second".to_string()];
history_state.selected_index = Some(1);
}
let key = KeyEvent::new(KeyCode::Backspace, event::KeyModifiers::SHIFT);
handle_prompt_backspace(&mut app, key);
if let AppMode::Prompt {
history_state,
input,
..
} = &app.mode
{
assert_eq!(input.text(), "hello");
assert_eq!(history_state.selected_index, None);
assert_eq!(history_state.draft_text, None);
}
}
#[test]
fn test_is_active_at_mention_true_for_valid_query() {
let at_mention_state = Some(PromptAtMentionState::new(Vec::new()));
let input = InputState::with_text("@read".to_string());
let result = is_active_at_mention(at_mention_state.as_ref(), &input);
assert!(result);
}
#[test]
fn test_is_active_at_mention_false_for_email_pattern() {
let at_mention_state = Some(PromptAtMentionState::new(Vec::new()));
let input = InputState::with_text("email@test".to_string());
let result = is_active_at_mention(at_mention_state.as_ref(), &input);
assert!(!result);
}
#[test]
fn test_is_active_at_mention_false_without_state() {
let at_mention_state = None;
let input = InputState::with_text("@read".to_string());
let result = is_active_at_mention(at_mention_state.as_ref(), &input);
assert!(!result);
}
#[tokio::test]
async fn test_prompt_context_marks_email_pattern_as_inactive_mention() {
let state = PromptAtMentionState::new(Vec::new());
let (mut app, _base_dir) = new_test_prompt_app("email@test", Some(state)).await;
let context = prompt_context(&mut app).expect("expected prompt context");
assert!(!context.is_at_mention());
}
#[tokio::test]
async fn test_prompt_context_falls_back_to_list_when_session_is_missing() {
let (mut app, _base_dir) = new_test_prompt_app("follow up", None).await;
app.mode = AppMode::Prompt {
at_mention_state: None,
attachment_state: PromptAttachmentState::default(),
history_state: PromptHistoryState::new(Vec::new()),
review_status_message: None,
review_text: None,
input: InputState::with_text("follow up".to_string()),
session_id: "missing-session".into(),
slash_state: PromptSlashState::default(),
scroll_offset: Some(2),
};
let context = prompt_context(&mut app);
assert!(context.is_none());
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_handle_at_mention_select_dismisses_stale_mention_state() {
let state = PromptAtMentionState::new(vec![FileEntry {
is_dir: false,
path: "src/main.rs".to_string(),
}]);
let (mut app, _base_dir) = new_test_prompt_app("email@test", Some(state)).await;
handle_at_mention_select(&mut app);
assert!(matches!(app.mode, AppMode::Prompt { .. }));
if let AppMode::Prompt {
at_mention_state,
input,
..
} = &app.mode
{
assert!(at_mention_state.is_none());
assert_eq!(input.text(), "email@test");
}
}
#[tokio::test]
async fn test_handle_at_mention_select_inserts_directory_with_trailing_slash() {
let state = PromptAtMentionState::new(vec![FileEntry {
is_dir: true,
path: "src".to_string(),
}]);
let (mut app, _base_dir) = new_test_prompt_app("@src", Some(state)).await;
handle_at_mention_select(&mut app);
assert!(matches!(app.mode, AppMode::Prompt { .. }));
if let AppMode::Prompt { input, .. } = &app.mode {
assert_eq!(input.text(), "@src/ ");
}
}
#[tokio::test]
async fn test_handle_at_mention_select_clamps_stale_selected_index() {
let mut state = PromptAtMentionState::new(vec![
FileEntry {
is_dir: false,
path: "src/main.rs".to_string(),
},
FileEntry {
is_dir: false,
path: "tests/main.rs".to_string(),
},
]);
state.selected_index = 9;
let (mut app, _base_dir) = new_test_prompt_app("@src/ma", Some(state)).await;
handle_at_mention_select(&mut app);
if let AppMode::Prompt {
at_mention_state,
input,
..
} = &app.mode
{
assert!(at_mention_state.is_none());
assert_eq!(input.text(), "@src/main.rs ");
}
}
#[tokio::test]
async fn test_handle_prompt_char_activates_and_clears_at_mention_state() {
let (mut app, _base_dir) = new_test_prompt_app("", None).await;
handle_prompt_char(&mut app, '@');
assert!(matches!(app.mode, AppMode::Prompt { .. }));
if let AppMode::Prompt {
at_mention_state, ..
} = &app.mode
{
assert!(at_mention_state.is_some());
}
handle_prompt_char(&mut app, ' ');
assert!(matches!(app.mode, AppMode::Prompt { .. }));
if let AppMode::Prompt {
at_mention_state, ..
} = &app.mode
{
assert!(at_mention_state.is_none());
}
}
#[tokio::test]
async fn test_handle_prompt_char_loads_at_mention_entries_from_project_root_for_draft_session()
{
let (mut app, base_dir) = new_test_draft_prompt_app("", None).await;
let expected_path = "draft_lookup_target.txt";
std::fs::write(base_dir.path().join(expected_path), "draft")
.expect("failed to write project file");
assert!(!app.sessions.sessions[0].folder.exists());
handle_prompt_char(&mut app, '@');
let next_event = wait_for_at_mention_entries_event(&mut app).await;
match next_event {
crate::app::AppEvent::AtMentionEntriesLoaded {
entries,
session_id,
} => {
assert_eq!(session_id, app.sessions.sessions[0].id.as_str());
assert!(entries.contains(&FileEntry {
is_dir: false,
path: expected_path.to_string(),
}));
}
_ => unreachable!("expected at-mention entries event"),
}
}
#[tokio::test]
async fn test_handle_prompt_left_reactivates_existing_at_mention_without_cached_state() {
let input_text = "@src/main.rs more";
let (mut app, _base_dir) = new_test_prompt_app(input_text, None).await;
let moves_back_into_mention = " more".chars().count();
for _ in 0..moves_back_into_mention {
handle_prompt_left(
&mut app,
KeyEvent::new(KeyCode::Left, event::KeyModifiers::NONE),
);
}
if let AppMode::Prompt {
at_mention_state,
input,
..
} = &app.mode
{
assert_eq!(input.cursor, "@src/main.rs".chars().count());
assert!(at_mention_state.is_some());
}
}
#[tokio::test]
async fn test_handle_prompt_cancel_key_deletes_blank_session() {
let (mut app, _base_dir) = new_test_prompt_app("", None).await;
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
assert!(prompt_context.is_new_session());
assert_eq!(app.sessions.sessions.len(), 1);
handle_prompt_cancel_key(&mut app, &prompt_context).await;
assert!(matches!(app.mode, AppMode::List));
assert!(app.sessions.sessions.is_empty());
}
#[tokio::test]
async fn test_handle_prompt_cancel_key_keeps_empty_draft_session() {
let (mut app, _base_dir) = new_test_draft_prompt_app("", None).await;
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
assert!(prompt_context.is_new_session());
assert!(!prompt_context.can_delete_on_cancel());
assert_eq!(app.sessions.sessions.len(), 1);
handle_prompt_cancel_key(&mut app, &prompt_context).await;
assert!(matches!(app.mode, AppMode::View { .. }));
assert_eq!(app.sessions.sessions.len(), 1);
assert_eq!(
app.sessions.sessions[0].status,
crate::domain::session::Status::Draft
);
assert!(app.sessions.sessions[0].prompt.is_empty());
}
#[tokio::test]
async fn test_handle_prompt_submit_key_ignores_empty_prompt() {
let (mut app, _base_dir) = new_test_prompt_app("", None).await;
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_submit_key(&mut app, &prompt_context).await;
assert!(matches!(app.mode, AppMode::Prompt { .. }));
assert_eq!(app.sessions.sessions.len(), 1);
assert_eq!(app.sessions.sessions[0].prompt, "");
}
#[tokio::test]
async fn test_handle_prompt_submit_key_drains_supported_image_turn() {
let (mut app, _base_dir) = new_test_draft_prompt_app("Review ", None).await;
app.sessions.sessions[0].model = crate::domain::agent::AgentModel::ClaudeSonnet46;
insert_pasted_image_placeholder(&mut app, std::path::PathBuf::from("/tmp/image-1.png"));
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_submit_key(&mut app, &prompt_context).await;
assert!(matches!(app.mode, AppMode::View { .. }));
assert_eq!(app.sessions.sessions[0].prompt, "Review [Image #1]");
assert_eq!(
app.sessions.sessions[0].status,
crate::domain::session::Status::Draft
);
assert_eq!(app.sessions.sessions[0].draft_attachments.len(), 1);
assert_eq!(
app.sessions.sessions[0].draft_attachments[0].placeholder,
"[Image #1]"
);
}
#[tokio::test]
async fn test_handle_prompt_submit_key_starts_regular_session_with_image_turn() {
let (mut app, _base_dir) = new_test_prompt_app("Review ", None).await;
app.sessions.sessions[0].model = crate::domain::agent::AgentModel::ClaudeSonnet46;
insert_pasted_image_placeholder(&mut app, std::path::PathBuf::from("/tmp/image-1.png"));
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_submit_key(&mut app, &prompt_context).await;
assert!(matches!(app.mode, AppMode::View { .. }));
assert_eq!(app.sessions.sessions[0].prompt, "Review [Image #1]");
assert_eq!(
app.sessions.sessions[0].title.as_deref(),
Some("Review [Image #1]")
);
assert!(app.sessions.sessions[0].draft_attachments.is_empty());
}
#[tokio::test]
async fn test_handle_prompt_submit_key_clears_cached_review_output() {
let (mut app, _base_dir) = new_test_prompt_app("follow up", None).await;
let session_id = app.sessions.sessions[0].id.clone();
app.review_cache.insert(
session_id.clone(),
crate::app::ReviewCacheEntry::Ready {
diff_hash: 7,
text: "Focused review".to_string(),
},
);
if let AppMode::Prompt {
review_status_message,
review_text,
..
} = &mut app.mode
{
*review_status_message = None;
*review_text = Some("Focused review".to_string());
}
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_submit_key(&mut app, &prompt_context).await;
assert!(matches!(
app.mode,
AppMode::View {
review_status_message: None,
review_text: None,
..
}
));
assert!(!app.review_cache.contains_key(session_id.as_str()));
}
#[tokio::test]
async fn test_handle_prompt_submit_key_replies_after_started_draft_session_reaches_review() {
let (mut app, _base_dir) = new_test_draft_prompt_app("follow up", None).await;
app.sessions.sessions[0].status = crate::domain::session::Status::Review;
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_submit_key(&mut app, &prompt_context).await;
assert!(!prompt_context.is_draft_session());
assert!(matches!(app.mode, AppMode::View { .. }));
assert!(
!app.sessions.sessions[0]
.output
.contains("Only `Draft` sessions can stage drafts")
);
}
#[tokio::test]
async fn test_handle_prompt_cancel_key_keeps_new_session_with_staged_drafts() {
let (mut app, _base_dir) = new_test_draft_prompt_app("Another draft", None).await;
app.sessions.sessions[0].prompt = "First draft".to_string();
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_cancel_key(&mut app, &prompt_context).await;
assert!(matches!(app.mode, AppMode::View { .. }));
assert_eq!(app.sessions.sessions.len(), 1);
}
#[tokio::test]
async fn test_handle_prompt_cancel_key_restores_review_output() {
let (mut app, _base_dir) = new_test_prompt_app("follow up", None).await;
app.sessions.sessions[0].status = crate::domain::session::Status::Review;
if let AppMode::Prompt {
review_status_message,
review_text,
..
} = &mut app.mode
{
*review_status_message = None;
*review_text = Some("Focused review".to_string());
}
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_cancel_key(&mut app, &prompt_context).await;
assert!(matches!(
app.mode,
AppMode::View {
review_status_message: None,
review_text: Some(ref review_text),
..
} if review_text == "Focused review"
));
}
#[tokio::test]
async fn test_handle_prompt_cancel_key_resets_existing_session_draft_attachments() {
let (mut app, base_dir) = new_test_prompt_app("Review ", None).await;
app.sessions.sessions[0].prompt = "Earlier prompt".to_string();
app.sessions.sessions[0].status = crate::domain::session::Status::Review;
let image_directory = base_dir.path().join("images");
std::fs::create_dir_all(&image_directory).expect("image directory should exist");
let image_path = image_directory.join("image-1.png");
std::fs::write(&image_path, b"png").expect("image file should be written");
insert_pasted_image_placeholder(&mut app, image_path.clone());
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_cancel_key(&mut app, &prompt_context).await;
assert!(matches!(app.mode, AppMode::View { .. }));
assert!(image_path.exists());
assert!(image_directory.exists());
}
#[test]
fn test_format_duration_zero() {
let result = format_duration(0);
assert_eq!(result, "00:00:00");
}
#[test]
fn test_format_duration_mixed() {
let result = format_duration(3661);
assert_eq!(result, "01:01:01");
}
#[test]
fn test_format_duration_large() {
let result = format_duration(86400);
assert_eq!(result, "24:00:00");
}
#[test]
fn test_format_stats_metric_line_uses_tab_delimiter() {
let session_line = format_stats_metric_line("Session ID", "session-id");
let error_line = format_stats_metric_line("Error", "boom");
assert_eq!(session_line, "Session ID\tsession-id");
assert_eq!(error_line, "Error\tboom");
}
#[test]
fn test_build_stats_markdown_renders_aligned_usage_table_without_box() {
let usage_rows_result = Ok(vec![TokenUsageRow {
in_tokens: "1.2k".to_string(),
model: "gemini-2.5-flash".to_string(),
out_tokens: "650".to_string(),
}]);
let result = build_stats_markdown("session-id", "00:20:15", usage_rows_result);
assert!(result.starts_with("\n## Session Stats\n\n```stats\n"));
assert!(result.contains("Session ID\tsession-id"));
assert!(result.contains("Session Time\t00:20:15"));
assert!(result.contains("Tokens Usage"));
assert!(result.contains("Model"));
assert!(result.contains("gemini-2.5-flash"));
assert!(result.contains("1.2k"));
assert!(result.contains("650"));
assert!(!result.contains('+'));
assert!(!result.contains('|'));
let session_id_index = result.find("Session ID").expect("expected session id");
let session_time_index = result.find("Session Time").expect("expected session time");
let token_usage_index = result
.find("Tokens Usage")
.expect("expected token usage title");
let model_header_index = result.find("Model").expect("expected model header");
assert!(session_id_index < session_time_index);
assert!(session_time_index < token_usage_index);
assert!(token_usage_index < model_header_index);
}
#[test]
fn test_build_stats_markdown_renders_no_usage_message() {
let usage_rows_result = Ok(Vec::new());
let result = build_stats_markdown("session-id", "00:20:15", usage_rows_result);
assert!(result.contains("Tokens Usage"));
assert!(result.contains("No token usage recorded."));
}
#[test]
fn test_build_apply_review_prompt_requires_verification_before_apply() {
let suggestions = "- Fix the typo in `README.md`.";
let prompt = build_apply_review_prompt(suggestions);
assert!(
prompt
.text
.contains("Verify the following focused-review suggestions"),
);
assert!(
prompt
.text
.contains("Apply only the suggestions that are still correct and relevant"),
);
assert!(prompt.text.contains(suggestions));
}
#[tokio::test]
async fn test_handle_apply_command_rejects_when_session_not_in_review_status() {
let (mut app, _base_dir) = new_test_prompt_app("/apply", None).await;
let session_id = app.sessions.sessions[0].id.clone();
app.review_cache.insert(
session_id.clone(),
crate::app::ReviewCacheEntry::Ready {
diff_hash: 0,
text: "## Review\n### Suggestions\n- Fix the typo.".to_string(),
},
);
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_apply_command(&mut app, &prompt_context).await;
assert!(matches!(app.mode, AppMode::Prompt { .. }));
assert!(app.review_cache.contains_key(session_id.as_str()));
}
#[tokio::test]
async fn test_handle_apply_command_rejects_without_cached_review() {
let (mut app, _base_dir) = new_test_prompt_app("/apply", None).await;
app.sessions.sessions[0].status = crate::domain::session::Status::Review;
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_apply_command(&mut app, &prompt_context).await;
assert!(matches!(app.mode, AppMode::Prompt { .. }));
}
#[tokio::test]
async fn test_handle_apply_command_invalidates_cache_when_diff_hash_mismatches() {
let (mut app, _base_dir) = new_test_prompt_app("/apply", None).await;
app.sessions.sessions[0].status = crate::domain::session::Status::Review;
let session_id = app.sessions.sessions[0].id.clone();
app.review_cache.insert(
session_id.clone(),
crate::app::ReviewCacheEntry::Ready {
diff_hash: u64::MAX,
text: "## Review\n### Suggestions\n- Fix the typo.".to_string(),
},
);
if let AppMode::Prompt {
review_status_message,
review_text,
..
} = &mut app.mode
{
*review_status_message = Some("stale status".to_string());
*review_text = Some("## Review\n### Suggestions\n- Fix the typo.".to_string());
}
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_apply_command(&mut app, &prompt_context).await;
assert!(!app.review_cache.contains_key(session_id.as_str()));
let AppMode::Prompt {
review_status_message,
review_text,
..
} = &app.mode
else {
unreachable!("expected AppMode::Prompt after stale-hash bail-out");
};
assert!(
review_status_message.is_none(),
"stale review status must be cleared so cancel cannot restore it",
);
assert!(
review_text.is_none(),
"stale review text must be cleared so cancel cannot restore it",
);
}
#[tokio::test]
async fn test_handle_apply_command_submits_suggestions_when_diff_hash_matches() {
let (mut app, _base_dir) = new_test_prompt_app("/apply", None).await;
app.sessions.sessions[0].status = crate::domain::session::Status::Review;
let session_id = app.sessions.sessions[0].id.clone();
let folder = app.sessions.sessions[0].folder.clone();
let base_branch = app.sessions.sessions[0].base_branch.clone();
let current_diff = app
.services
.git_client()
.diff(folder, base_branch)
.await
.unwrap_or_default();
let current_hash = crate::app::diff_content_hash(¤t_diff);
app.review_cache.insert(
session_id.clone(),
crate::app::ReviewCacheEntry::Ready {
diff_hash: current_hash,
text: "## Review\n### Suggestions\n- Fix the typo in `README.md`.".to_string(),
},
);
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_apply_command(&mut app, &prompt_context).await;
assert!(!app.review_cache.contains_key(session_id.as_str()));
assert!(matches!(app.mode, AppMode::View { .. }));
}
#[tokio::test]
async fn test_handle_apply_command_preserves_cache_on_git_diff_error() {
let (mut app, _base_dir) = new_test_prompt_app("/apply", None).await;
app.sessions.sessions[0].status = crate::domain::session::Status::Review;
let session_id = app.sessions.sessions[0].id.clone();
app.review_cache.insert(
session_id.clone(),
crate::app::ReviewCacheEntry::Ready {
diff_hash: 42,
text: "## Review\n### Suggestions\n- Fix the typo in `README.md`.".to_string(),
},
);
let mut mock_git_client = crate::infra::git::MockGitClient::new();
mock_git_client.expect_diff().returning(|_, _| {
Box::pin(async {
Err(crate::infra::git::GitError::OutputParse(
"simulated git failure".to_string(),
))
})
});
install_mock_git_client(&mut app, mock_git_client);
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_apply_command(&mut app, &prompt_context).await;
assert!(matches!(app.mode, AppMode::Prompt { .. }));
assert!(
matches!(
app.review_cache.get(session_id.as_str()),
Some(crate::app::ReviewCacheEntry::Ready { diff_hash: 42, .. }),
),
"cached review must survive a transient git diff error",
);
}
#[tokio::test]
async fn test_prompt_context_demotes_slash_command_to_text_when_session_is_in_progress() {
let (mut app, _base_dir) = new_test_prompt_app("/model", None).await;
app.sessions.sessions[0].status = crate::domain::session::Status::InProgress;
let context = prompt_context(&mut app).expect("expected prompt context");
assert!(
!context.is_slash_command(),
"slash command mode must be demoted while session is InProgress"
);
assert_eq!(context.input_mode, PromptInputMode::Text);
}
#[tokio::test]
async fn test_prompt_context_keeps_slash_command_when_session_is_review() {
let (mut app, _base_dir) = new_test_prompt_app("/model", None).await;
app.sessions.sessions[0].status = crate::domain::session::Status::Review;
let context = prompt_context(&mut app).expect("expected prompt context");
assert!(
context.is_slash_command(),
"slash command mode must remain active when session is not InProgress"
);
assert_eq!(context.input_mode, PromptInputMode::SlashCommand);
}
#[tokio::test]
async fn test_handle_prompt_submit_key_queues_slash_text_when_session_is_in_progress() {
let (mut app, _base_dir) = new_test_prompt_app("/model gpt-5", None).await;
let session_id = app.sessions.sessions[0].id.clone();
app.sessions.handles.insert(
session_id.clone(),
crate::domain::session::SessionHandles::new(
String::new(),
crate::domain::session::Status::InProgress,
),
);
app.sessions.sessions[0].status = crate::domain::session::Status::InProgress;
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_submit_key(&mut app, &prompt_context).await;
let queued_len = app
.sessions
.handles
.get(session_id.as_str())
.expect("handles for in-progress session")
.queued_messages
.lock()
.expect("queue lock")
.len();
assert_eq!(
queued_len, 1,
"slash-prefixed input must be queued as plain text while turn runs"
);
assert_eq!(app.sessions.sessions[0].queued_messages.len(), 1);
assert_eq!(
app.sessions.sessions[0].queued_messages[0], "/model gpt-5",
"queued message preserves the original slash-prefixed text"
);
}
#[tokio::test]
async fn test_handle_prompt_slash_submit_preserves_attachments_when_apply_bails_out() {
let (mut app, _base_dir) = new_test_prompt_app("/apply", None).await;
if let AppMode::Prompt {
attachment_state, ..
} = &mut app.mode
{
attachment_state
.attachments
.push(crate::domain::composer::PromptAttachment::new(
1,
std::path::PathBuf::from("/tmp/nonexistent-test-attachment.png"),
));
}
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_slash_submit(&mut app, &prompt_context).await;
let AppMode::Prompt {
attachment_state, ..
} = &app.mode
else {
unreachable!("expected AppMode::Prompt after /apply bail-out");
};
assert_eq!(
attachment_state.attachments.len(),
1,
"attachments must survive validation failure so the user keeps their pasted files",
);
}
#[tokio::test]
async fn test_handle_prompt_slash_submit_ignores_apply_when_suggestions_are_empty() {
let (mut app, _base_dir) = new_test_prompt_app("/apply", None).await;
app.sessions.sessions[0].status = crate::domain::session::Status::Review;
let session_id = app.sessions.sessions[0].id.clone();
app.review_cache.insert(
session_id.clone(),
crate::app::ReviewCacheEntry::Ready {
diff_hash: 0,
text: "## Review\n### Suggestions\n- None".to_string(),
},
);
let prompt_context = prompt_context(&mut app).expect("expected prompt context");
handle_prompt_slash_submit(&mut app, &prompt_context).await;
let AppMode::Prompt {
input, slash_state, ..
} = &app.mode
else {
unreachable!("expected AppMode::Prompt after unavailable /apply");
};
assert_eq!(input.text(), "/apply");
assert_eq!(*slash_state, PromptSlashState::default());
assert!(
matches!(
app.review_cache.get(session_id.as_str()),
Some(crate::app::ReviewCacheEntry::Ready { .. }),
),
"unavailable /apply should not consume the cached review",
);
}
}