use super::*;use pretty_assertions::assert_eq;use ratatui::Terminal;use ratatui::backend::TestBackend;#[tokio::test]async fn forked_thread_history_line_without_name_shows_id_once_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let forked_from_id = ThreadId::from_string("019c2d47-4935-7423-a190-05691f566092").expect("forked id"); chat.emit_forked_thread_event(forked_from_id, /*fork_parent_title*/ None); let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async { loop { match rx.recv().await { Some(AppEvent::InsertHistoryCell(cell)) => break cell, Some(_) => continue, None => panic!("app event channel closed before forked thread history was emitted"), } } }) .await .expect("timed out waiting for forked thread history"); let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80)); assert_chatwidget_snapshot!("forked_thread_history_line_without_name", combined);}#[tokio::test]async fn suppressed_interrupted_turn_notice_skips_history_warning() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); chat.set_interrupted_turn_notice_mode(InterruptedTurnNoticeMode::Suppress); chat.on_task_started(); chat.on_agent_message_delta("partial output".to_string()); chat.on_interrupted_turn(TurnAbortReason::Interrupted); let inserted = drain_insert_history(&mut rx); assert!( inserted.iter().all(|cell| { let rendered = lines_to_single_string(cell); !rendered.contains("Conversation interrupted - tell the model what to do differently.") && !rendered.contains("Model interrupted to submit steer instructions.") }), "unexpected interrupted-turn notice in side conversation: {inserted:?}" );}fn assert_side_rename_rejected( rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>, op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>,) { let event = rx .try_recv() .expect("expected side conversation rename error"); match event { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert!( rendered.contains("Side conversations are ephemeral and cannot be renamed."), "expected side conversation rename error, got {rendered:?}" ); } other => panic!("expected InsertHistoryCell error, got {other:?}"), } assert!(rx.try_recv().is_err(), "expected no follow-up events"); assert!(op_rx.try_recv().is_err(), "expected no rename op");}#[tokio::test]async fn slash_rename_is_rejected_for_side_threads() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_thread_rename_block_message( "Side conversations are ephemeral and cannot be renamed.".to_string(), ); chat.dispatch_command(SlashCommand::Rename); assert_side_rename_rejected(&mut rx, &mut op_rx);}#[tokio::test]async fn slash_rename_with_args_is_rejected_for_side_threads() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_thread_rename_block_message( "Side conversations are ephemeral and cannot be renamed.".to_string(), ); chat.dispatch_command_with_args(SlashCommand::Rename, "investigate".to_string(), Vec::new()); assert_side_rename_rejected(&mut rx, &mut op_rx);}#[tokio::test]async fn slash_commands_without_side_flag_are_rejected_for_side_threads() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_side_conversation_active(/*active*/ true); chat.dispatch_command(SlashCommand::Review); let event = rx .try_recv() .expect("expected side conversation slash command error"); match event { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert!( rendered.contains( "'/review' is unavailable in side conversations. Press Ctrl+C to return to the main thread first." ), "expected side conversation slash command error, got {rendered:?}" ); } other => panic!("expected InsertHistoryCell error, got {other:?}"), } assert!(rx.try_recv().is_err(), "expected no follow-up events"); assert!(op_rx.try_recv().is_err(), "expected no review op");}#[tokio::test]async fn slash_side_is_rejected_for_side_threads() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_side_conversation_active(/*active*/ true); chat.dispatch_command(SlashCommand::Side); let event = rx .try_recv() .expect("expected side conversation slash command error"); match event { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert!( rendered.contains( "'/side' is unavailable in side conversations. Press Ctrl+C to return to the main thread first." ), "expected side conversation slash command error, got {rendered:?}" ); } other => panic!("expected InsertHistoryCell error, got {other:?}"), } assert!(rx.try_recv().is_err(), "expected no follow-up events"); assert!( op_rx.try_recv().is_err(), "expected no side conversation op" );}#[tokio::test]async fn slash_side_is_rejected_during_review_mode() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.review.is_review_mode = true; chat.dispatch_command(SlashCommand::Side); let event = rx .try_recv() .expect("expected review-mode side conversation error"); match event { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert!( rendered.contains("'/side' is unavailable while code review is running."), "expected review-mode side conversation error, got {rendered:?}" ); } other => panic!("expected InsertHistoryCell error, got {other:?}"), } assert!(rx.try_recv().is_err(), "expected no follow-up events"); assert!( op_rx.try_recv().is_err(), "expected no side conversation op" );}#[tokio::test]async fn slash_btw_is_rejected_during_review_mode() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.review.is_review_mode = true; chat.dispatch_command(SlashCommand::Btw); let event = rx .try_recv() .expect("expected review-mode btw conversation error"); match event { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert!( rendered.contains("'/btw' is unavailable while code review is running."), "expected review-mode btw conversation error, got {rendered:?}" ); } other => panic!("expected InsertHistoryCell error, got {other:?}"), } assert!(rx.try_recv().is_err(), "expected no follow-up events"); assert!( op_rx.try_recv().is_err(), "expected no side conversation op" );}#[tokio::test]async fn slash_btw_is_rejected_before_the_session_starts() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Btw); let event = rx .try_recv() .expect("expected pre-session btw conversation error"); match event { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert!( rendered.contains("'/btw' is unavailable before the session starts."), "expected pre-session btw conversation error, got {rendered:?}" ); } other => panic!("expected InsertHistoryCell error, got {other:?}"), } assert!(rx.try_recv().is_err(), "expected no follow-up events"); assert!( op_rx.try_recv().is_err(), "expected no side conversation op" );}#[tokio::test]async fn submit_user_message_as_plain_user_turn_does_not_run_shell_commands() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); chat.submit_user_message_as_plain_user_turn("!echo hello".into()); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( items, vec![UserInput::Text { text: "!echo hello".to_string(), text_elements: Vec::new(), }] ), other => { panic!("expected Op::UserTurn for side-conversation shell-like input, got {other:?}") } }}#[tokio::test]async fn slash_side_without_args_starts_empty_side_conversation() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let parent_thread_id = ThreadId::new(); chat.thread_id = Some(parent_thread_id); chat.on_task_started(); chat.bottom_pane .set_composer_text("/side".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_matches!( rx.try_recv(), Ok(AppEvent::StartSide { parent_thread_id: emitted_parent_thread_id, user_message: None, }) if emitted_parent_thread_id == parent_thread_id ); assert!( op_rx.try_recv().is_err(), "bare /side should not submit an op on the parent thread" ); assert!(chat.input_queue.queued_user_messages.is_empty());}#[tokio::test]async fn slash_btw_without_args_starts_empty_side_conversation() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let parent_thread_id = ThreadId::new(); chat.thread_id = Some(parent_thread_id); chat.on_task_started(); chat.bottom_pane .set_composer_text("/btw".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_matches!( rx.try_recv(), Ok(AppEvent::StartSide { parent_thread_id: emitted_parent_thread_id, user_message: None, }) if emitted_parent_thread_id == parent_thread_id ); assert!( op_rx.try_recv().is_err(), "bare /btw should not submit an op on the parent thread" ); assert!(chat.input_queue.queued_user_messages.is_empty());}#[tokio::test]async fn slash_side_requests_forked_side_question_while_task_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let parent_thread_id = ThreadId::new(); chat.thread_id = Some(parent_thread_id); chat.config.tui_status_line = Some(vec!["model-with-reasoning".to_string()]); chat.refresh_status_line(); chat.on_task_started(); chat.show_welcome_banner = false; chat.bottom_pane.set_composer_text( "/side explore the codebase".to_string(), Vec::new(), Vec::new(), ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_matches!( rx.try_recv(), Ok(AppEvent::StartSide { parent_thread_id: emitted_parent_thread_id, user_message: Some(user_message), }) if emitted_parent_thread_id == parent_thread_id && user_message == UserMessage { text: "explore the codebase".to_string(), local_images: Vec::new(), remote_image_urls: Vec::new(), text_elements: Vec::new(), mention_bindings: Vec::new(), } ); assert!( op_rx.try_recv().is_err(), "expected no op on the parent thread" ); let width = 80; let height = chat.desired_height(width); let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal"); terminal .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw side conversation footer"); assert_chatwidget_snapshot!( "slash_side_requests_forked_side_question_while_task_running", normalized_backend_snapshot(terminal.backend()) );}#[tokio::test]async fn slash_btw_requests_forked_side_question_while_task_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let parent_thread_id = ThreadId::new(); chat.thread_id = Some(parent_thread_id); chat.on_task_started(); chat.bottom_pane.set_composer_text( "/btw explore the codebase".to_string(), Vec::new(), Vec::new(), ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_matches!( rx.try_recv(), Ok(AppEvent::StartSide { parent_thread_id: emitted_parent_thread_id, user_message: Some(user_message), }) if emitted_parent_thread_id == parent_thread_id && user_message == UserMessage { text: "explore the codebase".to_string(), local_images: Vec::new(), remote_image_urls: Vec::new(), text_elements: Vec::new(), mention_bindings: Vec::new(), } ); assert!( op_rx.try_recv().is_err(), "expected no op on the parent thread" );}#[tokio::test]async fn side_context_label_preserves_status_line_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_welcome_banner = false; chat.config.tui_status_line = Some(vec!["model-name".to_string()]); chat.refresh_status_line(); chat.set_side_conversation_active(/*active*/ true); chat.set_side_conversation_context_label(Some( "Side from main thread · Ctrl+C to return".to_string(), )); let width = 80; let height = chat.desired_height(width); let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal"); terminal .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw side conversation footer"); assert_chatwidget_snapshot!( "side_context_label_preserves_status_line", terminal.backend() );}#[tokio::test]async fn side_context_label_shows_parent_status_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_welcome_banner = false; chat.set_side_conversation_active(/*active*/ true); chat.set_side_conversation_context_label(Some( "Side from main thread · main needs input · Ctrl+C to return".to_string(), )); let width = 80; let height = chat.desired_height(width); let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal"); terminal .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw side conversation footer"); assert_chatwidget_snapshot!("side_context_label_shows_parent_status", terminal.backend());}