use super::*;use crate::bottom_pane::slash_commands::ServiceTierCommand;use pretty_assertions::assert_eq;use serial_test::serial;fn force_pet_image_support(chat: &mut ChatWidget) { chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported( crate::pets::ImageProtocol::Kitty, ));}fn force_tmux_pet_image_unsupported(chat: &mut ChatWidget) { chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Unsupported( crate::pets::PetImageUnsupportedReason::Tmux, ));}fn force_terminal_pet_image_unsupported(chat: &mut ChatWidget) { chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Unsupported( crate::pets::PetImageUnsupportedReason::Terminal, ));}fn force_old_iterm2_pet_image_unsupported(chat: &mut ChatWidget) { chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Unsupported( crate::pets::PetImageUnsupportedReason::Iterm2TooOld, ));}fn fast_tier_command() -> ServiceTierCommand { ServiceTierCommand { id: ServiceTier::Fast.request_value().to_string(), name: "fast".to_string(), description: "Fastest inference with increased plan usage".to_string(), }}fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) { if let Some(message) = message { complete_assistant_message( chat, &format!("{turn_id}-message"), message, Some(MessagePhase::FinalAnswer), ); } handle_turn_completed(chat, turn_id, /*duration_ms*/ None);}fn submit_composer_text(chat: &mut ChatWidget, text: &str) { chat.bottom_pane .set_composer_text(text.to_string(), Vec::new(), Vec::new()); submit_current_composer(chat);}fn submit_current_composer(chat: &mut ChatWidget) { chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));}fn queue_composer_text_with_tab(chat: &mut ChatWidget, text: &str) { chat.bottom_pane .set_composer_text(text.to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));}fn queue_goal_with_large_paste(chat: &mut ChatWidget, paste: String) { chat.bottom_pane .set_composer_text("/goal ".to_string(), Vec::new(), Vec::new()); chat.handle_paste(paste); chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));}fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String { chat.bottom_pane .set_composer_text(String::new(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); chat.bottom_pane.composer_text()}fn dispatch_usage_and_expect_refresh( chat: &mut ChatWidget, rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,) -> u64 { chat.dispatch_command_with_args(SlashCommand::Usage, "daily".to_string(), Vec::new()); expect_token_activity_refresh(rx)}fn expect_token_activity_refresh(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> u64 { match rx.try_recv() { Ok(AppEvent::RefreshTokenActivity { request_id }) => request_id, other => panic!("expected token activity refresh request, got {other:?}"), }}fn next_add_to_history_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> String { loop { match rx.try_recv() { Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) => return text, Ok(_) => continue, Err(TryRecvError::Empty) => { panic!("expected AppendMessageHistoryEntry event but queue was empty") } Err(TryRecvError::Disconnected) => { panic!("expected AppendMessageHistoryEntry event but channel closed") } } }}#[tokio::test]async fn service_tier_commands_lowercase_catalog_names() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; let mut preset = get_available_model(&chat, "gpt-5.4"); let expected_description = preset .service_tiers .iter() .find(|tier| tier.id == ServiceTier::Fast.request_value()) .expect("fast tier") .description .clone(); preset .service_tiers .iter_mut() .find(|tier| tier.id == ServiceTier::Fast.request_value()) .expect("fast tier") .name = "Fast".to_string(); chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset])); assert_eq!( chat.current_model_service_tier_commands(), vec![ServiceTierCommand { id: ServiceTier::Fast.request_value().to_string(), name: "fast".to_string(), description: expected_description, }] );}#[tokio::test]async fn slash_compact_eagerly_queues_follow_up_before_turn_start() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Compact); assert!(chat.bottom_pane.is_task_running()); match rx.try_recv() { Ok(AppEvent::CodexOp(Op::Compact)) => {} other => panic!("expected compact op to be submitted, got {other:?}"), } chat.bottom_pane.set_composer_text( "queued before compact turn start".to_string(), Vec::new(), Vec::new(), ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(chat.input_queue.pending_steers.is_empty()); assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_eq!( chat.input_queue.queued_user_messages.front().unwrap().text, "queued before compact turn start" ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));}#[tokio::test]async fn queued_slash_compact_dispatches_after_active_turn() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/compact"); assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_eq!( chat.input_queue .queued_user_messages .front() .unwrap() .action, QueuedInputAction::ParseSlash ); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); complete_turn_with_message(&mut chat, "turn-1", Some("done")); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>(); assert!( events .iter() .any(|event| matches!(event, AppEvent::CodexOp(Op::Compact))), "expected queued /compact to submit compact op; events: {events:?}" );}#[tokio::test]async fn queued_slash_review_with_args_dispatches_after_active_turn() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/review check regressions"); complete_turn_with_message(&mut chat, "turn-1", Some("done")); match op_rx.try_recv() { Ok(Op::Review { target }) => assert_eq!( target, ReviewTarget::Custom { instructions: "check regressions".to_string(), } ), other => panic!("expected queued /review to submit review op, got {other:?}"), }}#[tokio::test]async fn queued_slash_review_with_args_restores_for_edit() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/review check regressions"); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); assert_eq!( chat.bottom_pane.composer_text(), "/review check regressions" );}#[tokio::test]async fn queued_bang_shell_dispatches_after_active_turn() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "!echo hi"); assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_eq!( chat.input_queue .queued_user_messages .front() .unwrap() .action, QueuedInputAction::RunShell ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); complete_turn_with_message(&mut chat, "turn-1", Some("done")); match op_rx.try_recv() { Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"), other => panic!("expected queued shell command op, got {other:?}"), } assert_eq!(next_add_to_history_event(&mut rx), "!echo hi"); assert!(chat.input_queue.queued_user_messages.is_empty());}#[tokio::test]async fn queued_empty_bang_shell_reports_help_when_dequeued_and_drains_next_input() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "!"); queue_composer_text_with_tab(&mut chat, "hello after help"); assert!(drain_insert_history(&mut rx).is_empty()); complete_turn_with_message(&mut chat, "turn-1", Some("done")); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::<Vec<_>>() .join("\n"); assert!( rendered.contains(USER_SHELL_COMMAND_HELP_TITLE), "expected delayed shell help, got {rendered:?}" ); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( items, vec![UserInput::Text { text: "hello after help".to_string(), text_elements: Vec::new(), }] ), other => panic!("expected queued message after empty shell command, got {other:?}"), } assert!(chat.input_queue.queued_user_messages.is_empty());}#[tokio::test]async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "!echo hi"); queue_composer_text_with_tab(&mut chat, "hello after shell"); complete_turn_with_message(&mut chat, "turn-1", Some("done")); match op_rx.try_recv() { Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"), other => panic!("expected queued shell command op, got {other:?}"), } assert_eq!(next_add_to_history_event(&mut rx), "!echo hi"); assert_eq!(chat.input_queue.queued_user_messages.len(), 1); let begin = begin_exec_with_source( &mut chat, "user-shell-echo", "echo hi", ExecCommandSource::UserShell, ); end_exec(&mut chat, begin, "hi\n", "", /*exit_code*/ 0); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( items, vec![UserInput::Text { text: "hello after shell".to_string(), text_elements: Vec::new(), }] ), other => panic!("expected queued message after shell completion, got {other:?}"), } assert!(chat.input_queue.queued_user_messages.is_empty());}async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_popup_text: &str) { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, command); queue_composer_text_with_tab(&mut chat, "hello after menu"); complete_turn_with_message(&mut chat, "turn-1", Some("done")); assert_eq!(chat.input_queue.queued_user_messages.len(), 1); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( popup.contains(expected_popup_text), "expected {command} menu to open; popup:\n{popup}" ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( items, vec![UserInput::Text { text: "hello after menu".to_string(), text_elements: Vec::new(), }] ), other => panic!("expected queued message after cancelling {command}, got {other:?}"), } assert!(chat.input_queue.queued_user_messages.is_empty());}#[tokio::test]async fn queued_slash_menu_cancel_drains_next_input() { assert_cancelled_queued_menu_drains_next_input("/model", "Select Model").await; assert_cancelled_queued_menu_drains_next_input("/permissions", "Update Model Permissions") .await;}#[tokio::test]async fn queued_slash_menu_selection_drains_next_input() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/permissions"); queue_composer_text_with_tab(&mut chat, "hello after selection"); complete_turn_with_message(&mut chat, "turn-1", Some("done")); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( popup.contains("Update Model Permissions"), "expected permissions menu to open; popup:\n{popup}" ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( items, vec![UserInput::Text { text: "hello after selection".to_string(), text_elements: Vec::new(), }] ), other => panic!("expected queued message after permissions selection, got {other:?}"), } assert!(chat.input_queue.queued_user_messages.is_empty());}#[tokio::test]async fn queued_bare_rename_drains_next_input_after_name_update() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/rename"); queue_composer_text_with_tab(&mut chat, "hello after rename"); complete_turn_with_message(&mut chat, "turn-1", Some("done")); assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Name thread")); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); chat.handle_paste("Queued rename".to_string()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>(); assert!( events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::SetThreadName { name }) if name == "Queued rename" )), "expected rename prompt to submit thread name; events: {events:?}" ); chat.handle_server_notification( ServerNotification::ThreadNameUpdated( codex_app_server_protocol::ThreadNameUpdatedNotification { thread_id: thread_id.to_string(), thread_name: Some("Queued rename".to_string()), }, ), /*replay_kind*/ None, ); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( items, vec![UserInput::Text { text: "hello after rename".to_string(), text_elements: Vec::new(), }] ), other => panic!("expected queued message after /rename, got {other:?}"), } assert!(chat.input_queue.queued_user_messages.is_empty());}#[tokio::test]async fn queued_inline_rename_does_not_drain_again_before_turn_started() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/rename Queued rename"); queue_composer_text_with_tab(&mut chat, "first after rename"); queue_composer_text_with_tab(&mut chat, "second after rename"); complete_turn_with_message(&mut chat, "turn-1", Some("done")); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>(); assert!( events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::SetThreadName { name }) if name == "Queued rename" )), "expected queued /rename to submit thread name; events: {events:?}" ); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( items, vec![UserInput::Text { text: "first after rename".to_string(), text_elements: Vec::new(), }] ), other => panic!("expected first queued message after /rename, got {other:?}"), } assert!(events.iter().any(|event| matches!( event, AppEvent::AppendMessageHistoryEntry { text, .. } if text == "first after rename" ))); assert_eq!( chat.queued_user_message_texts(), vec!["second after rename"] ); let input_state = chat.capture_thread_input_state().unwrap(); assert!(input_state.user_turn_pending_start); chat.restore_thread_input_state(/*input_state*/ None); assert!(!chat.input_queue.user_turn_pending_start); chat.restore_thread_input_state(Some(input_state)); assert!(chat.input_queue.user_turn_pending_start); assert_eq!( chat.queued_user_message_texts(), vec!["second after rename"] ); chat.handle_server_notification( ServerNotification::ThreadNameUpdated( codex_app_server_protocol::ThreadNameUpdatedNotification { thread_id: thread_id.to_string(), thread_name: Some("Queued rename".to_string()), }, ), /*replay_kind*/ None, ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); assert_eq!( chat.queued_user_message_texts(), vec!["second after rename"] ); handle_turn_started(&mut chat, "turn-2"); complete_turn_with_message(&mut chat, "turn-2", Some("done")); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( items, vec![UserInput::Text { text: "second after rename".to_string(), text_elements: Vec::new(), }] ), other => panic!("expected second queued message after turn complete, got {other:?}"), } assert!(chat.input_queue.queued_user_messages.is_empty());}#[tokio::test]async fn queued_unknown_slash_reports_error_when_dequeued() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/does-not-exist"); assert!(drain_insert_history(&mut rx).is_empty()); complete_turn_with_message(&mut chat, "turn-1", Some("done")); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::<Vec<_>>() .join("\n"); assert!( rendered.contains("Unrecognized command '/does-not-exist'"), "expected delayed slash error, got {rendered:?}" ); assert!(chat.input_queue.queued_user_messages.is_empty());}#[tokio::test]async fn ctrl_d_quits_without_prompt() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));}#[tokio::test]async fn ctrl_d_with_modal_open_does_not_quit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.open_approvals_popup(); chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));}#[tokio::test]async fn slash_init_does_not_depend_on_loaded_instruction_sources() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.instruction_source_paths = vec![chat.config.cwd.join("project-instructions.md")]; submit_composer_text(&mut chat, "/init"); assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert!(drain_insert_history(&mut rx).is_empty()); assert_eq!(recall_latest_after_clearing(&mut chat), "/init");}#[tokio::test]async fn bare_slash_command_is_available_from_local_recall_after_dispatch() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; submit_composer_text(&mut chat, "/diff"); let _ = drain_insert_history(&mut rx); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(chat.bottom_pane.composer_text(), "/diff");}#[tokio::test]async fn inline_slash_command_is_available_from_local_recall_after_dispatch() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; submit_composer_text(&mut chat, "/rename Better title"); let _ = drain_insert_history(&mut rx); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(chat.bottom_pane.composer_text(), "/rename Better title");}#[tokio::test]async fn goal_slash_command_with_extra_os_emits_set_goal_event() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); let command = "/goooooooooooal --tokens 98.5K improve benchmark coverage"; submit_composer_text(&mut chat, command); let event = rx.try_recv().expect("expected goal draft event"); let AppEvent::SetThreadGoalDraft { thread_id: actual_thread_id, draft, mode, } = event else { panic!("expected SetThreadGoalDraft, got {event:?}"); }; assert_eq!(actual_thread_id, thread_id); assert_eq!(draft.objective, "--tokens 98.5K improve benchmark coverage"); assert_eq!(mode, crate::app_event::ThreadGoalSetMode::ConfirmIfExists); assert_no_submit_op(&mut op_rx); assert_eq!(recall_latest_after_clearing(&mut chat), command);}#[tokio::test]async fn goal_slash_command_uses_plain_text_for_mentions() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); chat.bottom_pane.set_composer_text_with_mention_bindings( "/goal use $figma for the mockup".to_string(), Vec::new(), Vec::new(), vec![MentionBinding { sigil: '$', mention: "figma".to_string(), path: "app://figma".to_string(), }], ); chat.handle_key_event(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let draft = next_goal_draft(&mut rx, thread_id); assert_eq!(draft.objective, "use $figma for the mockup"); assert_no_submit_op(&mut op_rx);}#[tokio::test]async fn goal_slash_command_emits_attached_images() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); let remote_url = "https://example.com/goal.png".to_string(); let local_image = chat.config.codex_home.join("goal-local.png"); std::fs::write(&local_image, b"png bytes").expect("write local image"); let placeholder = "[Image #2]"; let command = format!("/goal literal {placeholder} describe {placeholder}"); let placeholder_start = command.rfind(placeholder).expect("placeholder in command"); chat.set_remote_image_urls(vec![remote_url.clone()]); chat.bottom_pane.set_composer_text( command, vec![TextElement::new( (placeholder_start..placeholder_start + placeholder.len()).into(), Some(placeholder.to_string()), )], vec![local_image.to_path_buf()], ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let draft = next_goal_draft(&mut rx, thread_id); assert_eq!( draft.objective, format!("literal {placeholder} describe {placeholder}") ); assert_eq!(draft.remote_image_urls, vec![remote_url]); assert_eq!(draft.local_images.len(), 1); assert_eq!(draft.local_images[0].placeholder, placeholder); assert_eq!(draft.local_images[0].path, local_image.to_path_buf()); assert!(chat.remote_image_urls().is_empty()); assert!(chat.bottom_pane.composer_local_image_paths().is_empty()); assert_no_submit_op(&mut op_rx);}#[tokio::test]async fn bare_goal_slash_command_drains_pending_submission_state() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); let remote_url = "https://example.com/goal-menu.png".to_string(); let local_image = PathBuf::from("/tmp/goal-menu-local.png"); chat.set_remote_image_urls(vec![remote_url]); chat.bottom_pane .set_composer_text("/goal".to_string(), Vec::new(), vec![local_image]); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_matches!( rx.try_recv(), Ok(AppEvent::OpenThreadGoalMenu { thread_id: opened }) if opened == thread_id ); assert!(chat.remote_image_urls().is_empty()); assert!(chat.bottom_pane.composer_local_image_paths().is_empty());}#[tokio::test]async fn goal_control_slash_commands_emit_goal_events() { let cases = [ ("/goal clear", None), ("/goal pause", Some(AppThreadGoalStatus::Paused)), ("/goal resume", Some(AppThreadGoalStatus::Active)), ]; for (command, status) in cases { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); submit_composer_text(&mut chat, command); match status { Some(status) => { let event = rx.try_recv().expect("expected goal status event"); let AppEvent::SetThreadGoalStatus { thread_id: actual_thread_id, status: actual_status, } = event else { panic!("expected SetThreadGoalStatus, got {event:?}"); }; assert_eq!(actual_thread_id, thread_id); assert_eq!(actual_status, status); } None => { let event = rx.try_recv().expect("expected clear goal event"); let AppEvent::ClearThreadGoal { thread_id: actual_thread_id, } = event else { panic!("expected ClearThreadGoal, got {event:?}"); }; assert_eq!(actual_thread_id, thread_id); } } }}#[tokio::test]async fn goal_control_slash_command_without_thread_shows_full_usage() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); submit_composer_text(&mut chat, "/goal pause"); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected goal usage message"); insta::assert_snapshot!( lines_to_single_string(&cells[0]), @"• Usage: /goal [<objective>|clear|edit|pause|resume] The session must start before you can change a goal." );}#[tokio::test]async fn goal_edit_slash_command_opens_goal_editor() { for thread_id in [Some(ThreadId::new()), None] { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); chat.thread_id = thread_id; submit_composer_text(&mut chat, "/goal edit"); let event = rx.try_recv().expect("expected goal editor event"); let AppEvent::OpenThreadGoalEditor { thread_id: actual_thread_id, } = event else { panic!("expected OpenThreadGoalEditor, got {event:?}"); }; assert_eq!(actual_thread_id, thread_id); assert_no_submit_op(&mut op_rx); }}#[tokio::test]async fn queued_goal_slash_command_emits_set_goal_event_after_thread_starts() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); let command = "/goal improve benchmark coverage"; submit_composer_text(&mut chat, command); assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); chat.maybe_send_next_queued_input(); let draft = next_goal_draft(&mut rx, thread_id); assert_eq!(draft.objective, "improve benchmark coverage"); assert_no_submit_op(&mut op_rx);}#[tokio::test]async fn queued_goal_slash_command_preserves_large_paste() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); handle_turn_started(&mut chat, "turn-1"); let paste = "x".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS + 1); queue_goal_with_large_paste(&mut chat, paste.clone()); assert_eq!(chat.input_queue.queued_user_messages.len(), 1); complete_turn_with_message(&mut chat, "turn-1", Some("done")); let draft = next_goal_draft(&mut rx, thread_id); assert_eq!(draft.pending_pastes.len(), 1); assert_eq!(draft.pending_pastes[0].1, paste); assert!(draft.objective.contains(&draft.pending_pastes[0].0)); assert_no_submit_op(&mut op_rx);}#[tokio::test]async fn queued_goal_slash_command_restores_large_paste_for_edit() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); handle_turn_started(&mut chat, "turn-1"); let paste = "x".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS + 1); queue_goal_with_large_paste(&mut chat, paste.clone()); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); assert_eq!(chat.bottom_pane.composer_pending_pastes()[0].1, paste); complete_turn_with_message(&mut chat, "turn-1", Some("done")); submit_current_composer(&mut chat); let draft = next_goal_draft(&mut rx, thread_id); assert_eq!(draft.pending_pastes.len(), 1); assert_eq!(draft.pending_pastes[0].1, paste); assert_no_submit_op(&mut op_rx);}#[tokio::test]async fn interrupt_disambiguates_same_sized_goal_pastes() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); handle_turn_started(&mut chat, "turn-1"); let first = "a".repeat(codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS + 1); let second = "b".repeat(first.len()); queue_goal_with_large_paste(&mut chat, first); chat.bottom_pane .set_composer_text("/goal ".to_string(), Vec::new(), Vec::new()); chat.handle_paste(second.clone()); chat.on_interrupted_turn(TurnAbortReason::Interrupted); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); let remaining = chat.bottom_pane.composer_pending_pastes(); assert_eq!(remaining.len(), 1); assert_eq!(remaining[0].1, second); assert!(chat.bottom_pane.composer_text().contains(&remaining[0].0));}#[tokio::test]async fn queued_goal_slash_command_preserves_current_draft_metadata() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); let command = "/goal improve benchmark coverage"; submit_composer_text(&mut chat, command); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); let remote_url = "https://example.com/current-draft.png".to_string(); let local_image = PathBuf::from("/tmp/current-draft-local.png"); let placeholder = "[Image #3]"; let draft = format!("draft with {placeholder}"); let placeholder_start = draft.find(placeholder).expect("placeholder in draft"); chat.set_remote_image_urls(vec![remote_url.clone()]); chat.bottom_pane.set_composer_text( draft.clone(), vec![TextElement::new( (placeholder_start..placeholder_start + placeholder.len()).into(), Some(placeholder.to_string()), )], vec![local_image.clone()], ); let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); chat.maybe_send_next_queued_input(); let _ = next_goal_draft(&mut rx, thread_id); assert_no_submit_op(&mut op_rx); assert_eq!(chat.bottom_pane.composer_text(), draft); assert_eq!(chat.remote_image_urls(), vec![remote_url]); assert_eq!( chat.bottom_pane.composer_local_image_paths(), vec![local_image] );}#[tokio::test]async fn restored_queued_goal_slash_command_emits_set_goal_event() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); let command = "/goal improve benchmark coverage"; submit_composer_text(&mut chat, command); let input_state = chat .capture_thread_input_state() .expect("expected queued input state"); let (mut restored_chat, mut restored_rx, mut restored_op_rx) = make_chatwidget_manual(/*model_override*/ None).await; restored_chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); restored_chat.restore_thread_input_state(Some(input_state)); let thread_id = ThreadId::new(); restored_chat.thread_id = Some(thread_id); restored_chat.maybe_send_next_queued_input(); let _ = next_goal_draft(&mut restored_rx, thread_id); assert_no_submit_op(&mut restored_op_rx);}#[test]fn merged_history_record_preserves_raw_text_and_rebased_elements() { let first = UserMessage { text: "Ask $figma".to_string(), local_images: Vec::new(), remote_image_urls: Vec::new(), text_elements: vec![TextElement::new((4..10).into(), Some("$figma".to_string()))], mention_bindings: vec![MentionBinding { sigil: '$', mention: "figma".to_string(), path: "app://figma".to_string(), }], }; let second = UserMessage::from("internal prompt"); let (_message, history_record) = merge_user_messages_with_history_record(vec![ (first, UserMessageHistoryRecord::UserMessageText), ( second, UserMessageHistoryRecord::Override(UserMessageHistoryOverride { text: "/goal inspect [Image #1]".to_string(), text_elements: vec![TextElement::new( (14..24).into(), Some("[Image #1]".to_string()), )], }), ), ]); assert_eq!( history_record, UserMessageHistoryRecord::Override(UserMessageHistoryOverride { text: "Ask $figma\n/goal inspect [Image #1]".to_string(), text_elements: vec![ TextElement::new((4..10).into(), Some("$figma".to_string())), TextElement::new((25..35).into(), Some("[Image #1]".to_string())), ], }) );}#[test]fn merged_history_record_remaps_override_image_placeholders() { let first_placeholder = "[Image #1]"; let second_placeholder = "[Image #1]"; let first = UserMessage { text: format!("first {first_placeholder}"), local_images: vec![LocalImageAttachment { placeholder: first_placeholder.to_string(), path: PathBuf::from("/tmp/first.png"), }], remote_image_urls: Vec::new(), text_elements: vec![TextElement::new( (6..16).into(), Some(first_placeholder.to_string()), )], mention_bindings: Vec::new(), }; let second = UserMessage { text: format!("internal {second_placeholder}"), local_images: vec![LocalImageAttachment { placeholder: second_placeholder.to_string(), path: PathBuf::from("/tmp/second.png"), }], remote_image_urls: Vec::new(), text_elements: vec![TextElement::new( (9..19).into(), Some(second_placeholder.to_string()), )], mention_bindings: Vec::new(), }; let (message, history_record) = merge_user_messages_with_history_record(vec![ (first, UserMessageHistoryRecord::UserMessageText), ( second, UserMessageHistoryRecord::Override(UserMessageHistoryOverride { text: format!("goal {second_placeholder}"), text_elements: vec![TextElement::new( (5..15).into(), Some(second_placeholder.to_string()), )], }), ), ]); assert_eq!(message.text, "first [Image #1]\ninternal [Image #2]"); assert_eq!( message.text_elements, vec![ TextElement::new((6..16).into(), Some("[Image #1]".to_string())), TextElement::new((26..36).into(), Some("[Image #2]".to_string())), ] ); assert_eq!( message .local_images .iter() .map(|image| image.placeholder.as_str()) .collect::<Vec<_>>(), vec!["[Image #1]", "[Image #2]"] ); assert_eq!( history_record, UserMessageHistoryRecord::Override(UserMessageHistoryOverride { text: "first [Image #1]\ngoal [Image #2]".to_string(), text_elements: vec![ TextElement::new((6..16).into(), Some("[Image #1]".to_string())), TextElement::new((22..32).into(), Some("[Image #2]".to_string())), ], }) );}#[tokio::test]async fn interrupted_merged_message_history_encodes_mentions_once() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); chat.on_task_started(); chat.on_agent_message_delta("Final answer line\n".to_string()); let text = "use $figma now"; chat.bottom_pane.set_composer_text_with_mention_bindings( text.to_string(), Vec::new(), Vec::new(), vec![MentionBinding { sigil: '$', mention: "figma".to_string(), path: "app://figma".to_string(), }], ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => { let [ UserInput::Text { text: submitted, .. }, ] = items.as_slice() else { panic!("expected text item, got {items:?}"); }; assert_eq!(submitted, text); } other => panic!("expected user turn, got {other:?}"), } let encoded = "use [$figma](app://figma) now"; assert_eq!(next_add_to_history_event(&mut rx), encoded); chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); next_interrupt_op(&mut op_rx); chat.on_interrupted_turn(TurnAbortReason::Interrupted); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => { let [ UserInput::Text { text: submitted, .. }, ] = items.as_slice() else { panic!("expected resubmitted text item, got {items:?}"); }; assert_eq!(submitted, text); } other => panic!("expected resubmitted user turn, got {other:?}"), } assert_eq!(next_add_to_history_event(&mut rx), encoded);}#[tokio::test]async fn slash_rename_prefills_existing_thread_name() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_name = Some("Current project title".to_string()); chat.dispatch_command(SlashCommand::Rename); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("slash_rename_prefilled_prompt", popup); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_matches!( rx.try_recv(), Ok(AppEvent::CodexOp(Op::SetThreadName { name })) if name == "Current project title" );}#[tokio::test]async fn slash_rename_without_existing_thread_name_starts_empty() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Rename); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!(popup.contains("Name thread")); assert!(popup.contains("Type a name and press Enter")); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));}#[tokio::test]async fn usage_error_slash_command_is_available_from_local_recall() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; submit_composer_text(&mut chat, "/raw maybe"); assert_eq!(chat.bottom_pane.composer_text(), ""); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|cell| lines_to_single_string(cell)) .collect::<Vec<_>>() .join("\n"); assert!( rendered.contains("Usage: /raw [on|off]"), "expected usage message, got: {rendered:?}" ); assert_eq!(recall_latest_after_clearing(&mut chat), "/raw maybe");}#[tokio::test]async fn signed_out_usage_command_reports_chatgpt_login_requirement() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; submit_composer_text(&mut chat, "/usage"); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|cell| lines_to_single_string(cell)) .collect::<Vec<_>>() .join("\n"); assert_chatwidget_snapshot!( "signed_out_usage_command_reports_chatgpt_login_requirement", rendered ); assert_eq!(recall_latest_after_clearing(&mut chat), "/usage");}#[tokio::test]async fn signed_out_usage_command_with_args_reports_chatgpt_login_requirement() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; submit_composer_text(&mut chat, "/usage weekly"); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|cell| lines_to_single_string(cell)) .collect::<Vec<_>>() .join("\n"); assert!( rendered.contains("Sign in with ChatGPT to use /usage."), "expected ChatGPT login requirement, got: {rendered:?}" ); assert_eq!(recall_latest_after_clearing(&mut chat), "/usage weekly");}#[tokio::test]async fn usage_command_with_invalid_view_reports_usage_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); submit_composer_text(&mut chat, "/usage monthly"); let rendered = drain_insert_history(&mut rx) .iter() .map(|cell| lines_to_single_string(cell)) .collect::<Vec<_>>() .join("\n"); assert_chatwidget_snapshot!("usage_command_with_invalid_view_reports_usage", rendered); assert_eq!(recall_latest_after_clearing(&mut chat), "/usage monthly");}#[tokio::test]async fn usage_command_runs_with_backend_auth_without_chatgpt_account_flag() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.update_account_state( /*status_account_display*/ None, /*plan_type*/ None, /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true, ); chat.dispatch_command_with_args(SlashCommand::Usage, "daily".to_string(), Vec::new()); assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshTokenActivity { .. })); assert!(!chat.has_chatgpt_account());}#[tokio::test]async fn usage_command_runs_with_backend_auth_from_widget_init() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual_with_auth( /*model_override*/ None, /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true, ) .await; chat.dispatch_command_with_args(SlashCommand::Usage, "daily".to_string(), Vec::new()); assert_matches!(rx.try_recv(), Ok(AppEvent::RefreshTokenActivity { .. })); assert!(!chat.has_chatgpt_account()); assert!(chat.has_codex_backend_auth());}#[tokio::test]async fn clearing_pending_token_activity_refreshes_discards_late_result() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); assert_eq!( chat.pending_token_activity_output() .map(|cell| lines_to_single_string(&cell.display_lines(u16::MAX))), Some("/usage daily\n\n Token activity\n Loading...\n".to_string()), ); assert_eq!( chat.active_cell_transcript_lines(u16::MAX) .map(|lines| lines_to_single_string(&lines)), Some("/usage daily\n\n Token activity\n Loading...\n".to_string()), ); chat.clear_pending_token_activity_refreshes(); assert!( !chat.finish_token_activity_refresh( request_id, Err("stale token activity result".to_string()), ) );}#[tokio::test]async fn account_state_change_discards_pending_token_activity_refresh() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); assert!(chat.pending_token_activity_output().is_some()); chat.update_account_state( Some(crate::status::StatusAccountDisplay::ChatGpt { email: Some("new-account@example.com".to_string()), plan: None, }), /*plan_type*/ None, /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, ); assert!(chat.pending_token_activity_output().is_none()); assert!( !chat.finish_token_activity_refresh( request_id, Err("stale token activity result".to_string()), ) );}#[tokio::test]async fn pending_token_activity_refresh_renders_above_composer_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); dispatch_usage_and_expect_refresh(&mut chat, &mut rx); let width: u16 = 80; let height = chat.desired_height(width); let backend = VT100Backend::new(width, height); let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); term.set_viewport_area(Rect::new(/*x*/ 0, /*y*/ 0, width, height)); term.draw(|f| { chat.render(f.area(), f.buffer_mut()); }) .unwrap(); assert_chatwidget_snapshot!( "pending_token_activity_refresh_renders_above_composer_snapshot", normalize_snapshot_paths(term.backend().vt100().screen().contents()) );}#[tokio::test]async fn completed_token_activity_refresh_returns_one_history_cell() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); assert!(chat.pending_token_activity_output().is_some()); assert!( chat.finish_token_activity_refresh( request_id, Err("token activity unavailable".to_string()), ) ); assert!(chat.pending_token_activity_output().is_some()); let cell = chat .take_completed_token_activity_output() .expect("completed token activity cell"); assert!(chat.pending_token_activity_output().is_none()); assert_eq!( lines_to_single_string(&cell.display_lines(u16::MAX)), "/usage daily\n\n Token activity\n Token activity unavailable\n", );}#[tokio::test]async fn completed_token_activity_refresh_waits_for_active_stream() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); chat.on_agent_message_delta("partial response".to_string()); assert!(chat.usage_history_insertion_blocked()); assert!( chat.finish_token_activity_refresh( request_id, Err("token activity unavailable".to_string()), ) ); assert_eq!( chat.pending_token_activity_output() .map(|cell| lines_to_single_string(&cell.display_lines(u16::MAX))), Some("/usage daily\n\n Token activity\n Token activity unavailable\n".to_string()), ); chat.finalize_turn(); assert!(!chat.usage_history_insertion_blocked()); assert!( std::iter::from_fn(|| rx.try_recv().ok()) .any(|event| matches!(event, AppEvent::CommitPendingUsageOutputAfterStreamShutdown)) ); assert!(chat.take_completed_token_activity_output().is_some());}#[tokio::test]async fn completed_token_activity_refresh_waits_for_queued_stream_consolidation() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); chat.on_agent_message_delta("partial response".to_string()); chat.finalize_completed_assistant_message(/*message*/ None); assert!(chat.pending_stream_consolidations > 0); assert!( chat.finish_token_activity_refresh( request_id, Err("token activity unavailable".to_string()), ) ); assert!(chat.usage_history_insertion_blocked()); chat.note_stream_consolidation_completed(); assert!(!chat.usage_history_insertion_blocked());}#[tokio::test]async fn completed_token_activity_refresh_waits_for_active_history_cell() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); chat.transcript.active_cell = Some(Box::new(PlainHistoryCell::new(vec![Line::from( "active tool", )]))); assert!( chat.finish_token_activity_refresh( request_id, Err("token activity unavailable".to_string()), ) ); assert!(chat.usage_history_insertion_blocked()); chat.flush_active_cell(); assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); assert_matches!(rx.try_recv(), Ok(AppEvent::CommitPendingUsageOutput));}#[tokio::test]async fn completed_token_activity_refresh_waits_for_active_hook() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); handle_hook_started( &mut chat, hook_run( "post-tool-use:0:/tmp/hooks.json", codex_app_server_protocol::HookEventName::PostToolUse, codex_app_server_protocol::HookRunStatus::Running, "checking output policy", Vec::new(), ), ); let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); assert!( chat.finish_token_activity_refresh( request_id, Err("token activity unavailable".to_string()), ) ); assert!(chat.usage_history_insertion_blocked()); handle_hook_completed( &mut chat, hook_run( "post-tool-use:0:/tmp/hooks.json", codex_app_server_protocol::HookEventName::PostToolUse, codex_app_server_protocol::HookRunStatus::Completed, "checking output policy", vec![codex_app_server_protocol::HookOutputEntry { kind: codex_app_server_protocol::HookOutputEntryKind::Context, text: "hook context".to_string(), }], ), ); assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); assert_matches!(rx.try_recv(), Ok(AppEvent::CommitPendingUsageOutput));}#[tokio::test]async fn completed_token_activity_refresh_retries_after_plan_item_completion() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); let mut controller = crate::streaming::controller::PlanStreamController::new( /*width*/ None, &chat.config.cwd, chat.history_render_mode(), ); controller.push("Plan details"); chat.plan_stream_controller = Some(controller); assert!( chat.finish_token_activity_refresh( request_id, Err("token activity unavailable".to_string()), ) ); chat.on_plan_item_completed("Plan details".to_string()); assert!( std::iter::from_fn(|| rx.try_recv().ok()) .any(|event| matches!(event, AppEvent::CommitPendingUsageOutputAfterStreamShutdown)) );}#[tokio::test]async fn pending_token_activity_refresh_keeps_composer_visible_in_short_viewport() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.transcript.active_cell = Some(Box::new(PlainHistoryCell::new( std::iter::repeat_n(Line::from("active output"), /*n*/ 20).collect(), ))); dispatch_usage_and_expect_refresh(&mut chat, &mut rx); let width: u16 = 80; let height: u16 = 8; let backend = VT100Backend::new(width, height); let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); term.set_viewport_area(Rect::new(/*x*/ 0, /*y*/ 0, width, height)); term.draw(|f| { chat.render(f.area(), f.buffer_mut()); }) .unwrap(); assert!( term.backend() .vt100() .screen() .contents() .contains("Ask Codex to do anything") );}#[tokio::test]async fn repeated_token_activity_refreshes_keep_only_latest_card() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let first_request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx); chat.dispatch_command_with_args(SlashCommand::Usage, "weekly".to_string(), Vec::new()); let second_request_id = expect_token_activity_refresh(&mut rx); assert_eq!( chat.pending_token_activity_output() .map(|cell| lines_to_single_string(&cell.display_lines(u16::MAX))), Some("/usage weekly\n\n Token activity\n Loading...\n".to_string()), ); assert!(!chat.finish_token_activity_refresh( first_request_id, Err("stale token activity result".to_string()), )); assert!(chat.finish_token_activity_refresh( second_request_id, Err("token activity unavailable".to_string()), ));}#[tokio::test]async fn unrecognized_slash_command_is_not_added_to_local_recall() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; submit_composer_text(&mut chat, "/does-not-exist"); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|cell| lines_to_single_string(cell)) .collect::<Vec<_>>() .join("\n"); assert!( rendered.contains("Unrecognized command '/does-not-exist'"), "expected unrecognized-command message, got: {rendered:?}" ); assert_eq!(chat.bottom_pane.composer_text(), "/does-not-exist"); assert_eq!(recall_latest_after_clearing(&mut chat), "");}#[tokio::test]async fn unavailable_slash_command_is_available_from_local_recall() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.bottom_pane.set_task_running(/*running*/ true); submit_composer_text(&mut chat, "/model"); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|cell| lines_to_single_string(cell)) .collect::<Vec<_>>() .join("\n"); assert!( rendered.contains("'/model' is disabled while a task is in progress."), "expected disabled-command message, got: {rendered:?}" ); assert_eq!(recall_latest_after_clearing(&mut chat), "/model");}#[tokio::test]async fn no_op_stub_slash_command_is_available_from_local_recall() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; submit_composer_text(&mut chat, "/debug-m-drop"); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|cell| lines_to_single_string(cell)) .collect::<Vec<_>>() .join("\n"); assert!( rendered.contains("Memory maintenance"), "expected stub message, got: {rendered:?}" ); assert_eq!(recall_latest_after_clearing(&mut chat), "/debug-m-drop");}#[tokio::test]async fn slash_quit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Quit); assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));}#[tokio::test]async fn slash_logout_requests_app_server_logout() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Logout); assert_matches!(rx.try_recv(), Ok(AppEvent::Logout));}#[tokio::test]async fn slash_copy_state_tracks_turn_complete_final_reply() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; complete_turn_with_message(&mut chat, "turn-1", Some("Final reply **markdown**")); assert_eq!( chat.last_agent_markdown_text(), Some("Final reply **markdown**") );}#[tokio::test]async fn slash_copy_state_tracks_plan_item_completion() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let plan_text = "## Plan\n\n1. Build it\n2. Test it".to_string(); chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: String::new(), turn_id: "turn-1".to_string(), completed_at_ms: 0, item: AppServerThreadItem::Plan { id: "plan-1".to_string(), text: plan_text.clone(), }, }), /*replay_kind*/ None, ); handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); assert_eq!(chat.last_agent_markdown_text(), Some(plan_text.as_str())); assert_matches!( chat.pending_notification, Some(Notification::AgentTurnComplete { ref response }) if response == &plan_text );}#[tokio::test]async fn slash_copy_reports_when_no_agent_response_exists() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Copy); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected one info message"); let rendered = lines_to_single_string(&cells[0]); assert_chatwidget_snapshot!("slash_copy_no_output_info_message", rendered); assert!( rendered.contains("No agent response to copy"), "expected no-output message, got {rendered:?}" );}#[tokio::test]async fn ctrl_o_copy_reports_when_no_agent_response_exists() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected one info message"); let rendered = lines_to_single_string(&cells[0]); assert!( rendered.contains("No agent response to copy"), "expected no-output message, got {rendered:?}" );}#[tokio::test]async fn keymap_capture_can_capture_current_copy_shortcut() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let runtime_keymap = crate::keymap::RuntimeKeymap::defaults(); chat.open_keymap_capture( "composer".to_string(), "submit".to_string(), crate::app_event::KeymapEditIntent::ReplaceAll, &runtime_keymap, ); chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); let AppEvent::KeymapCaptured { context, action, key, intent, } = rx.try_recv().expect("captured key event") else { panic!("expected keymap capture event"); }; assert_eq!(context, "composer"); assert_eq!(action, "submit"); assert_eq!(key, "ctrl-o"); assert_eq!(intent, crate::app_event::KeymapEditIntent::ReplaceAll); assert!( drain_insert_history(&mut rx).is_empty(), "copy shortcut should not run while key capture is active" );}#[tokio::test]async fn slash_keymap_capture_can_capture_app_shortcuts() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let runtime_keymap = crate::keymap::RuntimeKeymap::defaults(); for (key, expected) in [('t', "ctrl-t"), ('l', "ctrl-l"), ('g', "ctrl-g")] { chat.open_keymap_capture( "global".to_string(), "open_transcript".to_string(), crate::app_event::KeymapEditIntent::ReplaceAll, &runtime_keymap, ); chat.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::CONTROL)); let AppEvent::KeymapCaptured { context, action, key, intent, } = rx.try_recv().expect("captured key event") else { panic!("expected keymap capture event"); }; assert_eq!(context, "global"); assert_eq!(action, "open_transcript"); assert_eq!(key, expected); assert_eq!(intent, crate::app_event::KeymapEditIntent::ReplaceAll); }}#[tokio::test]async fn slash_keymap_debug_opens_keypress_inspector() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command_with_args(SlashCommand::Keymap, "debug".to_string(), Vec::new()); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!(popup.contains("Keypress Inspector")); assert!(popup.contains("Waiting for a keypress")); chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); let popup = render_bottom_popup(&chat, /*width*/ 100); assert!(popup.contains("global.copy (Copy)")); assert!( drain_insert_history(&mut rx).is_empty(), "debug inspector should open without transcript messages" ); assert!(op_rx.try_recv().is_err(), "expected no core op to be sent");}#[tokio::test]async fn slash_keymap_debug_can_inspect_app_shortcuts() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command_with_args(SlashCommand::Keymap, "debug".to_string(), Vec::new()); for (key, expected_action) in [ ('t', "global.open_transcript (Open Transcript)"), ('l', "global.clear_terminal (Clear Terminal)"), ('g', "global.open_external_editor (Open External Editor)"), ] { chat.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::CONTROL)); let popup = render_bottom_popup(&chat, /*width*/ 100); assert!( popup.contains(expected_action), "expected {expected_action:?} in debug popup for ctrl-{key}, got {popup:?}" ); } assert!( drain_insert_history(&mut rx).is_empty(), "debug inspector should not run app shortcut side effects" ); assert!(op_rx.try_recv().is_err(), "expected no core op to be sent");}#[tokio::test]async fn slash_keymap_invalid_args_show_usage() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; submit_composer_text(&mut chat, "/keymap nope"); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|cell| lines_to_single_string(cell)) .collect::<Vec<_>>() .join("\n"); assert!( rendered.contains("Usage: /keymap [debug]"), "expected usage message, got: {rendered:?}" ); assert_eq!(recall_latest_after_clearing(&mut chat), "/keymap nope"); assert!(op_rx.try_recv().is_err(), "expected no core op to be sent");}#[tokio::test]async fn copy_shortcut_can_be_remapped() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let mut keymap_config = chat.config_ref().tui_keymap.clone(); keymap_config.global.copy = Some(codex_config::types::KeybindingsSpec::One( codex_config::types::KeybindingSpec("ctrl-x".to_string()), )); let runtime_keymap = crate::keymap::RuntimeKeymap::from_config(&keymap_config).expect("valid copy remap"); chat.apply_keymap_update(keymap_config, &runtime_keymap); chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)); assert!( drain_insert_history(&mut rx).is_empty(), "old copy shortcut should no longer copy" ); chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected one info message"); let rendered = lines_to_single_string(&cells[0]); assert!( rendered.contains("No agent response to copy"), "expected remapped copy shortcut to run, got {rendered:?}" );}#[tokio::test]async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.transcript.last_agent_markdown = Some("copy me".to_string()); chat.copy_last_agent_markdown_with(|markdown| { assert_eq!(markdown, "copy me"); Ok(Some(crate::clipboard_copy::ClipboardLease::test())) }); assert!(chat.clipboard_lease.is_some()); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected one success message"); let rendered = lines_to_single_string(&cells[0]); assert!( rendered.contains("Copied last message to clipboard"), "expected success message, got {rendered:?}" ); chat.copy_last_agent_markdown_with(|markdown| { assert_eq!(markdown, "copy me"); Err("blocked".into()) }); assert!(chat.clipboard_lease.is_some()); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected one failure message"); let rendered = lines_to_single_string(&cells[0]); assert!( rendered.contains("Copy failed: blocked"), "expected failure message, got {rendered:?}" );}#[tokio::test]async fn slash_copy_state_is_preserved_during_running_task() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; complete_turn_with_message(&mut chat, "turn-1", Some("Previous completed reply")); chat.on_task_started(); assert_eq!( chat.last_agent_markdown_text(), Some("Previous completed reply") );}#[tokio::test]async fn slash_copy_uses_agent_message_item_when_turn_complete_omits_final_text() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; handle_turn_started(&mut chat, "turn-1"); complete_assistant_message( &mut chat, "msg-1", "Legacy item final message", /*phase*/ None, ); let _ = drain_insert_history(&mut rx); handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); let _ = drain_insert_history(&mut rx); assert_eq!( chat.last_agent_markdown_text(), Some("Legacy item final message") ); assert_matches!( chat.pending_notification, Some(Notification::AgentTurnComplete { ref response }) if response == "Legacy item final message" );}#[tokio::test]async fn agent_turn_complete_notification_does_not_reuse_stale_copy_source() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; complete_turn_with_message(&mut chat, "turn-1", Some("Previous reply")); chat.pending_notification = None; handle_turn_completed(&mut chat, "turn-2", /*duration_ms*/ None); assert_matches!( chat.pending_notification, Some(Notification::AgentTurnComplete { ref response }) if response.is_empty() );}#[tokio::test]async fn active_goal_without_follow_up_suppresses_agent_turn_complete_notification() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); chat.handle_server_notification( ServerNotification::ThreadGoalUpdated( codex_app_server_protocol::ThreadGoalUpdatedNotification { thread_id: "thread-1".to_string(), turn_id: None, goal: codex_app_server_protocol::ThreadGoal { thread_id: "thread-1".to_string(), objective: "finish the benchmark".to_string(), status: codex_app_server_protocol::ThreadGoalStatus::Active, token_budget: None, tokens_used: 0, time_used_seconds: 0, created_at: 1, updated_at: 1, }, }, ), /*replay_kind*/ None, ); complete_turn_with_message(&mut chat, "turn-1", Some("Still working")); assert_matches!(chat.pending_notification, None);}#[tokio::test]async fn queued_follow_up_suppresses_agent_turn_complete_notification() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); chat.queue_user_message("Continue".into()); complete_turn_with_message(&mut chat, "turn-1", Some("Still working")); assert_matches!(chat.pending_notification, None); assert!(chat.input_queue.queued_user_messages.is_empty()); assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. });}#[tokio::test]async fn queued_menu_slash_keeps_agent_turn_complete_notification() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/model"); complete_turn_with_message(&mut chat, "turn-1", Some("Done")); assert_matches!( chat.pending_notification, Some(Notification::AgentTurnComplete { ref response }) if response == "Done" ); assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Select Model")); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));}#[tokio::test]async fn slash_copy_uses_latest_surviving_response_after_rollback() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; replay_user_message_text(&mut chat, "user-1", "foo", ReplayKind::ThreadSnapshot); replay_agent_message( &mut chat, "agent-1", "foo response", ReplayKind::ThreadSnapshot, ); replay_user_message_text(&mut chat, "user-2", "bar", ReplayKind::ThreadSnapshot); replay_agent_message( &mut chat, "agent-2", "bar response", ReplayKind::ThreadSnapshot, ); let _ = drain_insert_history(&mut rx); assert_eq!(chat.last_agent_markdown_text(), Some("bar response")); chat.truncate_agent_copy_history_to_user_turn_count(/*user_turn_count*/ 1); assert_eq!(chat.last_agent_markdown_text(), Some("foo response")); chat.copy_last_agent_markdown_with(|markdown| { assert_eq!(markdown, "foo response"); Ok(None) });}#[tokio::test]async fn slash_copy_reports_when_rewind_exceeds_retained_copy_history() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; replay_user_message_text(&mut chat, "user-1", "foo", ReplayKind::ThreadSnapshot); replay_agent_message( &mut chat, "agent-1", "foo response", ReplayKind::ThreadSnapshot, ); let _ = drain_insert_history(&mut rx); chat.truncate_agent_copy_history_to_user_turn_count(/*user_turn_count*/ 0); chat.dispatch_command(SlashCommand::Copy); let cells = drain_insert_history(&mut rx); let rendered = lines_to_single_string(&cells[0]); assert!( rendered.contains( "Cannot copy that response after rewinding. Only the most recent 32 responses are available to /copy." ), "expected evicted-history message, got {rendered:?}" );}#[tokio::test]async fn slash_exit_requests_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Exit); assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));}#[tokio::test]async fn slash_stop_submits_background_terminal_cleanup() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Stop); assert_matches!(op_rx.try_recv(), Ok(Op::CleanBackgroundTerminals)); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected cleanup confirmation message"); let rendered = lines_to_single_string(&cells[0]); assert!( rendered.contains("Stopping all background terminals."), "expected cleanup confirmation, got {rendered:?}" );}#[tokio::test]async fn slash_clear_requests_ui_clear_when_idle() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Clear); assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi));}#[tokio::test]async fn slash_clear_after_ctrl_c_keeps_stashed_draft_recallable() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); chat.bottom_pane .set_history_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 0); submit_composer_text(&mut chat, "ok"); assert_eq!(next_add_to_history_event(&mut rx), "ok"); let stashed_draft = "explain why history recall lost this draft"; chat.bottom_pane .set_composer_text(stashed_draft.to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); assert_eq!(chat.bottom_pane.composer_text(), ""); assert_eq!(next_add_to_history_event(&mut rx), stashed_draft); chat.bottom_pane .set_composer_text("/clear".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi)); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(chat.bottom_pane.composer_text(), stashed_draft); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(chat.bottom_pane.composer_text(), "ok");}#[tokio::test]async fn slash_clear_is_disabled_while_task_running() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.bottom_pane.set_task_running(/*running*/ true); chat.dispatch_command(SlashCommand::Clear); let event = rx.try_recv().expect("expected disabled command error"); match event { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert!( rendered.contains("'/clear' is disabled while a task is in progress."), "expected /clear task-running error, got {rendered:?}" ); } other => panic!("expected InsertHistoryCell error, got {other:?}"), } assert!(rx.try_recv().is_err(), "expected no follow-up events");}#[tokio::test]async fn slash_archive_is_disabled_while_task_running() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.bottom_pane.set_task_running(/*running*/ true); chat.dispatch_command(SlashCommand::Archive); let event = rx.try_recv().expect("expected disabled command error"); match event { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert!( rendered.contains("'/archive' is disabled while a task is in progress."), "expected /archive task-running error, got {rendered:?}" ); } other => panic!("expected InsertHistoryCell error, got {other:?}"), } assert!(rx.try_recv().is_err(), "expected no follow-up events");}#[tokio::test]async fn slash_memory_drop_reports_stubbed_feature() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::MemoryDrop); let event = rx.try_recv().expect("expected unsupported-feature error"); match event { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert!(rendered.contains("Memory maintenance: Not available in TUI yet.")); } other => panic!("expected InsertHistoryCell error, got {other:?}"), } assert!( op_rx.try_recv().is_err(), "expected no memory op to be sent" );}#[tokio::test]async fn slash_mcp_requests_inventory_via_app_server() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); chat.dispatch_command(SlashCommand::Mcp); assert!(active_blob(&chat).contains("Loading MCP inventory")); assert_matches!( rx.try_recv(), Ok(AppEvent::FetchMcpInventory { detail: McpServerStatusDetail::ToolsAndAuthOnly, thread_id: Some(actual_thread_id) }) if actual_thread_id == thread_id ); assert!(op_rx.try_recv().is_err(), "expected no core op to be sent");}#[tokio::test]async fn slash_mcp_verbose_requests_full_inventory_via_app_server() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); submit_composer_text(&mut chat, "/mcp verbose"); assert!(active_blob(&chat).contains("Loading MCP inventory")); assert_matches!( rx.try_recv(), Ok(AppEvent::FetchMcpInventory { detail: McpServerStatusDetail::Full, thread_id: Some(actual_thread_id) }) if actual_thread_id == thread_id ); assert!(op_rx.try_recv().is_err(), "expected no core op to be sent");}#[tokio::test]async fn slash_mcp_invalid_args_show_usage() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; submit_composer_text(&mut chat, "/mcp full"); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|cell| lines_to_single_string(cell)) .collect::<Vec<_>>() .join("\n"); assert!( rendered.contains("Usage: /mcp [verbose]"), "expected usage message, got: {rendered:?}" ); assert_eq!(recall_latest_after_clearing(&mut chat), "/mcp full"); assert!(op_rx.try_recv().is_err(), "expected no core op to be sent");}#[tokio::test]async fn slash_memories_opens_memory_menu() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::MemoryTool, /*enabled*/ true); chat.dispatch_command(SlashCommand::Memories); assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Use memories")); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); assert!(op_rx.try_recv().is_err(), "expected no core op to be sent");}#[tokio::test]async fn slash_memory_update_reports_stubbed_feature() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::MemoryUpdate); let event = rx.try_recv().expect("expected unsupported-feature error"); match event { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(/*width*/ 80)); assert!(rendered.contains("Memory maintenance: Not available in TUI yet.")); } other => panic!("expected InsertHistoryCell error, got {other:?}"), } assert!( op_rx.try_recv().is_err(), "expected no memory op to be sent" );}#[tokio::test]async fn slash_resume_opens_picker() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Resume); assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker));}#[tokio::test]async fn slash_import_opens_claude_code_import_picker() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Import); assert_matches!( rx.try_recv(), Ok(AppEvent::OpenExternalAgentConfigMigration) );}#[tokio::test]async fn slash_archive_confirmation_requests_current_thread_archive() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Archive); assert!(chat.bottom_pane.has_active_view()); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("slash_archive_confirmation_popup", popup); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_matches!(rx.try_recv(), Ok(AppEvent::ArchiveCurrentThread));}#[tokio::test]async fn slash_delete_confirmation_requests_current_thread_delete() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Delete); assert!(chat.bottom_pane.has_active_view()); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("slash_delete_confirmation_popup", popup); chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_matches!(rx.try_recv(), Ok(AppEvent::DeleteCurrentThread));}#[tokio::test]async fn slash_resume_with_arg_requests_named_session() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.bottom_pane.set_composer_text( "/resume my-saved-thread".to_string(), Vec::new(), Vec::new(), ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_matches!( rx.try_recv(), Ok(AppEvent::ResumeSessionByIdOrName(id_or_name)) if id_or_name == "my-saved-thread" ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));}#[tokio::test]#[serial]async fn slash_pets_opens_picker() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; force_pet_image_support(&mut chat); chat.dispatch_command(SlashCommand::Pets); assert!(chat.bottom_pane.has_active_view()); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("slash_pets_picker", popup);}#[tokio::test]#[serial]async fn slash_pets_with_arg_selects_named_pet() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; force_pet_image_support(&mut chat); chat.bottom_pane .set_composer_text("/pets chefito".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_matches!( rx.try_recv(), Ok(AppEvent::PetSelected { pet_id }) if pet_id == "chefito" ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));}#[tokio::test]#[serial]async fn slash_pets_disable_disables_pets_even_on_unsupported_terminal() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; force_tmux_pet_image_unsupported(&mut chat); chat.bottom_pane .set_composer_text("/pets disable".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_matches!(rx.try_recv(), Ok(AppEvent::PetDisabled)); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));}#[tokio::test]#[serial]async fn slash_pet_hide_disables_pets_even_on_unsupported_terminal() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; force_tmux_pet_image_unsupported(&mut chat); chat.bottom_pane .set_composer_text("/pet hide".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_matches!(rx.try_recv(), Ok(AppEvent::PetDisabled)); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));}#[tokio::test]#[serial]async fn slash_pets_on_unsupported_terminal_warns_without_picker() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; force_tmux_pet_image_unsupported(&mut chat); chat.dispatch_command(SlashCommand::Pets); assert!(!chat.bottom_pane.has_active_view()); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::<Vec<_>>() .join("\n"); assert!(rendered.contains("Pets are disabled in tmux.")); assert!(rendered.contains("outside tmux"));}#[tokio::test]#[serial]async fn slash_pets_with_arg_on_unsupported_terminal_warns_without_selection() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; force_tmux_pet_image_unsupported(&mut chat); chat.bottom_pane .set_composer_text("/pets chefito".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::<Vec<_>>() .join("\n"); assert!(rendered.contains("Pets are disabled in tmux.")); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));}#[tokio::test]#[serial]async fn slash_pets_on_unsupported_terminal_shows_terminal_warning() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; force_terminal_pet_image_unsupported(&mut chat); chat.dispatch_command(SlashCommand::Pets); assert!(!chat.bottom_pane.has_active_view()); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::<Vec<_>>() .join("\n"); assert!(rendered.contains("Pets aren’t available in this terminal.")); assert!(rendered.contains("Kitty graphics or Sixel support"));}#[tokio::test]#[serial]async fn slash_pets_on_old_iterm2_shows_upgrade_warning() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; force_old_iterm2_pet_image_unsupported(&mut chat); chat.dispatch_command(SlashCommand::Pets); assert!(!chat.bottom_pane.has_active_view()); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::<Vec<_>>() .join("\n"); assert!(rendered.contains("Pets require iTerm2 3.6 or newer.")); assert!(rendered.contains("Upgrade iTerm2 to use terminal pets."));}#[tokio::test]async fn slash_fork_requests_current_fork() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Fork); assert_matches!(rx.try_recv(), Ok(AppEvent::ForkCurrentSession));}#[tokio::test]async fn slash_app_requests_desktop_handoff() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); chat.dispatch_command(SlashCommand::App); assert_matches!( rx.try_recv(), Ok(AppEvent::OpenDesktopThread { thread_id: actual_thread_id, }) if actual_thread_id == thread_id );}#[tokio::test]async fn slash_app_without_thread_id_shows_starting_error() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::App); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected app startup error"); assert_chatwidget_snapshot!( "slash_app_without_thread_id_shows_starting_error", lines_to_single_string(&cells[0]) );}#[tokio::test]async fn slash_rollout_displays_current_path() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let rollout_path = PathBuf::from("/tmp/codex-test-rollout.jsonl"); chat.current_rollout_path = Some(rollout_path.clone()); chat.dispatch_command(SlashCommand::Rollout); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1, "expected info message for rollout path"); let rendered = lines_to_single_string(&cells[0]); assert!( rendered.contains(&rollout_path.display().to_string()), "expected rollout path to be shown: {rendered}" );}#[tokio::test]async fn slash_rollout_handles_missing_path() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Rollout); let cells = drain_insert_history(&mut rx); assert_eq!( cells.len(), 1, "expected info message explaining missing path" ); let rendered = lines_to_single_string(&cells[0]); assert!( rendered.contains("not available"), "expected missing rollout path message: {rendered}" );}#[tokio::test]async fn fast_slash_command_updates_and_persists_local_service_tier() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); chat.handle_service_tier_command_dispatch(fast_tier_command()); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>(); assert!( events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::OverrideTurnContext { service_tier: Some(Some(service_tier)), .. }) if service_tier == ServiceTier::Fast.request_value() )), "expected fast-mode override app event; events: {events:?}" ); assert!( events.iter().any(|event| matches!( event, AppEvent::PersistServiceTierSelection { service_tier: Some(service_tier), } if service_tier == ServiceTier::Fast.request_value() )), "expected fast-mode persistence app event; events: {events:?}" ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));}#[tokio::test]async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); chat.toggle_fast_mode_from_ui(); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>(); assert!( events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::OverrideTurnContext { service_tier: Some(Some(service_tier)), .. }) if service_tier == ServiceTier::Fast.request_value() )), "expected fast-mode override app event; events: {events:?}" ); assert!( events.iter().any(|event| matches!( event, AppEvent::PersistServiceTierSelection { service_tier: Some(service_tier), } if service_tier == ServiceTier::Fast.request_value() )), "expected fast-mode persistence app event; events: {events:?}" ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));}#[tokio::test]async fn fast_keybinding_toggle_requires_feature_and_idle_surface() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ false); assert!(!chat.can_toggle_fast_mode_from_keybinding()); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); assert!(chat.can_toggle_fast_mode_from_keybinding()); chat.bottom_pane.set_task_running(/*running*/ true); assert!(!chat.can_toggle_fast_mode_from_keybinding());}#[tokio::test]async fn user_turn_carries_service_tier_after_fast_toggle() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.thread_id = Some(ThreadId::new()); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); chat.handle_service_tier_command_dispatch(fast_tier_command()); let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>(); chat.bottom_pane .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { service_tier: Some(Some(service_tier)), .. } if service_tier == ServiceTier::Fast.request_value() => {} other => panic!("expected Op::UserTurn with fast service tier, got {other:?}"), }}#[tokio::test]async fn model_switch_recomputes_catalog_default_service_tier() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; chat.thread_id = Some(ThreadId::new()); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); let mut models = chat.model_catalog.try_list_models().expect("test catalog"); let default_model = models .iter_mut() .find(|model| model.model == "gpt-5.4") .expect("gpt-5.4 test model"); default_model.default_service_tier = Some(ServiceTier::Fast.request_value().to_string()); chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(models)); chat.refresh_effective_service_tier(); assert_eq!(chat.current_service_tier(), None); chat.set_model("gpt-5.4"); assert_eq!( chat.current_service_tier(), Some(ServiceTier::Fast.request_value()) ); chat.set_model("gpt-5.3-codex"); assert_eq!(chat.current_service_tier(), None); chat.bottom_pane .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { service_tier: Some(Some(service_tier)), .. } if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE => {} other => panic!("expected Op::UserTurn with default service tier override, got {other:?}"), }}#[tokio::test]async fn queued_fast_slash_applies_before_next_queued_message() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.thread_id = Some(ThreadId::new()); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); handle_turn_started(&mut chat, "turn-1"); queue_composer_text_with_tab(&mut chat, "/fast"); queue_composer_text_with_tab(&mut chat, "hello after fast"); complete_turn_with_message(&mut chat, "turn-1", Some("done")); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>(); assert!( events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::OverrideTurnContext { service_tier: Some(Some(service_tier)), .. }) if service_tier == ServiceTier::Fast.request_value() )), "expected queued /fast to update service tier before next turn; events: {events:?}" ); match next_submit_op(&mut op_rx) { Op::UserTurn { items, service_tier: Some(Some(service_tier)), .. } if service_tier == ServiceTier::Fast.request_value() => assert_eq!( items, vec![UserInput::Text { text: "hello after fast".to_string(), text_elements: Vec::new(), }] ), other => panic!("expected queued message to submit with fast tier, got {other:?}"), }}#[tokio::test]async fn user_turn_sends_standard_override_after_fast_is_turned_off() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.thread_id = Some(ThreadId::new()); set_chatgpt_auth(&mut chat); set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); chat.handle_service_tier_command_dispatch(fast_tier_command()); let _events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>(); chat.handle_service_tier_command_dispatch(fast_tier_command()); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>(); assert!( events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::OverrideTurnContext { service_tier: Some(Some(service_tier)), .. }) if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE )), "expected fast-mode off default service tier app event; events: {events:?}" ); assert!( events.iter().any(|event| matches!( event, AppEvent::PersistServiceTierSelection { service_tier: Some(service_tier) } if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE )), "expected default service tier persistence app event; events: {events:?}" ); chat.bottom_pane .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { service_tier: Some(Some(service_tier)), .. } if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE => {} other => panic!("expected Op::UserTurn with default service tier override, got {other:?}"), }}#[tokio::test]async fn raw_slash_command_toggles_and_accepts_on_off_args() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command(SlashCommand::Raw); assert!(chat.raw_output_mode()); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>(); assert!( events .iter() .any(|event| matches!(event, AppEvent::RawOutputModeChanged { enabled: true })) ); chat.dispatch_command_with_args(SlashCommand::Raw, "off".to_string(), Vec::new()); assert!(!chat.raw_output_mode()); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>(); assert!( events .iter() .any(|event| matches!(event, AppEvent::RawOutputModeChanged { enabled: false })) ); chat.dispatch_command_with_args(SlashCommand::Raw, "on".to_string(), Vec::new()); assert!(chat.raw_output_mode()); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>(); assert!( events .iter() .any(|event| matches!(event, AppEvent::RawOutputModeChanged { enabled: true })) );}#[tokio::test]async fn raw_slash_command_reports_usage_for_invalid_arg() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.dispatch_command_with_args(SlashCommand::Raw, "status".to_string(), Vec::new()); assert!(!chat.raw_output_mode()); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::<Vec<_>>() .join("\n"); assert!( rendered.contains("Usage: /raw [on|off]"), "expected raw usage error, got {rendered:?}" );}#[tokio::test]async fn compact_queues_user_messages_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); chat.submit_user_message(UserMessage::from( "Steer submitted while /compact was running.".to_string(), )); handle_error( &mut chat, "cannot steer a compact turn", Some(CodexErrorInfo::ActiveTurnNotSteerable { turn_kind: NonSteerableTurnKind::Compact, }), ); let width: u16 = 80; let height: u16 = 18; let backend = VT100Backend::new(width, height); let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); let desired_height = chat.desired_height(width).min(height); term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height)); term.draw(|f| { chat.render(f.area(), f.buffer_mut()); }) .unwrap(); assert_chatwidget_snapshot!( "compact_queues_user_messages_snapshot", normalize_snapshot_paths(term.backend().vt100().screen().contents()) );}