use std::io;
use crossterm::event::{self, KeyCode, KeyEvent};
use ratatui::Terminal;
use ratatui::backend::Backend;
use ratatui::layout::Rect;
use tracing::warn;
use crate::app::session::{SessionTaskService, remote_branch_name_from_upstream_ref};
use crate::app::{self, App, AppEvent, ReviewCacheEntry, diff_content_hash};
use crate::domain::input::InputState;
use crate::domain::session::{FollowUpTaskAction, PublishBranchAction, SessionId, Status};
use crate::domain::transcript_notice::TranscriptNotice;
use crate::presentation::app_mode::{
AppMode, ChatFocus, ConfirmationIntent, ConfirmationViewMode, HelpContext,
};
use crate::presentation::help_action::{self, ViewSessionState};
use crate::presentation::prompt::{PromptAttachmentState, PromptHistoryState};
use crate::runtime::EventResult;
use crate::runtime::mode::chat_scroll::{self, ChatScrollMetrics};
use crate::runtime::mode::confirmation::DEFAULT_OPTION_INDEX;
use crate::runtime::mode::input_key::is_insertable_char_key;
use crate::runtime::mode::{diff, prompt};
use crate::ui::RenderCacheStore;
#[derive(Clone)]
struct ViewContext {
scroll_offset: Option<u16>,
session_id: SessionId,
session_index: usize,
}
struct ViewPendingUpdate {
scroll_offset: Option<u16>,
}
impl ViewPendingUpdate {
fn from_context(view_context: &ViewContext) -> Self {
Self {
scroll_offset: view_context.scroll_offset,
}
}
}
struct ViewKeyContext<'a> {
context: &'a ViewContext,
metrics: ChatScrollMetrics,
session_snapshot: &'a ViewSessionSnapshot,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ViewActionState {
Disabled,
Enabled,
}
impl ViewActionState {
fn from_bool(is_enabled: bool) -> Self {
if is_enabled {
return Self::Enabled;
}
Self::Disabled
}
fn is_enabled(self) -> bool {
self == Self::Enabled
}
}
struct ViewSessionSnapshot {
continue_terminal_session: ViewActionState,
fork_session: ViewActionState,
follow_up_task_action: Option<FollowUpTaskAction>,
merge_session_branch: ViewActionState,
mutate_session_branch: ViewActionState,
open_worktree: ViewActionState,
publish_pull_request_action: Option<PublishBranchAction>,
rebase_session_branch: ViewActionState,
reply_to_session: ViewActionState,
review_comments: ViewActionState,
session_state: ViewSessionState,
session_status: Status,
start_staged_session: ViewActionState,
}
impl ViewSessionSnapshot {
fn can_merge_session(&self) -> bool {
self.session_status.allows_session_actions()
&& self.can_merge_session_branch()
&& self.session_state != ViewSessionState::StackedDraft
}
fn can_rebase_session(&self) -> bool {
self.session_status.allows_rebase_action()
&& self.can_rebase_session_branch()
&& self.session_state != ViewSessionState::StackedDraft
}
fn can_continue_terminal_session(&self) -> bool {
self.continue_terminal_session.is_enabled()
}
fn can_fork_session(&self) -> bool {
self.fork_session.is_enabled()
}
fn can_mutate_session_branch(&self) -> bool {
self.mutate_session_branch.is_enabled()
}
fn can_merge_session_branch(&self) -> bool {
self.merge_session_branch.is_enabled()
}
fn can_open_worktree(&self) -> bool {
self.open_worktree.is_enabled()
}
fn can_rebase_session_branch(&self) -> bool {
self.rebase_session_branch.is_enabled()
}
fn can_reply_to_session(&self) -> bool {
self.reply_to_session.is_enabled()
}
fn can_open_review_comments(&self) -> bool {
self.review_comments.is_enabled()
}
fn can_start_staged_session(&self) -> bool {
self.start_staged_session.is_enabled()
}
fn can_open_prompt_composer(&self) -> bool {
if !self.session_status.allows_chat_composer() {
return false;
}
self.can_edit_without_branch_work() || self.can_reply_to_session()
}
fn can_launch_configuration_composer(&self) -> bool {
if !self.session_status.allows_session_actions() {
return false;
}
self.can_edit_without_branch_work() || self.can_mutate_session_branch()
}
fn can_paste_image_into_draft_composer(&self) -> bool {
self.can_open_prompt_composer() && self.can_edit_without_branch_work()
}
fn can_edit_without_branch_work(&self) -> bool {
matches!(
self.session_state,
ViewSessionState::NewSession | ViewSessionState::StackedDraft
)
}
}
const REVIEW_NO_DIFF_MESSAGE: &str = "No diff changes found for review.";
pub(crate) async fn handle_with_cache<B: Backend>(
app: &mut App,
render_cache_store: &RenderCacheStore,
terminal: &mut Terminal<B>,
key: KeyEvent,
) -> io::Result<EventResult>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
let Some(view_context) = view_context(app) else {
return Ok(EventResult::Continue);
};
let view_metrics = view_metrics(app, render_cache_store, terminal, &view_context)?;
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
let Some(view_session_snapshot) = view_session_snapshot(app, &view_context) else {
return Ok(EventResult::Continue);
};
let view_key_context = ViewKeyContext {
context: &view_context,
metrics: view_metrics,
session_snapshot: &view_session_snapshot,
};
if !handle_view_key(app, key, view_key_context, &mut pending_update).await {
return Ok(EventResult::Continue);
}
apply_view_scroll_and_output_mode(app, pending_update.scroll_offset);
Ok(EventResult::Continue)
}
#[cfg(test)]
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,
{
handle_with_cache(app, &RenderCacheStore::default(), terminal, key).await
}
async fn handle_view_key(
app: &mut App,
key: KeyEvent,
view_key_context: ViewKeyContext<'_>,
pending_update: &mut ViewPendingUpdate,
) -> bool {
let view_context = view_key_context.context;
let view_metrics = view_key_context.metrics;
let view_session_snapshot = view_key_context.session_snapshot;
if let Some(should_apply_pending_update) = handle_primary_view_key(
app,
key,
view_context,
view_session_snapshot,
pending_update,
)
.await
{
return should_apply_pending_update;
}
if chat_scroll::apply_scroll_key(&mut pending_update.scroll_offset, view_metrics, key) {
return true;
}
if let Some(should_apply_pending_update) = handle_workflow_view_key(
app,
key,
view_context,
view_session_snapshot,
pending_update,
)
.await
{
return should_apply_pending_update;
}
true
}
async fn handle_primary_view_key(
app: &mut App,
key: KeyEvent,
view_context: &ViewContext,
view_session_snapshot: &ViewSessionSnapshot,
pending_update: &ViewPendingUpdate,
) -> Option<bool> {
match key.code {
KeyCode::Char('q') => {
app.mode = AppMode::List;
}
KeyCode::Char('o') if view_session_snapshot.can_open_worktree() => {
open_worktree_for_view_session(app, view_context).await;
}
KeyCode::Char('l') if view_session_snapshot.follow_up_task_action.is_some() => {
if let Err(error) = app
.launch_or_open_selected_follow_up_task(&view_context.session_id)
.await
{
app.append_output_for_session(
&view_context.session_id,
&TranscriptNotice::FollowUpTaskError.format(error),
)
.await;
}
return Some(false);
}
KeyCode::Char('s') if view_session_snapshot.can_start_staged_session() => {
if let Err(error) = app.start_staged_session(&view_context.session_id).await {
app.append_output_for_session(
&view_context.session_id,
&TranscriptNotice::StartError.format(error),
)
.await;
}
return Some(false);
}
KeyCode::Char('v' | 'V')
if prompt::is_prompt_image_paste_key(key)
&& view_session_snapshot.can_paste_image_into_draft_composer() =>
{
open_draft_prompt_with_pasted_image(app, view_context, pending_update.scroll_offset)
.await;
return Some(false);
}
KeyCode::Char('c')
if key.modifiers == event::KeyModifiers::NONE
&& view_session_snapshot.can_open_review_comments() =>
{
let diff = load_view_session_diff(app, view_context).await;
app.open_session_review_comments(&view_context.session_id, diff);
return Some(false);
}
KeyCode::Char('c' | 'C') if is_continue_terminal_shortcut(key, view_session_snapshot) => {
open_continue_confirmation(app, view_context);
return Some(false);
}
KeyCode::Char('[') if app.has_multiple_follow_up_tasks(&view_context.session_id) => {
app.select_previous_follow_up_task(&view_context.session_id);
}
KeyCode::Char(']') if app.has_multiple_follow_up_tasks(&view_context.session_id) => {
app.select_next_follow_up_task(&view_context.session_id);
}
KeyCode::Enter if view_session_snapshot.can_open_prompt_composer() => {
switch_view_to_prompt(
app,
view_context,
PromptHistoryState::new(session_prompt_history_entries(
app.sessions.session_at(view_context.session_index)?,
)),
InputState::default(),
pending_update.scroll_offset,
)
.await;
}
KeyCode::Char('/')
if view_session_snapshot.can_launch_configuration_composer()
&& is_insertable_char_key(key) =>
{
switch_view_to_prompt(
app,
view_context,
PromptHistoryState::new(session_prompt_history_entries(
app.sessions.session_at(view_context.session_index)?,
)),
InputState::with_text("/".to_string()),
pending_update.scroll_offset,
)
.await;
}
_ => return None,
}
Some(true)
}
fn is_continue_terminal_shortcut(
key: KeyEvent,
view_session_snapshot: &ViewSessionSnapshot,
) -> bool {
let has_supported_modifiers = matches!(
key.modifiers,
event::KeyModifiers::NONE | event::KeyModifiers::SHIFT
);
let has_unambiguous_key =
key.code == KeyCode::Char('C') || !view_session_snapshot.can_open_review_comments();
has_supported_modifiers
&& view_session_snapshot.can_continue_terminal_session()
&& has_unambiguous_key
}
async fn handle_workflow_view_key(
app: &mut App,
key: KeyEvent,
view_context: &ViewContext,
view_session_snapshot: &ViewSessionSnapshot,
pending_update: &mut ViewPendingUpdate,
) -> Option<bool> {
match key.code {
KeyCode::Char('d')
if !key.modifiers.contains(event::KeyModifiers::CONTROL)
&& view_session_snapshot.session_status.allows_diff_view() =>
{
show_diff_for_view_session(app, view_context).await;
}
KeyCode::Char(character)
if character.eq_ignore_ascii_case(&'p')
&& !key.modifiers.contains(event::KeyModifiers::CONTROL)
&& view_session_snapshot.publish_pull_request_action.is_some() =>
{
let Some(publish_pull_request_action) =
view_session_snapshot.publish_pull_request_action
else {
return Some(true);
};
open_publish_branch_input(app, view_context, publish_pull_request_action);
return Some(false);
}
KeyCode::Char('F')
if !key.modifiers.contains(event::KeyModifiers::CONTROL)
&& view_session_snapshot.can_fork_session() =>
{
open_fork_confirmation(app, view_context);
return Some(false);
}
KeyCode::Char('f')
if !key.modifiers.contains(event::KeyModifiers::CONTROL)
&& view_session_snapshot.session_status.allows_review_actions() =>
{
open_or_regenerate_review(app, view_context, pending_update).await;
}
KeyCode::Char('m') if view_session_snapshot.can_merge_session() => {
open_merge_confirmation(app, view_context);
}
KeyCode::Char('r') if view_session_snapshot.can_rebase_session() => {
rebase_view_session(app, &view_context.session_id).await;
}
KeyCode::Char('c')
if key.modifiers.contains(event::KeyModifiers::CONTROL)
&& view_session_snapshot.session_status == Status::InProgress =>
{
end_in_progress_turn(app, &view_context.session_id).await;
return Some(false);
}
KeyCode::Char('?') => {
open_view_help_overlay(app, view_context, view_session_snapshot);
return Some(false);
}
_ => return None,
}
Some(true)
}
fn open_fork_confirmation(app: &mut App, view_context: &ViewContext) {
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::ForkSession,
confirmation_message: "Fork this session into a new session with the current transcript \
history?"
.to_string(),
confirmation_title: "Confirm Fork".to_string(),
restore_view: Some(confirmation_view_mode(view_context)),
session_id: Some(view_context.session_id.clone()),
selected_confirmation_index: DEFAULT_OPTION_INDEX,
};
}
fn open_merge_confirmation(app: &mut App, view_context: &ViewContext) {
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::MergeSession,
confirmation_message: "Add this session to merge queue?".to_string(),
confirmation_title: "Confirm Merge".to_string(),
restore_view: Some(confirmation_view_mode(view_context)),
session_id: Some(view_context.session_id.clone()),
selected_confirmation_index: DEFAULT_OPTION_INDEX,
};
}
fn open_continue_confirmation(app: &mut App, view_context: &ViewContext) {
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::ContinueSession,
confirmation_message: "Create a new draft session with initial context from this session?"
.to_string(),
confirmation_title: "Confirm Continue".to_string(),
restore_view: Some(confirmation_view_mode(view_context)),
session_id: Some(view_context.session_id.clone()),
selected_confirmation_index: DEFAULT_OPTION_INDEX,
};
}
async fn open_worktree_for_view_session(app: &mut App, view_context: &ViewContext) {
let launch_configurations = app.configured_launch_configurations();
if launch_configurations.len() > 1 {
app.mode = AppMode::LaunchConfigurationSelector {
commands: launch_configurations,
restore_view: confirmation_view_mode(view_context),
selected_command_index: 0,
};
return;
}
let selected_launch_configuration = launch_configurations.first().map(String::as_str);
app.open_session_worktree_in_tmux_with_command(selected_launch_configuration)
.await;
}
fn confirmation_view_mode(view_context: &ViewContext) -> ConfirmationViewMode {
ConfirmationViewMode {
scroll_offset: view_context.scroll_offset,
session_id: view_context.session_id.clone(),
}
}
async fn open_or_regenerate_review(
app: &mut App,
view_context: &ViewContext,
pending_update: &mut ViewPendingUpdate,
) {
let (review_status_message, review_text) = app.review_view_state(&view_context.session_id);
if app.review_is_loading(&view_context.session_id) {
return;
}
if review_text.is_some() || review_status_message.is_some() {
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::RegenerateReview,
confirmation_message: "Regenerate focused review?".to_string(),
confirmation_title: "Confirm Regenerate".to_string(),
restore_view: Some(confirmation_view_mode(view_context)),
session_id: Some(view_context.session_id.clone()),
selected_confirmation_index: DEFAULT_OPTION_INDEX,
};
return;
}
open_review_output_mode(app, view_context).await;
pending_update.scroll_offset = None;
}
fn view_session_snapshot(app: &App, view_context: &ViewContext) -> Option<ViewSessionSnapshot> {
let session = app.sessions.session_at(view_context.session_index)?;
let session_status = session.status;
let can_open_worktree = session_status.allows_session_actions()
&& *app
.sessions
.session_worktree_availability()
.get(view_context.session_id.as_str())
.unwrap_or(&false);
Some(ViewSessionSnapshot {
continue_terminal_session: ViewActionState::from_bool(
session.allows_terminal_continuation(),
),
fork_session: ViewActionState::from_bool(session.allows_fork_action()),
follow_up_task_action: app.selected_follow_up_task_action(&view_context.session_id),
merge_session_branch: ViewActionState::from_bool(
app.sessions
.can_merge_session_branch_in_stack(view_context.session_id.as_str()),
),
mutate_session_branch: ViewActionState::from_bool(
app.sessions
.can_mutate_session_branch_in_stack(view_context.session_id.as_str()),
),
open_worktree: ViewActionState::from_bool(can_open_worktree),
publish_pull_request_action: session.publish_pull_request_action(),
rebase_session_branch: ViewActionState::from_bool(
app.sessions
.can_rebase_session_branch_in_stack(view_context.session_id.as_str()),
),
reply_to_session: ViewActionState::from_bool(
app.sessions
.can_reply_to_session_in_stack(view_context.session_id.as_str()),
),
review_comments: ViewActionState::from_bool(session.has_review_request()),
session_state: help_action::session_view_state(session),
session_status,
start_staged_session: ViewActionState::from_bool(
app.sessions
.can_start_staged_session(view_context.session_id.as_str()),
),
})
}
fn apply_view_scroll_and_output_mode(app: &mut App, scroll_offset: Option<u16>) {
if let AppMode::View {
scroll_offset: view_scroll_offset,
..
} = &mut app.mode
{
*view_scroll_offset = scroll_offset;
}
}
async fn end_in_progress_turn(app: &mut App, session_id: &str) {
if pop_last_queued_chat_message_if_any(app, session_id).await {
return;
}
cancel_in_progress_turn(app, session_id).await;
}
async fn pop_last_queued_chat_message_if_any(app: &mut App, session_id: &str) -> bool {
let popped_prompt = app
.sessions
.session_handles()
.get(session_id)
.and_then(|handles| handles.queued_messages.lock().ok()?.pop_back());
let Some(popped_prompt) = popped_prompt else {
return false;
};
app.sessions.sync_session_from_handle(session_id);
app.cleanup_prompt_attachment_files(&popped_prompt).await;
app.services.emit_app_event(AppEvent::SessionUpdated {
session_id: session_id.into(),
version: SessionTaskService::next_session_update_version(
&app.services.session_update_versions(),
session_id,
),
});
true
}
async fn cancel_in_progress_turn(app: &mut App, session_id: &str) {
let timestamp_seconds =
app::session::unix_timestamp_from_system_time(app.services.clock().now_system_time());
if let Err(error) = app
.services
.db()
.operations()
.request_cancel_for_session_operations(session_id)
.await
{
warn!(
session_id = session_id,
error = %error,
"failed to request cancellation for queued session operations"
);
}
if let Some(handles) = app.sessions.session_handles().get(session_id) {
match handles.cancel_token.lock() {
Ok(cancel_token) => cancel_token.cancel(),
Err(error) => {
warn!(
session_id = session_id,
error = %error,
"failed to lock session cancel token"
);
}
}
}
if let Err(error) = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(
session_id,
&Status::Review.to_string(),
timestamp_seconds,
)
.await
{
warn!(
session_id = session_id,
error = %error,
"failed to persist review status after interrupting session turn"
);
return;
}
if let Some(handles) = app.sessions.session_handles().get(session_id)
&& let Ok(mut handle_status) = handles.status.lock()
{
*handle_status = Status::Review;
}
if let Some(session) = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == session_id)
{
session.status = Status::Review;
}
suppress_auto_review_for_stopped_turn(app, session_id);
app.services.emit_app_event(AppEvent::SessionUpdated {
session_id: session_id.into(),
version: SessionTaskService::next_session_update_version(
&app.services.session_update_versions(),
session_id,
),
});
app.services.emit_session_and_project_refresh_events();
}
fn suppress_auto_review_for_stopped_turn(app: &mut App, session_id: &str) {
app.suppress_review_output(session_id);
}
async fn switch_view_to_prompt(
app: &mut App,
view_context: &ViewContext,
history_state: PromptHistoryState,
input: InputState,
scroll_offset: Option<u16>,
) {
if input.is_empty() && app.restore_prompt_progress(&view_context.session_id).await {
return;
}
app.discard_prompt_progress(&view_context.session_id).await;
app.mode = AppMode::Prompt {
at_mention_state: None,
attachment_state: PromptAttachmentState::default(),
focus: ChatFocus::Input,
history_state,
slash_state: app.prompt_slash_state(),
session_id: view_context.session_id.clone(),
input,
scroll_offset,
};
}
async fn open_draft_prompt_with_pasted_image(
app: &mut App,
view_context: &ViewContext,
scroll_offset: Option<u16>,
) {
let Some(session) = app.sessions.session_at(view_context.session_index) else {
return;
};
let history_state = PromptHistoryState::new(session_prompt_history_entries(session));
switch_view_to_prompt(
app,
view_context,
history_state,
InputState::default(),
scroll_offset,
)
.await;
prompt::paste_image_into_active_prompt(app, &view_context.session_id).await;
}
fn open_view_help_overlay(
app: &mut App,
view_context: &ViewContext,
view_session_snapshot: &ViewSessionSnapshot,
) {
app.mode = AppMode::Help {
context: HelpContext::View {
can_fork_session: view_session_snapshot.can_fork_session(),
can_merge_session_branch: view_session_snapshot.can_merge_session_branch(),
can_mutate_session_branch: view_session_snapshot.can_mutate_session_branch(),
can_open_worktree: view_session_snapshot.can_open_worktree(),
can_rebase_session_branch: view_session_snapshot.can_rebase_session_branch(),
can_reply_to_session: view_session_snapshot.can_reply_to_session(),
can_start_staged_session: view_session_snapshot.can_start_staged_session(),
can_view_review_comments: view_session_snapshot.can_open_review_comments(),
publish_pull_request_action: view_session_snapshot.publish_pull_request_action,
session_id: view_context.session_id.clone(),
session_state: view_session_snapshot.session_state,
scroll_offset: view_context.scroll_offset,
},
scroll_offset: 0,
};
}
fn open_publish_branch_input(
app: &mut App,
view_context: &ViewContext,
publish_branch_action: PublishBranchAction,
) {
let Some(session) = app.sessions.session_at(view_context.session_index) else {
return;
};
let default_branch_name = crate::app::session::session_branch(&session.id);
let locked_upstream_ref = session.published_upstream_ref.clone();
let input = locked_upstream_ref
.as_deref()
.map(remote_branch_name_from_upstream_ref)
.map(InputState::with_text)
.unwrap_or_default();
app.mode = AppMode::PublishBranchInput {
default_branch_name,
input,
locked_upstream_ref,
publish_branch_action,
restore_view: confirmation_view_mode(view_context),
};
}
fn view_context(app: &mut App) -> Option<ViewContext> {
let (session_id, scroll_offset) = match &app.mode {
AppMode::View {
session_id,
scroll_offset,
} => (session_id.clone(), *scroll_offset),
_ => return None,
};
let Some(session_index) = app.session_index_for_id(&session_id) else {
app.mode = AppMode::List;
return None;
};
Some(ViewContext {
scroll_offset,
session_id,
session_index,
})
}
fn view_metrics<B: Backend>(
app: &App,
render_cache_store: &RenderCacheStore,
terminal: &Terminal<B>,
view_context: &ViewContext,
) -> io::Result<ChatScrollMetrics>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
let terminal_size = terminal.size().map_err(crate::runtime::backend_err)?;
let terminal_rect = Rect::new(0, 0, terminal_size.width, terminal_size.height);
Ok(ChatScrollMetrics::new(
app,
render_cache_store,
&view_context.session_id,
view_context.session_index,
terminal_rect,
))
}
fn prompt_history_entries(output: &str) -> Vec<String> {
let mut entries = Vec::new();
let mut output_lines = output.lines().peekable();
while let Some(line) = output_lines.next() {
let Some(first_prompt_line) = line.strip_prefix(" › ") else {
continue;
};
let mut prompt = first_prompt_line.to_string();
while let Some(next_line) = output_lines.peek().copied() {
if next_line.is_empty() {
break;
}
let prompt_line = next_line.strip_prefix(" ").unwrap_or(next_line);
prompt.push('\n');
prompt.push_str(prompt_line);
let _ = output_lines.next();
}
entries.push(prompt);
}
entries
}
fn session_prompt_history_entries(session: &crate::domain::session::Session) -> Vec<String> {
if session.status == Status::Draft && session.is_draft_session() {
return vec![session.prompt.clone()];
}
let Some(transcript_text) = session
.transcript
.as_ref()
.and_then(crate::domain::session_message::SessionTranscript::replay_text)
else {
return Vec::new();
};
prompt_history_entries(&transcript_text)
}
async fn open_review_output_mode(app: &mut App, view_context: &ViewContext) {
if let Some(cached) = app.review_cache.get(view_context.session_id.as_str())
&& !matches!(cached, ReviewCacheEntry::Suppressed)
{
return;
}
let Some(session) = app.sessions.session_at(view_context.session_index) else {
return;
};
let session_folder = session.folder.clone();
let diff = load_view_session_diff(app, view_context).await;
if diff.trim().is_empty() {
app.set_review_ready_output(
&view_context.session_id,
diff_content_hash(&diff),
REVIEW_NO_DIFF_MESSAGE.to_string(),
);
return;
}
if diff.starts_with("Failed to run git diff:") {
let diff_hash = diff_content_hash(&diff);
app.set_review_ready_output(&view_context.session_id, diff_hash, diff);
return;
}
let diff_hash = diff_content_hash(&diff);
let _ = app
.services
.db()
.sessions()
.update_session_focused_review(&view_context.session_id, None, None)
.await;
app.start_review_assist(&view_context.session_id, &session_folder, diff_hash, &diff);
}
async fn show_diff_for_view_session(app: &mut App, view_context: &ViewContext) -> bool {
let diff_text = load_view_session_diff(app, view_context).await;
if diff_text.trim().is_empty() {
return false;
}
diff::enter_diff_mode(app, &view_context.session_id, diff_text, None);
true
}
async fn load_view_session_diff(app: &App, view_context: &ViewContext) -> String {
let Some(session) = app.sessions.session_at(view_context.session_index) else {
return String::new();
};
let session_folder = session.folder.clone();
let base_branch = session.base_branch.clone();
match app
.services
.git_client()
.diff(session_folder, base_branch)
.await
{
Ok(diff) => diff,
Err(error) => {
warn!(
session_id = %view_context.session_id,
error = %error,
"failed to load session diff for view mode"
);
format!("Failed to run git diff: {error}")
}
}
}
async fn rebase_view_session(app: &mut App, session_id: &str) -> bool {
if let Err(error) = app.rebase_session(session_id).await {
app.append_output_for_session(session_id, &TranscriptNotice::RebaseError.format(error))
.await;
return false;
}
true
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crossterm::event::KeyModifiers;
use mockall::predicate::eq;
use tempfile::tempdir;
use super::*;
use crate::app::review_loading_message;
use crate::domain::agent::AgentModel;
use crate::domain::session::{
ForgeKind, ReviewRequest, ReviewRequestState, ReviewRequestSummary,
};
use crate::domain::session_message::{SessionMessage, SessionMessageKind, SessionTranscript};
use crate::infra::tmux::{MockTmuxClient, TmuxClient};
use crate::presentation::app_mode::PromptModeSnapshot;
use crate::presentation::prompt::PromptSlashState;
use crate::runtime::mode::session_output_metric;
use crate::ui::component::session_output::SessionOutputLineContext;
use crate::ui::page::session_chat::SessionChatPage;
fn session_replay_text(session: &crate::domain::session::Session) -> String {
session
.transcript
.as_ref()
.and_then(SessionTranscript::replay_text)
.unwrap_or_default()
}
async fn new_test_app_with_session_and_tmux_client(
tmux_client: Arc<dyn TmuxClient>,
) -> (App, tempfile::TempDir, String) {
let (mut app, base_dir) =
crate::test_support::new_git_test_app_with_tmux_client(tmux_client).await;
let session_id = app
.create_session()
.await
.expect("failed to create session");
(app, base_dir, session_id)
}
async fn new_test_app_with_session() -> (App, tempfile::TempDir, String) {
new_test_app_with_session_and_tmux_client(Arc::new(MockTmuxClient::new())).await
}
fn reply_enabled_review_snapshot() -> ViewSessionSnapshot {
ViewSessionSnapshot {
continue_terminal_session: ViewActionState::Disabled,
fork_session: ViewActionState::Enabled,
merge_session_branch: ViewActionState::Enabled,
mutate_session_branch: ViewActionState::Enabled,
rebase_session_branch: ViewActionState::Enabled,
open_worktree: ViewActionState::Enabled,
reply_to_session: ViewActionState::Enabled,
review_comments: ViewActionState::Disabled,
start_staged_session: ViewActionState::Disabled,
follow_up_task_action: None,
publish_pull_request_action: None,
session_state: ViewSessionState::Review,
session_status: Status::Review,
}
}
fn install_mock_clipboard_image_client(
app: &mut App,
mock_clipboard_image_client: crate::infra::clipboard_image::MockClipboardImageClient,
) {
let clipboard_image_client: Arc<dyn crate::infra::clipboard_image::ClipboardImageClient> =
Arc::new(mock_clipboard_image_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 available_agent_clis =
crate::domain::agent::AgentCliInfo::from_kinds(&available_agent_kinds);
let app_server_client_override = app.services.app_server_client_override();
let fs_client = app.services.fs_client();
let git_client = app.services.git_client();
let review_request_client = app.services.review_request_client();
app.services = crate::app::AppServices::new_with_agent_clis(
base_path,
app.services.clock(),
event_sender,
crate::app::AppServiceDeps {
app_server_client_override,
available_agent_kinds,
clipboard_image_client_override: Some(clipboard_image_client),
fs_client,
git_client,
one_shot_client_override: None,
repositories: db,
review_request_client,
},
available_agent_clis,
);
}
fn session_fixture(status: Status, is_draft: bool) -> crate::domain::session::Session {
crate::test_support::SessionFixtureBuilder::new()
.status(status)
.draft(is_draft)
.folder(std::env::temp_dir())
.project_name("")
.build()
}
#[tokio::test]
async fn test_view_context_returns_none_for_non_view_mode() {
let (mut app, _base_dir) = crate::test_support::new_test_app_with_mock_tmux_client().await;
app.mode = AppMode::List;
let context = view_context(&mut app);
assert!(context.is_none());
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_handle_ignores_key_when_mode_is_not_view() {
let (mut app, _base_dir) = crate::test_support::new_test_app_with_mock_tmux_client().await;
app.mode = AppMode::List;
let backend = ratatui::backend::TestBackend::new(120, 30);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let result = handle(
&mut app,
&mut terminal,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
)
.await
.expect("non-view key should be handled");
assert!(matches!(result, EventResult::Continue));
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_view_context_falls_back_to_list_when_session_is_missing() {
let (mut app, _base_dir) = crate::test_support::new_test_app_with_mock_tmux_client().await;
app.mode = AppMode::View {
session_id: "missing-session".into(),
scroll_offset: Some(2),
};
let context = view_context(&mut app);
assert!(context.is_none());
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_view_context_returns_existing_session_details() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
session_id: session_id.clone().into(),
scroll_offset: Some(4),
};
let context = view_context(&mut app);
assert!(context.is_some());
let context = context.expect("expected view context");
assert_eq!(context.session_id, session_id);
assert_eq!(context.scroll_offset, Some(4));
assert_eq!(context.session_index, 0);
}
#[tokio::test]
async fn test_view_session_snapshot_disables_actions_for_done_session() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::Done;
app.mode = AppMode::View {
session_id: session_id.into(),
scroll_offset: Some(1),
};
let context = view_context(&mut app).expect("expected view context");
let snapshot = view_session_snapshot(&app, &context).expect("expected view snapshot");
assert!(snapshot.can_continue_terminal_session());
assert!(!snapshot.can_open_worktree());
assert_eq!(snapshot.session_state, ViewSessionState::Done);
assert_eq!(snapshot.session_status, Status::Done);
}
#[tokio::test]
async fn test_view_session_snapshot_disables_continue_for_canceled_session() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::Canceled;
app.mode = AppMode::View {
session_id: session_id.into(),
scroll_offset: Some(1),
};
let context = view_context(&mut app).expect("expected view context");
let snapshot = view_session_snapshot(&app, &context).expect("expected view snapshot");
assert!(!snapshot.can_continue_terminal_session());
assert!(!snapshot.can_open_worktree());
assert_eq!(snapshot.session_state, ViewSessionState::Canceled);
assert_eq!(snapshot.session_status, Status::Canceled);
}
#[tokio::test]
async fn test_view_session_snapshot_hides_worktree_open_for_unstarted_draft_session() {
let (mut app, _base_dir) =
crate::test_support::new_git_test_app_with_tmux_client(Arc::new(MockTmuxClient::new()))
.await;
let session_id = app
.create_draft_session()
.await
.expect("failed to create draft session");
app.mode = AppMode::View {
session_id: session_id.into(),
scroll_offset: Some(1),
};
let context = view_context(&mut app).expect("expected view context");
let snapshot = view_session_snapshot(&app, &context).expect("expected view snapshot");
assert!(!snapshot.can_open_worktree());
assert_eq!(snapshot.session_state, ViewSessionState::NewSession);
assert!(snapshot.can_paste_image_into_draft_composer());
assert!(!snapshot.can_rebase_session());
}
#[tokio::test]
async fn test_view_session_snapshot_allows_start_for_stacked_draft() {
let (mut app, _base_dir, parent_session_id) = new_test_app_with_session().await;
let session_id = app
.create_draft_session()
.await
.expect("failed to create draft session");
let parent_session = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == parent_session_id)
.expect("expected parent session");
parent_session.status = Status::Review;
let session = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == session_id)
.expect("expected draft session");
session.parent_session_id = Some(parent_session_id.clone().into());
session.prompt = "staged child draft".to_string();
app.mode = AppMode::View {
session_id: session_id.into(),
scroll_offset: Some(1),
};
let context = view_context(&mut app).expect("expected view context");
let snapshot = view_session_snapshot(&app, &context).expect("expected view snapshot");
assert_eq!(snapshot.session_state, ViewSessionState::StackedDraft);
assert!(snapshot.can_start_staged_session());
assert!(snapshot.can_paste_image_into_draft_composer());
assert!(!snapshot.can_merge_session());
assert!(!snapshot.can_rebase_session());
}
#[tokio::test]
async fn test_open_draft_prompt_with_pasted_image_inserts_clipboard_image() {
let (mut app, _base_dir) =
crate::test_support::new_git_test_app_with_tmux_client(Arc::new(MockTmuxClient::new()))
.await;
let session_id = app
.create_draft_session()
.await
.expect("failed to create draft session");
app.mode = AppMode::View {
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let view_context = view_context(&mut app).expect("expected view context");
let expected_session_id = session_id.clone();
let expected_session_id_for_mock = expected_session_id.clone();
let mut clipboard_image_client =
crate::infra::clipboard_image::MockClipboardImageClient::new();
clipboard_image_client
.expect_persist_clipboard_image()
.once()
.withf(move |session_id, attachment_number| {
session_id == &expected_session_id_for_mock && *attachment_number == 1
})
.returning(|_, _| {
Box::pin(async {
Ok(crate::infra::clipboard_image::PersistedClipboardImage {
local_image_path: std::path::PathBuf::from("/tmp/draft-image.png"),
})
})
});
install_mock_clipboard_image_client(&mut app, clipboard_image_client);
open_draft_prompt_with_pasted_image(&mut app, &view_context, Some(2)).await;
assert!(matches!(
app.mode,
AppMode::Prompt {
ref input,
ref session_id,
scroll_offset: Some(2),
..
} if input.text() == "[Image #1]"
&& session_id.as_str() == expected_session_id.as_str()
));
}
#[tokio::test]
async fn test_open_draft_prompt_with_pasted_image_ignores_missing_session() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
let view_context = ViewContext {
scroll_offset: Some(2),
session_id: "missing-session".into(),
session_index: usize::MAX,
};
open_draft_prompt_with_pasted_image(&mut app, &view_context, Some(2)).await;
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_view_session_snapshot_blocks_stacked_draft_start_until_parent_review() {
let (mut app, _base_dir, parent_session_id) = new_test_app_with_session().await;
let session_id = app
.create_draft_session()
.await
.expect("failed to create draft session");
let parent_session = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == parent_session_id)
.expect("expected parent session");
parent_session.status = Status::InProgress;
let session = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == session_id)
.expect("expected draft session");
session.parent_session_id = Some(parent_session_id.into());
session.prompt = "staged child draft".to_string();
app.mode = AppMode::View {
session_id: session_id.into(),
scroll_offset: Some(1),
};
let context = view_context(&mut app).expect("expected view context");
let snapshot = view_session_snapshot(&app, &context).expect("expected view snapshot");
assert_eq!(snapshot.session_state, ViewSessionState::StackedDraft);
assert!(!snapshot.can_start_staged_session());
assert!(snapshot.can_open_prompt_composer());
}
#[tokio::test]
async fn test_view_session_snapshot_keeps_parent_reply_with_review_child() {
let (mut app, _base_dir, parent_session_id) = new_test_app_with_session().await;
let child_session_id = app
.create_draft_session()
.await
.expect("failed to create draft session");
let parent_session = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == parent_session_id)
.expect("expected parent session");
parent_session.status = Status::Review;
let child_session = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == child_session_id)
.expect("expected child session");
child_session.parent_session_id = Some(parent_session_id.clone().into());
child_session.status = Status::Review;
app.mode = AppMode::View {
session_id: parent_session_id.clone().into(),
scroll_offset: Some(1),
};
let context = view_context(&mut app).expect("expected view context");
let snapshot = view_session_snapshot(&app, &context).expect("expected view snapshot");
assert_eq!(snapshot.session_state, ViewSessionState::Review);
assert!(snapshot.can_open_prompt_composer());
assert!(snapshot.can_merge_session());
assert!(snapshot.can_rebase_session());
}
#[tokio::test]
async fn test_view_session_snapshot_blocks_parent_reply_with_running_child() {
let (mut app, _base_dir, parent_session_id) = new_test_app_with_session().await;
let child_session_id = app
.create_draft_session()
.await
.expect("failed to create draft session");
let parent_session = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == parent_session_id)
.expect("expected parent session");
parent_session.status = Status::Review;
let child_session = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == child_session_id)
.expect("expected child session");
child_session.parent_session_id = Some(parent_session_id.clone().into());
child_session.status = Status::InProgress;
app.mode = AppMode::View {
session_id: parent_session_id.clone().into(),
scroll_offset: Some(1),
};
let context = view_context(&mut app).expect("expected view context");
let snapshot = view_session_snapshot(&app, &context).expect("expected view snapshot");
assert_eq!(snapshot.session_state, ViewSessionState::Review);
assert!(!snapshot.can_open_prompt_composer());
assert!(!snapshot.can_merge_session());
assert!(!snapshot.can_rebase_session());
}
#[tokio::test]
async fn test_view_session_snapshot_reads_cached_worktree_availability() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions
.set_session_worktree_available(&session_id, false);
app.mode = AppMode::View {
session_id: session_id.into(),
scroll_offset: Some(1),
};
let context = view_context(&mut app).expect("expected view context");
let snapshot = view_session_snapshot(&app, &context).expect("expected view snapshot");
assert!(!snapshot.can_open_worktree());
}
#[tokio::test]
async fn test_view_session_snapshot_returns_none_for_stale_session_index() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
session_id: session_id.into(),
scroll_offset: Some(1),
};
let mut context = view_context(&mut app).expect("expected view context");
context.session_index = 99;
let snapshot = view_session_snapshot(&app, &context);
assert!(snapshot.is_none());
}
#[tokio::test]
async fn test_view_total_lines_counts_wrapped_output_lines() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].transcript = Some(
crate::test_support::assistant_transcript("word ".repeat(40)),
);
let raw_line_count = u16::try_from(
session_replay_text(&app.sessions.sessions()[0])
.lines()
.count(),
)
.unwrap_or(u16::MAX);
let total_lines =
session_output_metric::rendered_output_line_count(&app, &session_id, 0, 20, 5);
assert!(total_lines > raw_line_count);
}
#[test]
fn test_prompt_history_entries_extracts_user_prompts() {
let output = " › first\n\nassistant\n\n › second\n\n";
let entries = prompt_history_entries(output);
assert_eq!(entries, vec!["first".to_string(), "second".to_string()]);
}
#[test]
fn test_prompt_history_entries_keeps_multiline_prompts() {
let output = " › first line\n second line\n\nassistant\n\n";
let entries = prompt_history_entries(output);
assert_eq!(entries, vec!["first line\nsecond line".to_string()]);
}
#[test]
fn test_prompt_history_entries_keeps_multiple_blank_lines_in_prompts() {
let output = " › first line\n \n \n after gap\n\nassistant\n\n";
let entries = prompt_history_entries(output);
assert_eq!(entries, vec!["first line\n\n\nafter gap".to_string()]);
}
#[test]
fn test_prompt_history_entries_ignores_non_prompt_lines() {
let output = "assistant line\n\n";
let entries = prompt_history_entries(output);
assert!(entries.is_empty());
}
#[tokio::test]
async fn test_scroll_offset_down_does_not_jump_to_bottom_for_wrapped_output() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let transcript = SessionTranscript::new(vec![SessionMessage::conversation(
0,
SessionMessageKind::AssistantAnswer,
"word ".repeat(60),
)]);
app.sessions.sessions_mut()[0].transcript = Some(transcript);
let metrics = ChatScrollMetrics {
total_lines: session_output_metric::rendered_output_line_count(
&app,
&session_id,
0,
20,
5,
),
view_height: 5,
};
let next_offset = chat_scroll::scroll_offset_down(Some(0), metrics, 1);
assert_eq!(next_offset, Some(1));
}
#[tokio::test]
async fn test_apply_view_scroll_and_output_mode_updates_scroll_state() {
let (mut app, _base_dir, expected_session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
session_id: expected_session_id.clone().into(),
scroll_offset: Some(3),
};
apply_view_scroll_and_output_mode(&mut app, Some(1));
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
scroll_offset: Some(1),
} if session_id == &expected_session_id
));
}
#[tokio::test]
async fn test_view_total_lines_uses_default_review_model_for_loading_fallback() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.settings.default_review_selection = crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Claude,
AgentModel::ClaudeHaiku4520251001,
);
app.sessions.sessions_mut()[0].agent = crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Codex,
AgentModel::Gpt55,
);
app.sessions.sessions_mut()[0].status = Status::AgentReview;
let output_width = 14;
let viewport_height = 5;
let render_cache_store = RenderCacheStore::default();
let session = &app.sessions.sessions()[0];
let expected = SessionChatPage::rendered_output_line_count(
session,
output_width,
viewport_height,
SessionOutputLineContext {
active_prompt_output: None,
active_progress: None,
session_update_version: app.session_update_version(&session_id),
},
render_cache_store.markdown_render_cache(),
render_cache_store.session_output_layout_cache(),
);
let total_lines = session_output_metric::rendered_output_line_count(
&app,
&session_id,
0,
output_width,
viewport_height,
);
assert_eq!(total_lines, expected);
}
#[tokio::test]
async fn test_open_review_output_mode_leaves_existing_cache_unchanged() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.review_cache.insert(
session_id.clone().into(),
ReviewCacheEntry::Ready {
diff_hash: 123,
text: "Cached review".to_string(),
},
);
let view_context = ViewContext {
scroll_offset: None,
session_id: session_id.into(),
session_index: 0,
};
open_review_output_mode(&mut app, &view_context).await;
let (review_status_message, review_text) = app.review_view_state(&view_context.session_id);
assert_eq!(review_status_message, None);
assert_eq!(review_text, Some("Cached review"));
}
#[tokio::test]
async fn test_open_review_output_mode_starts_loading_when_diff_exists() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.settings.default_review_selection = crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Claude,
AgentModel::ClaudeOpus48,
);
app.sessions.sessions_mut()[0].status = Status::Review;
let session_folder = app.sessions.sessions()[0].folder.clone();
std::fs::write(session_folder.join("README.md"), "review test content\n")
.expect("failed to update readme");
let view_context = ViewContext {
scroll_offset: None,
session_id: session_id.into(),
session_index: 0,
};
open_review_output_mode(&mut app, &view_context).await;
let (review_status_message, review_text) = app.review_view_state(&view_context.session_id);
assert_eq!(
review_status_message,
Some(review_loading_message(AgentModel::ClaudeOpus48))
);
assert_eq!(review_text, None);
assert_eq!(app.sessions.sessions()[0].status, Status::AgentReview);
assert!(matches!(
app.review_cache.get(&view_context.session_id),
Some(ReviewCacheEntry::Loading { .. })
));
}
#[tokio::test]
async fn test_open_review_output_mode_shows_no_diff_message_when_diff_empty() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let view_context = ViewContext {
scroll_offset: None,
session_id: session_id.into(),
session_index: 0,
};
open_review_output_mode(&mut app, &view_context).await;
let (review_status_message, review_text) = app.review_view_state(&view_context.session_id);
assert_eq!(review_status_message, None);
assert_eq!(review_text, Some(REVIEW_NO_DIFF_MESSAGE));
assert!(matches!(
app.review_cache.get(&view_context.session_id),
Some(ReviewCacheEntry::Ready {
diff_hash,
text,
}) if *diff_hash == diff_content_hash("") && text == REVIEW_NO_DIFF_MESSAGE
));
}
#[tokio::test]
async fn test_open_review_output_mode_ignores_stale_session_selection() {
let (app, _base_dir, session_id) = new_test_app_with_session().await;
let view_context = ViewContext {
scroll_offset: None,
session_id: session_id.into(),
session_index: 99,
};
let mut app = app;
open_review_output_mode(&mut app, &view_context).await;
assert!(!app.review_cache.contains_key(&view_context.session_id));
}
#[tokio::test]
async fn test_show_diff_for_view_session_switches_mode_to_diff() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let session_folder = app.sessions.sessions()[0].folder.clone();
std::fs::write(session_folder.join("README.md"), "updated content")
.expect("failed to write diff fixture");
let context = ViewContext {
scroll_offset: Some(0),
session_id: session_id.clone().into(),
session_index: 0,
};
let opened = show_diff_for_view_session(&mut app, &context).await;
assert!(opened);
assert!(matches!(
app.mode,
AppMode::Diff {
ref session_id,
scroll_offset: 0,
..
} if session_id == &context.session_id
));
}
#[tokio::test]
async fn test_show_diff_for_view_session_keeps_view_mode_when_diff_is_empty() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
session_id: session_id.clone().into(),
scroll_offset: Some(0),
};
let context = ViewContext {
scroll_offset: Some(0),
session_id: session_id.clone().into(),
session_index: 0,
};
let opened = show_diff_for_view_session(&mut app, &context).await;
assert!(!opened);
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
scroll_offset: Some(0),
..
} if session_id == &context.session_id
));
}
#[tokio::test]
async fn test_show_diff_for_view_session_uses_error_message_outside_git_repo() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let non_git_dir = tempdir().expect("failed to create non-git dir");
app.sessions.sessions_mut()[0].folder = non_git_dir.path().to_path_buf();
let context = ViewContext {
scroll_offset: Some(0),
session_id: session_id.clone().into(),
session_index: 0,
};
let opened = show_diff_for_view_session(&mut app, &context).await;
assert!(opened);
assert!(matches!(
app.mode,
AppMode::Diff {
ref session_id,
ref diff,
scroll_offset: 0,
..
} if session_id == &context.session_id && diff.contains("Failed to run git diff:")
));
}
#[tokio::test]
async fn test_load_view_session_diff_returns_empty_string_for_stale_session_index() {
let (app, _base_dir, session_id) = new_test_app_with_session().await;
let context = ViewContext {
scroll_offset: Some(0),
session_id: session_id.into(),
session_index: 99,
};
let diff = load_view_session_diff(&app, &context).await;
assert!(diff.is_empty());
}
#[tokio::test]
async fn test_append_output_for_session_appends_text() {
let (app, _base_dir, session_id) = new_test_app_with_session().await;
let mut app = app;
app.append_output_for_session(&session_id, "line one").await;
app.sessions.sync_from_handles();
let output = session_replay_text(&app.sessions.sessions()[0]);
assert_eq!(output, "line one");
}
#[tokio::test]
async fn test_open_merge_confirmation_sets_confirmation_mode_with_view_restore_state() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
session_id: session_id.clone().into(),
scroll_offset: Some(5),
};
let context = view_context(&mut app).expect("expected view context");
open_merge_confirmation(&mut app, &context);
assert!(matches!(
app.mode,
AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::MergeSession,
ref confirmation_message,
ref confirmation_title,
restore_view: Some(ConfirmationViewMode {
scroll_offset: Some(5),
session_id: ref restored_session_id,
}),
session_id: Some(ref mode_session_id),
selected_confirmation_index: DEFAULT_OPTION_INDEX,
} if confirmation_title == "Confirm Merge"
&& confirmation_message == "Add this session to merge queue?"
&& restored_session_id == &session_id
&& mode_session_id == &session_id
));
}
#[tokio::test]
async fn test_open_worktree_for_view_session_opens_command_selector_for_multiple_commands() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.settings.launch_configuration = "cargo test\nnpm run dev".to_string();
app.mode = AppMode::View {
session_id: session_id.clone().into(),
scroll_offset: Some(4),
};
let context = view_context(&mut app).expect("expected view context");
open_worktree_for_view_session(&mut app, &context).await;
assert!(matches!(
app.mode,
AppMode::LaunchConfigurationSelector {
ref commands,
restore_view:
ConfirmationViewMode {
session_id: ref restored_session_id,
scroll_offset: Some(4),
},
selected_command_index: 0,
} if commands == &vec!["cargo test".to_string(), "npm run dev".to_string()]
&& restored_session_id == &session_id
));
}
#[tokio::test]
async fn test_open_worktree_for_view_session_keeps_view_mode_for_single_command() {
let mut mock_tmux_client = MockTmuxClient::new();
mock_tmux_client
.expect_open_window_for_folder()
.times(1)
.returning(|_| Box::pin(async { Some("@42".to_string()) }));
mock_tmux_client
.expect_run_command_in_window()
.with(eq("@42".to_string()), eq("cargo test".to_string()))
.times(1)
.returning(|_, _| Box::pin(async {}));
let (mut app, _base_dir, session_id) =
new_test_app_with_session_and_tmux_client(Arc::new(mock_tmux_client)).await;
app.settings.launch_configuration = "cargo test".to_string();
app.mode = AppMode::View {
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let context = view_context(&mut app).expect("expected view context");
open_worktree_for_view_session(&mut app, &context).await;
assert!(matches!(
app.mode,
AppMode::View {
session_id: ref mode_session_id,
scroll_offset: Some(2),
} if mode_session_id == &session_id
));
}
#[tokio::test]
async fn test_rebase_view_session_appends_error_output_without_review_status() {
let (app, _base_dir, session_id) = new_test_app_with_session().await;
let mut app = app;
rebase_view_session(&mut app, &session_id).await;
app.sessions.sync_from_handles();
let output = session_replay_text(&app.sessions.sessions()[0]);
assert!(output.contains("[Sync Error]"));
}
#[tokio::test]
async fn test_open_view_help_overlay_preserves_view_context() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let view_context = ViewContext {
scroll_offset: Some(3),
session_id: session_id.clone().into(),
session_index: 0,
};
let view_session_snapshot = ViewSessionSnapshot {
continue_terminal_session: ViewActionState::Disabled,
fork_session: ViewActionState::Enabled,
merge_session_branch: ViewActionState::Enabled,
mutate_session_branch: ViewActionState::Enabled,
rebase_session_branch: ViewActionState::Enabled,
open_worktree: ViewActionState::Enabled,
reply_to_session: ViewActionState::Enabled,
review_comments: ViewActionState::Disabled,
start_staged_session: ViewActionState::Disabled,
follow_up_task_action: None,
publish_pull_request_action: Some(PublishBranchAction::PublishPullRequest),
session_state: ViewSessionState::Review,
session_status: Status::Review,
};
open_view_help_overlay(&mut app, &view_context, &view_session_snapshot);
assert!(matches!(
app.mode,
AppMode::Help {
context: HelpContext::View {
can_fork_session: true,
can_merge_session_branch: true,
can_mutate_session_branch: true,
can_open_worktree: true,
can_start_staged_session: false,
publish_pull_request_action: Some(PublishBranchAction::PublishPullRequest),
session_id: ref session_id_in_mode,
session_state: ViewSessionState::Review,
scroll_offset: Some(3),
..
},
scroll_offset: 0,
} if session_id_in_mode == &session_id
));
}
#[tokio::test]
async fn test_open_publish_branch_input_preserves_view_context() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let view_context = ViewContext {
scroll_offset: Some(5),
session_id: session_id.clone().into(),
session_index: 0,
};
open_publish_branch_input(
&mut app,
&view_context,
PublishBranchAction::PublishPullRequest,
);
assert!(matches!(
app.mode,
AppMode::PublishBranchInput {
ref default_branch_name,
input: ref input_state,
locked_upstream_ref: None,
publish_branch_action: PublishBranchAction::PublishPullRequest,
restore_view:
ConfirmationViewMode {
session_id: ref restored_session_id,
scroll_offset: Some(5),
},
} if default_branch_name == &crate::app::session::session_branch(&session_id)
&& input_state.cursor == 0
&& input_state.text().is_empty()
&& restored_session_id == &session_id
));
}
#[tokio::test]
async fn test_open_publish_branch_input_locks_existing_upstream_branch_name() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].published_upstream_ref =
Some("origin/review/custom".to_string());
let view_context = ViewContext {
scroll_offset: Some(1),
session_id: session_id.into(),
session_index: 0,
};
open_publish_branch_input(
&mut app,
&view_context,
PublishBranchAction::PublishPullRequest,
);
assert!(matches!(
app.mode,
AppMode::PublishBranchInput {
input: ref input_state,
locked_upstream_ref: Some(ref upstream_ref),
..
} if upstream_ref == "origin/review/custom"
&& input_state.text() == "review/custom"
));
}
#[test]
fn test_view_session_state_maps_merge_queue_statuses() {
let merge_queue_statuses = [Status::Queued, Status::Merging];
let mapped_states: Vec<ViewSessionState> = merge_queue_statuses
.iter()
.map(|status| help_action::session_view_state(&session_fixture(*status, false)))
.collect();
assert!(
mapped_states
.iter()
.all(|state| *state == ViewSessionState::MergeQueue)
);
}
#[test]
fn test_view_session_state_maps_rebasing_status() {
let status = Status::Rebasing;
let session = session_fixture(status, false);
let state = help_action::session_view_state(&session);
assert_eq!(state, ViewSessionState::Rebasing);
}
#[test]
fn test_view_session_state_maps_stacked_draft_status() {
let session = crate::test_support::SessionFixtureBuilder::new()
.status(Status::Draft)
.draft(true)
.parent_session_id(Some("parent-session".into()))
.folder(std::env::temp_dir())
.project_name("")
.build();
let state = help_action::session_view_state(&session);
assert_eq!(state, ViewSessionState::StackedDraft);
}
#[test]
fn test_view_session_state_maps_canceled_status() {
let status = Status::Canceled;
let session = session_fixture(status, false);
let state = help_action::session_view_state(&session);
assert_eq!(state, ViewSessionState::Canceled);
}
#[tokio::test]
async fn test_open_review_output_mode_uses_ready_cache_entry() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let cached_text = "## Review\nCached review from auto-generation.";
app.review_cache.insert(
session_id.clone().into(),
ReviewCacheEntry::Ready {
diff_hash: 123,
text: cached_text.to_string(),
},
);
let view_context = ViewContext {
scroll_offset: None,
session_id: session_id.into(),
session_index: 0,
};
open_review_output_mode(&mut app, &view_context).await;
let (review_status_message, review_text) = app.review_view_state(&view_context.session_id);
assert_eq!(review_status_message, None);
assert_eq!(review_text, Some(cached_text));
}
#[tokio::test]
async fn test_open_review_output_mode_shows_loading_for_cache_loading_entry() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.settings.default_review_selection = crate::domain::agent::AgentSelection::new(
crate::domain::agent::AgentKind::Claude,
AgentModel::ClaudeOpus48,
);
app.review_cache.insert(
session_id.clone().into(),
ReviewCacheEntry::Loading { diff_hash: 456 },
);
let view_context = ViewContext {
scroll_offset: None,
session_id: session_id.into(),
session_index: 0,
};
open_review_output_mode(&mut app, &view_context).await;
let (review_status_message, review_text) = app.review_view_state(&view_context.session_id);
assert_eq!(
review_status_message,
Some(review_loading_message(AgentModel::ClaudeOpus48))
);
assert_eq!(review_text, None);
}
#[tokio::test]
async fn test_open_or_regenerate_review_opens_when_review_output_is_missing() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let view_context = ViewContext {
scroll_offset: Some(5),
session_id: session_id.into(),
session_index: 0,
};
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
open_or_regenerate_review(&mut app, &view_context, &mut pending_update).await;
assert_eq!(pending_update.scroll_offset, None);
}
#[tokio::test]
async fn test_open_or_regenerate_shows_confirmation_when_review_output_exists() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.review_cache.insert(
session_id.clone().into(),
ReviewCacheEntry::Ready {
text: "Old review".to_string(),
diff_hash: 123,
},
);
let view_context = ViewContext {
scroll_offset: None,
session_id: session_id.clone().into(),
session_index: 0,
};
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
open_or_regenerate_review(&mut app, &view_context, &mut pending_update).await;
assert!(matches!(
app.mode,
AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::RegenerateReview,
..
}
));
assert!(app.review_cache.contains_key(session_id.as_str()));
}
#[tokio::test]
async fn test_open_or_regenerate_skips_when_loading_in_progress() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.review_cache.insert(
session_id.clone().into(),
ReviewCacheEntry::Loading { diff_hash: 42 },
);
app.mode = AppMode::View {
scroll_offset: None,
session_id: session_id.clone().into(),
};
let view_context = ViewContext {
scroll_offset: None,
session_id: session_id.clone().into(),
session_index: 0,
};
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
open_or_regenerate_review(&mut app, &view_context, &mut pending_update).await;
assert!(matches!(
app.review_cache.get(session_id.as_str()),
Some(ReviewCacheEntry::Loading { diff_hash: 42 })
));
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
..
} if session_id == &view_context.session_id
));
}
#[tokio::test]
async fn test_handle_view_key_ignores_diff_for_non_review_status() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let view_context = view_context(&mut app).expect("expected view context");
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
let view_session_snapshot = ViewSessionSnapshot {
continue_terminal_session: ViewActionState::Disabled,
fork_session: ViewActionState::Disabled,
merge_session_branch: ViewActionState::Enabled,
mutate_session_branch: ViewActionState::Enabled,
rebase_session_branch: ViewActionState::Enabled,
open_worktree: ViewActionState::Disabled,
reply_to_session: ViewActionState::Enabled,
review_comments: ViewActionState::Disabled,
start_staged_session: ViewActionState::Disabled,
follow_up_task_action: None,
publish_pull_request_action: None,
session_state: ViewSessionState::Done,
session_status: Status::Done,
};
let view_key_context = ViewKeyContext {
context: &view_context,
metrics: ChatScrollMetrics {
total_lines: 10,
view_height: 5,
},
session_snapshot: &view_session_snapshot,
};
let should_apply = handle_view_key(
&mut app,
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
view_key_context,
&mut pending_update,
)
.await;
assert!(should_apply);
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
scroll_offset: Some(2),
..
} if session_id == &view_context.session_id
));
assert_eq!(pending_update.scroll_offset, Some(2));
}
#[tokio::test]
async fn test_handle_view_key_uppercase_f_does_not_start_review_when_fork_unavailable() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::Review;
app.mode = AppMode::View {
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let view_context = view_context(&mut app).expect("expected view context");
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
let view_session_snapshot = ViewSessionSnapshot {
continue_terminal_session: ViewActionState::Disabled,
fork_session: ViewActionState::Disabled,
merge_session_branch: ViewActionState::Enabled,
mutate_session_branch: ViewActionState::Enabled,
rebase_session_branch: ViewActionState::Enabled,
open_worktree: ViewActionState::Enabled,
reply_to_session: ViewActionState::Enabled,
review_comments: ViewActionState::Disabled,
start_staged_session: ViewActionState::Disabled,
follow_up_task_action: None,
publish_pull_request_action: None,
session_state: ViewSessionState::Review,
session_status: Status::Review,
};
let view_key_context = ViewKeyContext {
context: &view_context,
metrics: ChatScrollMetrics {
total_lines: 10,
view_height: 5,
},
session_snapshot: &view_session_snapshot,
};
let should_apply = handle_view_key(
&mut app,
KeyEvent::new(KeyCode::Char('F'), KeyModifiers::NONE),
view_key_context,
&mut pending_update,
)
.await;
assert!(should_apply);
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
scroll_offset: Some(2),
} if session_id == &view_context.session_id
));
assert!(!app.review_cache.contains_key(session_id.as_str()));
}
#[tokio::test]
async fn test_handle_launch_follow_up_task_key_opens_linked_sibling_session() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let sibling_session_id = app
.create_session()
.await
.expect("failed to create sibling session");
let source_session = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == session_id)
.expect("expected source session in session list");
source_session.follow_up_tasks = vec![crate::domain::session::SessionFollowUpTask {
id: 1,
launched_session_id: Some(sibling_session_id.clone().into()),
position: 0,
text: "Open the sibling session.".to_string(),
}];
app.mode = AppMode::View {
session_id: session_id.into(),
scroll_offset: Some(0),
};
let backend = ratatui::backend::TestBackend::new(120, 30);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let result = handle(
&mut app,
&mut terminal,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
)
.await
.expect("launch/open key should be handled");
assert!(matches!(result, EventResult::Continue));
assert_eq!(
app.selected_session().map(|session| session.id.as_str()),
Some(sibling_session_id.as_str())
);
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
..
} if session_id == &sibling_session_id
));
}
#[tokio::test]
async fn test_handle_continue_key_opens_confirmation_for_done_session() {
let (mut app, _base_dir, source_session_id) = new_test_app_with_session().await;
let source_session = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == source_session_id)
.expect("expected source session in session list");
source_session.status = Status::Done;
source_session.summary = Some("# Summary\n\nKeep going.".to_string());
source_session.title = Some("Done source".to_string());
app.mode = AppMode::View {
session_id: source_session_id.clone().into(),
scroll_offset: Some(0),
};
let backend = ratatui::backend::TestBackend::new(120, 30);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let result = handle(
&mut app,
&mut terminal,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
)
.await
.expect("continue key should be handled");
assert!(matches!(result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::ContinueSession,
ref confirmation_title,
ref confirmation_message,
ref restore_view,
ref session_id,
..
} if confirmation_title == "Confirm Continue"
&& confirmation_message
== "Create a new draft session with initial context from this session?"
&& matches!(restore_view, Some(restore_view) if restore_view.session_id == source_session_id)
&& matches!(session_id, Some(session_id) if session_id.as_str() == source_session_id)
));
}
#[tokio::test]
async fn test_linked_terminal_session_routes_c_to_comments_and_shift_c_to_continue() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let session = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == session_id)
.expect("session should exist");
session.review_request = Some(ReviewRequest {
last_refreshed_at: 0,
summary: ReviewRequestSummary {
display_id: "#42".to_string(),
forge_kind: ForgeKind::GitHub,
source_branch: "wt/linked-terminal".to_string(),
state: ReviewRequestState::Open,
status_summary: None,
target_branch: "main".to_string(),
title: "Linked terminal session".to_string(),
web_url: "https://github.com/agentty-xyz/agentty/pull/42".to_string(),
},
});
app.mode = AppMode::View {
session_id: session_id.into(),
scroll_offset: Some(0),
};
let view_context = view_context(&mut app).expect("expected view context");
let pending_update = ViewPendingUpdate::from_context(&view_context);
let view_session_snapshot = ViewSessionSnapshot {
continue_terminal_session: ViewActionState::Enabled,
fork_session: ViewActionState::Disabled,
follow_up_task_action: None,
merge_session_branch: ViewActionState::Disabled,
mutate_session_branch: ViewActionState::Disabled,
open_worktree: ViewActionState::Disabled,
publish_pull_request_action: None,
rebase_session_branch: ViewActionState::Disabled,
reply_to_session: ViewActionState::Disabled,
review_comments: ViewActionState::Enabled,
session_state: ViewSessionState::Done,
session_status: Status::Done,
start_staged_session: ViewActionState::Disabled,
};
let lowercase_continues = is_continue_terminal_shortcut(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
&view_session_snapshot,
);
let uppercase_continues = is_continue_terminal_shortcut(
KeyEvent::new(KeyCode::Char('C'), KeyModifiers::SHIFT),
&view_session_snapshot,
);
assert!(!lowercase_continues);
assert!(uppercase_continues);
let comments_result = handle_primary_view_key(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
&view_context,
&view_session_snapshot,
&pending_update,
)
.await;
assert_eq!(comments_result, Some(false));
assert!(matches!(app.mode, AppMode::ReviewComments { .. }));
let continue_result = handle_primary_view_key(
&mut app,
KeyEvent::new(KeyCode::Char('C'), KeyModifiers::SHIFT),
&view_context,
&view_session_snapshot,
&pending_update,
)
.await;
assert_eq!(continue_result, Some(false));
assert!(matches!(
app.mode,
AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::ContinueSession,
..
}
));
}
#[tokio::test]
async fn test_handle_continue_key_does_not_open_confirmation_for_canceled_session() {
let (mut app, _base_dir, source_session_id) = new_test_app_with_session().await;
app.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == source_session_id)
.expect("expected source session")
.status = Status::Canceled;
app.mode = AppMode::View {
session_id: source_session_id.clone().into(),
scroll_offset: Some(0),
};
let backend = ratatui::backend::TestBackend::new(120, 30);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let result = handle(
&mut app,
&mut terminal,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
)
.await
.expect("c key should be handled");
assert!(matches!(result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
..
} if session_id.as_str() == source_session_id
));
}
#[tokio::test]
async fn test_handle_view_key_enter_opens_empty_prompt_composer() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let view_context = view_context(&mut app).expect("expected view context");
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
let view_session_snapshot = reply_enabled_review_snapshot();
let view_key_context = ViewKeyContext {
context: &view_context,
metrics: ChatScrollMetrics {
total_lines: 10,
view_height: 5,
},
session_snapshot: &view_session_snapshot,
};
let should_apply = handle_view_key(
&mut app,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
view_key_context,
&mut pending_update,
)
.await;
assert!(should_apply);
assert!(matches!(
app.mode,
AppMode::Prompt {
ref input,
ref session_id,
scroll_offset: Some(2),
..
} if input.is_empty() && session_id == &view_context.session_id
));
}
#[tokio::test]
async fn test_slash_prompt_replacement_prevents_saved_prompt_restoration() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let view_context = view_context(&mut app).expect("expected view context");
app.save_prompt_progress(PromptModeSnapshot {
at_mention_state: None,
attachment_state: PromptAttachmentState::default(),
history_state: PromptHistoryState::new(Vec::new()),
input: InputState::with_text("saved reply".to_string()),
scroll_offset: Some(4),
session_id: session_id.into(),
slash_state: PromptSlashState::default(),
});
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
let view_session_snapshot = reply_enabled_review_snapshot();
let view_key_context = ViewKeyContext {
context: &view_context,
metrics: ChatScrollMetrics {
total_lines: 10,
view_height: 5,
},
session_snapshot: &view_session_snapshot,
};
let should_apply = handle_view_key(
&mut app,
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
view_key_context,
&mut pending_update,
)
.await;
let opened_prefilled_slash = matches!(
app.mode,
AppMode::Prompt {
ref input,
ref session_id,
scroll_offset: Some(2),
..
} if input.text() == "/"
&& input.cursor == 1
&& session_id == &view_context.session_id
);
app.mode = AppMode::View {
session_id: view_context.session_id.clone(),
scroll_offset: Some(2),
};
switch_view_to_prompt(
&mut app,
&view_context,
PromptHistoryState::new(Vec::new()),
InputState::default(),
Some(2),
)
.await;
assert!(should_apply);
assert!(opened_prefilled_slash);
assert!(matches!(
app.mode,
AppMode::Prompt {
ref input,
ref session_id,
scroll_offset: Some(2),
..
} if input.is_empty()
&& session_id == &view_context.session_id
));
assert!(app.prompt_progress.is_empty());
}
#[tokio::test]
async fn test_switch_view_to_prompt_restores_saved_progress() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let view_context = view_context(&mut app).expect("expected view context");
app.save_prompt_progress(PromptModeSnapshot {
at_mention_state: None,
attachment_state: PromptAttachmentState::default(),
history_state: PromptHistoryState::new(vec!["previous".to_string()]),
input: InputState::with_text("saved reply".to_string()),
scroll_offset: Some(4),
session_id: session_id.into(),
slash_state: PromptSlashState::default(),
});
switch_view_to_prompt(
&mut app,
&view_context,
PromptHistoryState::new(Vec::new()),
InputState::default(),
Some(2),
)
.await;
assert!(matches!(
&app.mode,
AppMode::Prompt {
focus: ChatFocus::Input,
history_state,
input,
scroll_offset: Some(4),
..
} if history_state.entries == vec!["previous".to_string()]
&& input.text() == "saved reply"
));
assert!(app.prompt_progress.is_empty());
}
#[tokio::test]
async fn test_handle_view_key_p_opens_review_request_publish_input() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let view_context = view_context(&mut app).expect("expected view context");
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
let view_session_snapshot = ViewSessionSnapshot {
continue_terminal_session: ViewActionState::Disabled,
fork_session: ViewActionState::Enabled,
merge_session_branch: ViewActionState::Enabled,
mutate_session_branch: ViewActionState::Enabled,
rebase_session_branch: ViewActionState::Enabled,
open_worktree: ViewActionState::Enabled,
reply_to_session: ViewActionState::Enabled,
review_comments: ViewActionState::Disabled,
start_staged_session: ViewActionState::Disabled,
follow_up_task_action: None,
publish_pull_request_action: Some(PublishBranchAction::PublishPullRequest),
session_state: ViewSessionState::Review,
session_status: Status::Review,
};
let view_key_context = ViewKeyContext {
context: &view_context,
metrics: ChatScrollMetrics {
total_lines: 10,
view_height: 5,
},
session_snapshot: &view_session_snapshot,
};
let should_apply = handle_view_key(
&mut app,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
view_key_context,
&mut pending_update,
)
.await;
assert!(!should_apply);
assert!(matches!(
app.mode,
AppMode::PublishBranchInput {
publish_branch_action: PublishBranchAction::PublishPullRequest,
ref restore_view,
..
} if restore_view.session_id == session_id
));
}
#[tokio::test]
async fn test_handle_view_key_shift_p_opens_review_request_publish_input() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let view_context = view_context(&mut app).expect("expected view context");
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
let view_session_snapshot = ViewSessionSnapshot {
continue_terminal_session: ViewActionState::Disabled,
fork_session: ViewActionState::Enabled,
merge_session_branch: ViewActionState::Enabled,
mutate_session_branch: ViewActionState::Enabled,
rebase_session_branch: ViewActionState::Enabled,
open_worktree: ViewActionState::Enabled,
reply_to_session: ViewActionState::Enabled,
review_comments: ViewActionState::Disabled,
start_staged_session: ViewActionState::Disabled,
follow_up_task_action: None,
publish_pull_request_action: Some(PublishBranchAction::PublishPullRequest),
session_state: ViewSessionState::Review,
session_status: Status::Review,
};
let view_key_context = ViewKeyContext {
context: &view_context,
metrics: ChatScrollMetrics {
total_lines: 10,
view_height: 5,
},
session_snapshot: &view_session_snapshot,
};
let should_apply = handle_view_key(
&mut app,
KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT),
view_key_context,
&mut pending_update,
)
.await;
assert!(!should_apply);
assert!(matches!(
app.mode,
AppMode::PublishBranchInput {
publish_branch_action: PublishBranchAction::PublishPullRequest,
ref restore_view,
..
} if restore_view.session_id == session_id
));
}
#[tokio::test]
async fn test_handle_view_key_ignores_status_gated_actions() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let view_context = view_context(&mut app).expect("expected view context");
let view_metrics = ChatScrollMetrics {
total_lines: 10,
view_height: 5,
};
for key in [
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT),
] {
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
let view_session_snapshot = ViewSessionSnapshot {
continue_terminal_session: ViewActionState::Disabled,
fork_session: ViewActionState::Disabled,
merge_session_branch: ViewActionState::Enabled,
mutate_session_branch: ViewActionState::Enabled,
rebase_session_branch: ViewActionState::Enabled,
open_worktree: ViewActionState::Disabled,
reply_to_session: ViewActionState::Enabled,
review_comments: ViewActionState::Disabled,
start_staged_session: ViewActionState::Disabled,
follow_up_task_action: None,
publish_pull_request_action: None,
session_state: ViewSessionState::Done,
session_status: Status::Done,
};
let view_key_context = ViewKeyContext {
context: &view_context,
metrics: view_metrics,
session_snapshot: &view_session_snapshot,
};
let should_apply =
handle_view_key(&mut app, key, view_key_context, &mut pending_update).await;
assert!(should_apply);
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
scroll_offset: Some(2),
..
} if session_id == &view_context.session_id
));
assert_eq!(pending_update.scroll_offset, Some(2));
}
}
#[tokio::test]
async fn test_q_always_transitions_to_list() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let view_context = ViewContext {
scroll_offset: Some(10),
session_id: session_id.into(),
session_index: 0,
};
let pending_update = ViewPendingUpdate::from_context(&view_context);
app.mode = AppMode::List;
assert!(matches!(app.mode, AppMode::List));
assert_eq!(pending_update.scroll_offset, Some(10));
}
#[tokio::test]
async fn test_end_in_progress_turn_transitions_session_to_review() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::InProgress;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::InProgress.to_string(), 0)
.await;
app.sessions.session_handles_mut().insert(
session_id.clone().into(),
crate::domain::session::SessionHandles::new(Status::InProgress),
);
end_in_progress_turn(&mut app, &session_id).await;
assert_eq!(app.sessions.sessions()[0].status, Status::Review);
let handle_status = *app
.sessions
.session_handles()
.get(session_id.as_str())
.expect("handles missing")
.status
.lock()
.expect("lock failed");
assert_eq!(handle_status, Status::Review);
}
#[tokio::test]
async fn test_end_in_progress_turn_does_not_send_sigterm_directly() {
let mut child = tokio::process::Command::new("sleep")
.arg("60")
.spawn()
.expect("failed to spawn sleep");
let child_pid = child.id().expect("child has no pid");
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::InProgress;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::InProgress.to_string(), 0)
.await;
let handles = crate::domain::session::SessionHandles::new(Status::InProgress);
if let Ok(mut guard) = handles.child_pid.lock() {
*guard = Some(child_pid);
}
app.sessions
.session_handles_mut()
.insert(session_id.clone().into(), handles);
end_in_progress_turn(&mut app, &session_id).await;
assert!(
child.try_wait().expect("try_wait failed").is_none(),
"child should still be running — UI must not send SIGTERM"
);
child.kill().await.expect("failed to kill child");
}
#[tokio::test]
async fn test_end_in_progress_turn_cancels_token() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::InProgress;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::InProgress.to_string(), 0)
.await;
let handles = crate::domain::session::SessionHandles::new(Status::InProgress);
let cancel_token = std::sync::Arc::clone(&handles.cancel_token);
app.sessions
.session_handles_mut()
.insert(session_id.clone().into(), handles);
end_in_progress_turn(&mut app, &session_id).await;
let is_cancelled = cancel_token
.lock()
.expect("cancel token lock")
.is_cancelled();
assert!(
is_cancelled,
"cancel_token should be cancelled by end_in_progress_turn"
);
}
#[tokio::test]
async fn test_end_in_progress_turn_keeps_review_session_review_ready() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::Review;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::Review.to_string(), 0)
.await;
app.sessions.session_handles_mut().insert(
session_id.clone().into(),
crate::domain::session::SessionHandles::new(Status::Review),
);
end_in_progress_turn(&mut app, &session_id).await;
assert_eq!(app.sessions.sessions()[0].status, Status::Review);
let handle_status = *app
.sessions
.session_handles()
.get(session_id.as_str())
.expect("handles missing")
.status
.lock()
.expect("lock failed");
assert_eq!(handle_status, Status::Review);
}
#[tokio::test]
async fn test_end_in_progress_turn_first_press_with_queue_pops_last_queued_message() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::InProgress;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::InProgress.to_string(), 0)
.await;
let handles = crate::domain::session::SessionHandles::new(Status::InProgress);
let cancel_token = std::sync::Arc::clone(&handles.cancel_token);
let queued_messages = std::sync::Arc::clone(&handles.queued_messages);
{
let mut queued = queued_messages.lock().expect("queued_messages lock");
queued.push_back(crate::domain::turn_prompt::TurnPrompt::from("first queued"));
queued.push_back(crate::domain::turn_prompt::TurnPrompt::from(
"second queued",
));
}
app.sessions
.session_handles_mut()
.insert(session_id.clone().into(), handles);
app.sessions.sessions_mut()[0].queued_messages =
vec!["first queued".to_string(), "second queued".to_string()];
end_in_progress_turn(&mut app, &session_id).await;
let remaining_handle_queue: Vec<String> = queued_messages
.lock()
.expect("queued_messages lock")
.iter()
.map(crate::domain::turn_prompt::TurnPrompt::transcript_text)
.collect();
assert_eq!(
remaining_handle_queue,
vec!["first queued".to_string()],
"only the most recently queued chat message should be popped on first Ctrl+C"
);
assert_eq!(
app.sessions.sessions()[0].queued_messages,
vec!["first queued".to_string()],
"snapshot queued_messages should mirror the handle after LIFO pop"
);
assert_eq!(
app.sessions.sessions()[0].status,
Status::InProgress,
"status should stay InProgress while only a queued message is popped"
);
let handle_status = *app
.sessions
.session_handles()
.get(session_id.as_str())
.expect("handles missing")
.status
.lock()
.expect("lock failed");
assert_eq!(handle_status, Status::InProgress);
assert!(
!cancel_token
.lock()
.expect("cancel token lock")
.is_cancelled(),
"cancel_token must not be cancelled when only a queued message is popped"
);
}
#[tokio::test]
async fn test_end_in_progress_turn_drains_queue_one_press_at_a_time_then_cancels() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::InProgress;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::InProgress.to_string(), 0)
.await;
let handles = crate::domain::session::SessionHandles::new(Status::InProgress);
let cancel_token = std::sync::Arc::clone(&handles.cancel_token);
let queued_messages = std::sync::Arc::clone(&handles.queued_messages);
{
let mut queued = queued_messages.lock().expect("queued_messages lock");
queued.push_back(crate::domain::turn_prompt::TurnPrompt::from("first queued"));
queued.push_back(crate::domain::turn_prompt::TurnPrompt::from(
"second queued",
));
}
app.sessions
.session_handles_mut()
.insert(session_id.clone().into(), handles);
app.sessions.sessions_mut()[0].queued_messages =
vec!["first queued".to_string(), "second queued".to_string()];
end_in_progress_turn(&mut app, &session_id).await;
end_in_progress_turn(&mut app, &session_id).await;
assert!(
queued_messages
.lock()
.expect("queued_messages lock")
.is_empty(),
"queue should be drained after one press per queued message"
);
assert!(
app.sessions.sessions()[0].queued_messages.is_empty(),
"snapshot queued_messages should be empty after LIFO drain"
);
assert_eq!(app.sessions.sessions()[0].status, Status::InProgress);
assert!(
!cancel_token
.lock()
.expect("cancel token lock")
.is_cancelled(),
"cancel_token must not be cancelled while queued messages are still being drained"
);
end_in_progress_turn(&mut app, &session_id).await;
assert!(
cancel_token
.lock()
.expect("cancel token lock")
.is_cancelled(),
"cancel_token must be cancelled once the queue is drained"
);
assert_eq!(app.sessions.sessions()[0].status, Status::Review);
}
#[tokio::test]
async fn test_end_in_progress_turn_second_press_after_empty_queue_cancels_turn() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::InProgress;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::InProgress.to_string(), 0)
.await;
let handles = crate::domain::session::SessionHandles::new(Status::InProgress);
let cancel_token = std::sync::Arc::clone(&handles.cancel_token);
app.sessions
.session_handles_mut()
.insert(session_id.clone().into(), handles);
end_in_progress_turn(&mut app, &session_id).await;
assert!(
cancel_token
.lock()
.expect("cancel token lock")
.is_cancelled(),
"cancel_token must be cancelled when the queue is empty"
);
assert_eq!(app.sessions.sessions()[0].status, Status::Review);
let handle_status = *app
.sessions
.session_handles()
.get(session_id.as_str())
.expect("handles missing")
.status
.lock()
.expect("lock failed");
assert_eq!(handle_status, Status::Review);
}
#[tokio::test]
async fn test_pop_last_queued_chat_message_resyncs_snapshot_after_worker_drain() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::InProgress;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::InProgress.to_string(), 0)
.await;
let handles = crate::domain::session::SessionHandles::new(Status::InProgress);
let queued_messages = std::sync::Arc::clone(&handles.queued_messages);
{
let mut queued = queued_messages.lock().expect("queued_messages lock");
queued.push_back(crate::domain::turn_prompt::TurnPrompt::from("first queued"));
queued.push_back(crate::domain::turn_prompt::TurnPrompt::from(
"second queued",
));
}
app.sessions
.session_handles_mut()
.insert(session_id.clone().into(), handles);
app.sessions.sessions_mut()[0].queued_messages =
vec!["first queued".to_string(), "second queued".to_string()];
{
let mut queued = queued_messages.lock().expect("queued_messages lock");
queued.pop_front();
}
end_in_progress_turn(&mut app, &session_id).await;
assert!(
queued_messages
.lock()
.expect("queued_messages lock")
.is_empty(),
"handle queue should be empty after retracting the only remaining entry"
);
assert!(
app.sessions.sessions()[0].queued_messages.is_empty(),
"snapshot must rebuild from the handle state and not show a phantom row for a prompt \
the worker is already executing"
);
}
}