use super::*;
use crate::tui_internal::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::tui_internal::pets::PetImageSupport::Supported(
crate::tui_internal::pets::ImageProtocol::Kitty,
));
}
fn force_tmux_pet_image_unsupported(chat: &mut ChatWidget) {
chat.set_pet_image_support_for_tests(crate::tui_internal::pets::PetImageSupport::Unsupported(
crate::tui_internal::pets::PetImageUnsupportedReason::Tmux,
));
}
fn force_terminal_pet_image_unsupported(chat: &mut ChatWidget) {
chat.set_pet_image_support_for_tests(crate::tui_internal::pets::PetImageSupport::Unsupported(
crate::tui_internal::pets::PetImageUnsupportedReason::Terminal,
));
}
fn force_old_iterm2_pet_image_unsupported(chat: &mut ChatWidget) {
chat.set_pet_image_support_for_tests(crate::tui_internal::pets::PetImageSupport::Unsupported(
crate::tui_internal::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, 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( 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( 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( 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( 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( 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( 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( 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", "", 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,
cancel_key: KeyEvent,
) {
let (mut chat, mut 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, 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(cancel_key);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
assert!(
std::iter::from_fn(|| rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::SettingsSelectionClosed))
);
chat.set_queue_autosend_suppressed( false);
chat.maybe_send_next_queued_input();
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",
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
)
.await;
assert_cancelled_queued_menu_drains_next_input(
"/permissions",
"Update Model Permissions",
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
)
.await;
}
#[tokio::test]
async fn queued_settings_selection_applies_before_next_input() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
chat.thread_id = Some(ThreadId::new());
let mut preset = get_available_model(&chat, "gpt-5.6-terra");
preset.supported_reasoning_efforts.truncate(1);
let selected_effort = preset.supported_reasoning_efforts[0].effort.clone();
chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset]));
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "/model");
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, 80);
assert!(
popup.contains("Select Model and Effort"),
"expected model menu to open; popup:\n{popup}"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::OpenReasoningPopup { model } => chat.open_reasoning_popup(model),
AppEvent::UpdateModel(model) => chat.set_model(&model),
AppEvent::UpdateReasoningEffort(effort) => chat.set_reasoning_effort(effort),
AppEvent::SettingsSelectionClosed => {
chat.app_event_tx.send(AppEvent::SettingsSelectionSettled);
}
AppEvent::SettingsSelectionSettled if chat.no_modal_or_popup_active() => {
chat.set_queue_autosend_suppressed( false);
chat.maybe_send_next_queued_input();
}
_ => {}
}
}
match next_submit_op(&mut op_rx) {
Op::UserTurn { model, effort, .. } => assert_eq!(
(model, effort),
("gpt-5.6-terra".to_string(), Some(selected_effort))
),
other => panic!("expected queued message with updated model, 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( 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, 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(
lemurclaw_core::app_server_protocol::ThreadNameUpdatedNotification {
thread_id: thread_id.to_string(),
thread_name: Some("Queued rename".to_string()),
},
),
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( 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(
None,
ThreadInputStateRestoreMode {
preserve_in_flight_turn: true,
},
);
assert!(!chat.input_queue.user_turn_pending_start);
chat.restore_thread_input_state(
Some(input_state),
ThreadInputStateRestoreMode {
preserve_in_flight_turn: true,
},
);
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(
lemurclaw_core::app_server_protocol::ThreadNameUpdatedNotification {
thread_id: thread_id.to_string(),
thread_name: Some("Queued rename".to_string()),
},
),
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( 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( 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( 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( None).await;
chat.instruction_source_paths = vec![lemurclaw_core::utils_path_uri::PathUri::from_abs_path(
&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( 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( 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( None).await;
chat.set_feature_enabled(Feature::Goals, 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::tui_internal::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( None).await;
chat.set_feature_enabled(Feature::Goals, 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( None).await;
chat.set_feature_enabled(Feature::Goals, 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( None).await;
chat.set_feature_enabled(Feature::Goals, 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( None).await;
chat.set_feature_enabled(Feature::Goals, 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( None).await;
chat.set_feature_enabled(Feature::Goals, 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( None).await;
chat.set_feature_enabled(Feature::Goals, 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( None).await;
chat.set_feature_enabled(Feature::Goals, 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( None).await;
chat.set_feature_enabled(Feature::Goals, true);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
handle_turn_started(&mut chat, "turn-1");
let paste = "x".repeat(lemurclaw_core::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( None).await;
chat.set_feature_enabled(Feature::Goals, true);
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
handle_turn_started(&mut chat, "turn-1");
let paste = "x".repeat(lemurclaw_core::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( None).await;
chat.set_feature_enabled(Feature::Goals, true);
handle_turn_started(&mut chat, "turn-1");
let first = "a".repeat(lemurclaw_core::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( None).await;
chat.set_feature_enabled(Feature::Goals, 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( None).await;
chat.set_feature_enabled(Feature::Goals, 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( None).await;
restored_chat.set_feature_enabled(Feature::Goals, true);
restored_chat.restore_thread_input_state(
Some(input_state),
ThreadInputStateRestoreMode {
preserve_in_flight_turn: true,
},
);
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( 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( None).await;
chat.thread_name = Some("Current project title".to_string());
chat.dispatch_command(SlashCommand::Rename);
let popup = render_bottom_popup(&chat, 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( None).await;
chat.dispatch_command(SlashCommand::Rename);
let popup = render_bottom_popup(&chat, 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.2")).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( 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( 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( 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( None).await;
chat.update_account_state(
None, None,
false, 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(
None,
false,
true,
FrameRequester::test_dummy(),
)
.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( 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( 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::tui_internal::status::StatusAccountDisplay::ChatGpt {
email: Some("new-account@example.com".to_string()),
plan: None,
}),
None,
true,
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( 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::tui_internal::custom_terminal::Terminal::with_options(backend).expect("terminal");
term.set_viewport_area(Rect::new( 0, 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( 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( 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( 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( 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( 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( None).await;
set_chatgpt_auth(&mut chat);
handle_hook_started(
&mut chat,
hook_run(
"post-tool-use:0:/tmp/hooks.json",
lemurclaw_core::app_server_protocol::HookEventName::PostToolUse,
lemurclaw_core::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",
lemurclaw_core::app_server_protocol::HookEventName::PostToolUse,
lemurclaw_core::app_server_protocol::HookRunStatus::Completed,
"checking output policy",
vec![lemurclaw_core::app_server_protocol::HookOutputEntry {
kind: lemurclaw_core::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( None).await;
set_chatgpt_auth(&mut chat);
let request_id = dispatch_usage_and_expect_refresh(&mut chat, &mut rx);
let mut controller = crate::tui_internal::streaming::controller::PlanStreamController::new(
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( None).await;
set_chatgpt_auth(&mut chat);
chat.transcript.active_cell = Some(Box::new(PlainHistoryCell::new(
std::iter::repeat_n(Line::from("active output"), 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::tui_internal::custom_terminal::Terminal::with_options(backend).expect("terminal");
term.set_viewport_area(Rect::new( 0, 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( 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( 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( None).await;
chat.bottom_pane.set_task_running( true);
submit_composer_text(&mut chat, "/review");
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("'/review' is disabled while a task is in progress."),
"expected disabled-command message, got: {rendered:?}"
);
assert_eq!(recall_latest_after_clearing(&mut chat), "/review");
}
#[tokio::test]
async fn no_op_stub_slash_command_is_available_from_local_recall() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( 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( 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( 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( 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( 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(),
},
}),
None,
);
handle_turn_completed(&mut chat, "turn-1", 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( 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( 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( None).await;
let runtime_keymap = crate::tui_internal::keymap::RuntimeKeymap::defaults();
chat.open_keymap_capture(
"composer".to_string(),
"submit".to_string(),
crate::tui_internal::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::tui_internal::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( None).await;
let runtime_keymap = crate::tui_internal::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::tui_internal::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::tui_internal::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( None).await;
chat.dispatch_command_with_args(SlashCommand::Keymap, "debug".to_string(), Vec::new());
let popup = render_bottom_popup(&chat, 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, 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( 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, 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( 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( None).await;
let mut keymap_config = chat.config_ref().tui_keymap.clone();
keymap_config.global.copy = Some(lemurclaw_core::config::types::KeybindingsSpec::One(
lemurclaw_core::config::types::KeybindingSpec("ctrl-x".to_string()),
));
let runtime_keymap =
crate::tui_internal::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( 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::tui_internal::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( 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( None).await;
handle_turn_started(&mut chat, "turn-1");
complete_assistant_message(
&mut chat,
"msg-1",
"Legacy item final message",
None,
);
let _ = drain_insert_history(&mut rx);
handle_turn_completed(&mut chat, "turn-1", 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( None).await;
complete_turn_with_message(&mut chat, "turn-1", Some("Previous reply"));
chat.pending_notification = None;
handle_turn_completed(&mut chat, "turn-2", 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( None).await;
chat.set_feature_enabled(Feature::Goals, true);
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
lemurclaw_core::app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: "thread-1".to_string(),
turn_id: None,
goal: lemurclaw_core::app_server_protocol::ThreadGoal {
thread_id: "thread-1".to_string(),
objective: "finish the benchmark".to_string(),
status: lemurclaw_core::app_server_protocol::ThreadGoalStatus::Active,
token_budget: None,
tokens_used: 0,
time_used_seconds: 0,
created_at: 1,
updated_at: 1,
},
},
),
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( 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, 80).contains("Select Model"));
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn slash_exit_requests_exit() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( 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( 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( 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( None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.bottom_pane
.set_history_metadata(thread_id, 1, 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( None).await;
chat.bottom_pane.set_task_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( 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( None).await;
chat.bottom_pane.set_task_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( 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( 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( 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( 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( 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( 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( None).await;
chat.set_feature_enabled(Feature::MemoryTool, true);
chat.dispatch_command(SlashCommand::Memories);
assert!(render_bottom_popup(&chat, 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( 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( 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_while_mcp_startup_is_running() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual( None).await;
chat.bottom_pane.set_task_running( true);
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( 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( 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, 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( 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, 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_while_mcp_startup_is_running() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual( None).await;
chat.bottom_pane.set_task_running( true);
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( 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, 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( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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( 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, true);
chat.bottom_pane.set_task_running( true);
submit_composer_text(&mut chat, "/fast");
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, 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, false);
assert!(!chat.can_toggle_fast_mode_from_keybinding());
chat.set_feature_enabled(Feature::FastMode, true);
assert!(chat.can_toggle_fast_mode_from_keybinding());
chat.bottom_pane.set_task_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, 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.2")).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, 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.2");
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, 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, 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( 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( 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( 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::tui_internal::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())
);
}