mirror of
https://github.com/openai/codex.git
synced 2026-11-03 16:23:51 +00:00
## 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
2959 lines
105 KiB
Rust
2959 lines
105 KiB
Rust
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())
|
||
);
|
||
}
|