Files
codex/codex-rs/tui/src/chatwidget/tests/slash_commands.rs
T
Felipe Coury 1e85ca099e Support two-stroke TUI key chords (#36511)
## What changed

- Accept two-stroke bindings such as `ctrl-x ctrl-s` in TUI keymap configuration while preserving arrays as alternative bindings.
- Route chords through the active TUI context, show pending and configured chord hints, and cancel pending chords on `esc`, context changes, or a one-second timeout.
- Extend `/keymap` to capture, add, replace, and display key chords.
- Reject chords that conflict with active single-key bindings, overlapping-context chords, or reserved terminal shortcuts.

## Testing

- Add coverage for chord parsing, validation, matching, timeout and cancellation behavior, context dispatch, key capture, and rendered shortcut hints.

GitOrigin-RevId: dfdede03f518d4936ad8621ebf5537026f2b282e
2026-08-01 17:43:03 +00:00

2959 lines
105 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
handle_turn_started(&mut chat, "turn-2");
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);
handle_turn_completed(&mut chat, "turn-2", /*duration_ms*/ None);
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, /*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(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(/*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, /*width*/ 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(/*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(/*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,
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(
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![codex_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(/*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),
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(/*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.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(/*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,
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(/*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, "/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(/*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,
crate::app_event::KeymapCaptureMode::SingleKey,
&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,
crate::app_event::KeymapCaptureMode::SingleKey,
&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_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 { name: None }));
}
#[tokio::test]
async fn slash_new_with_name_requests_named_session() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane
.set_composer_text("/new Add User ".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::NewSession {
name: Some(name)
}) if name == "Add User"
);
}
#[tokio::test]
async fn slash_clear_with_name_requests_named_session() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane
.set_composer_text("/clear Add User ".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::ClearUi {
name: Some(name)
}) if name == "Add User"
);
}
#[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 { name: None }));
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_while_mcp_startup_is_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::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_while_mcp_startup_is_running() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane.set_task_running(/*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(/*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 { name: None })
);
}
#[tokio::test]
async fn slash_fork_with_name_requests_named_fork() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane
.set_composer_text("/fork Add User ".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::ForkCurrentSession {
name: Some(name)
}) if name == "Add User"
);
}
#[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.bottom_pane.set_task_running(/*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, /*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.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, /*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.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, /*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())
);
}