mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
## Why Cloud messages previously accepted only plain text, and switching a draft to Cloud waited for session preparation without processing composer input. ## What changed - Support ordered text and image parts in Cloud submissions and user-message summaries, preserving attachment placeholders, image detail, and client message IDs. - Accept inline data URLs and uploaded file IDs, including image-only messages. Reject invalid references and text spans locally, and cap encoded image requests at 3.75 MiB while retaining the existing plain-text limit. - Keep the composer editable and redraw the TUI while Cloud prepares a conversation. Carry the latest draft, attachments, and Vim mode into the destination, retain drafts on failure, and prevent submission to the outgoing session during the handoff. - Preserve buffered paste input and pending clipboard reads across the handoff, with connection and failure hints. ## Testing Add regression coverage for image wire encoding, receipt identity, validation and size boundaries, draft editing during successful and failed handoffs, clipboard completion, Vim state, and attachment recovery after rejected submissions. GitOrigin-RevId: 0cbcde0ab89ba961780b5f88456057e6b6a19cd5
3924 lines
148 KiB
Rust
3924 lines
148 KiB
Rust
use super::helpers::drain_insert_history_transcript;
|
||
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 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")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn next_copy_selection(
|
||
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||
) -> (String, String) {
|
||
let selection = match rx.try_recv() {
|
||
Ok(AppEvent::CopySelection { text, label, .. }) => (text.to_string(), label),
|
||
other => panic!("expected selected clipboard content, got {other:?}"),
|
||
};
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::SettingsSelectionClosed));
|
||
selection
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn service_tier_commands_and_saved_selection_respect_independent_speed_policy() {
|
||
for (fast_enabled, ultrafast_enabled) in [(false, true), (true, false)] {
|
||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.5")).await;
|
||
let mut preset = get_available_model(&chat, "gpt-5.5");
|
||
preset.service_tiers = ["priority", "ultrafast"]
|
||
.into_iter()
|
||
.map(|tier| codex_protocol::openai_models::ModelServiceTier {
|
||
id: tier.to_string(),
|
||
name: tier.to_string(),
|
||
description: format!("{tier} processing"),
|
||
})
|
||
.collect();
|
||
preset.default_service_tier = Some("ultrafast".to_string());
|
||
chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset]));
|
||
chat.set_feature_enabled(Feature::FastMode, fast_enabled);
|
||
chat.set_feature_enabled(Feature::UltrafastMode, ultrafast_enabled);
|
||
chat.set_service_tier(Some("ultrafast".to_string()));
|
||
assert_eq!(
|
||
chat.current_service_tier(),
|
||
ultrafast_enabled.then_some("ultrafast")
|
||
);
|
||
assert_eq!(
|
||
chat.current_model_service_tier_commands()
|
||
.into_iter()
|
||
.map(|tier| tier.id)
|
||
.collect::<Vec<_>>(),
|
||
vec![
|
||
if ultrafast_enabled {
|
||
"ultrafast"
|
||
} else {
|
||
"priority"
|
||
}
|
||
.to_string()
|
||
],
|
||
);
|
||
chat.bottom_pane
|
||
.set_composer_text("/ultra".to_string(), Vec::new(), Vec::new());
|
||
let snapshot_name = if ultrafast_enabled {
|
||
"ultrafast_command_allowed"
|
||
} else {
|
||
"ultrafast_command_denied"
|
||
};
|
||
insta::assert_snapshot!(
|
||
snapshot_name,
|
||
normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 80))
|
||
);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn service_tier_commands_lowercase_catalog_names() {
|
||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.5")).await;
|
||
let mut preset = get_available_model(&chat, "gpt-5.5");
|
||
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 slash_recap_requests_generation_for_current_thread() {
|
||
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::Recap);
|
||
|
||
assert_matches!(
|
||
rx.try_recv(),
|
||
Ok(AppEvent::GenerateRecap {
|
||
thread_id: requested_thread_id,
|
||
}) if requested_thread_id == thread_id
|
||
);
|
||
}
|
||
|
||
#[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_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
|
||
|
||
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);
|
||
if command.trim() == "/permissions" {
|
||
assert!(
|
||
std::iter::from_fn(|| rx.try_recv().ok())
|
||
.any(|event| matches!(event, AppEvent::OpenPermissionsPopup)),
|
||
"permission menu must go through the app"
|
||
);
|
||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||
chat.open_permissions_popup();
|
||
}
|
||
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::ApplyModelSelection { model, effort } => {
|
||
chat.set_model(&model);
|
||
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, 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, "/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());
|
||
|
||
chat.set_local_worktree_operations(/*enabled*/ false);
|
||
let drain = chat.submit_queued_slash_prompt(UserMessage::from("/worktree extra").into());
|
||
assert_matches!(drain, QueueDrain::Continue);
|
||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||
assert!(drain_insert_history(&mut rx).iter().any(|lines| {
|
||
lines_to_single_string(lines).contains("Unrecognized command '/worktree'")
|
||
}));
|
||
|
||
chat.set_local_worktree_operations(/*enabled*/ true);
|
||
let non_git = tempfile::tempdir().unwrap();
|
||
chat.config.cwd = non_git.path().to_path_buf().abs();
|
||
let drain = chat.submit_queued_slash_prompt(UserMessage::from("/worktree").into());
|
||
assert_matches!(drain, QueueDrain::Continue);
|
||
assert!(drain_insert_history(&mut rx).iter().any(|lines| {
|
||
lines_to_single_string(lines).contains("Managed worktrees require a local Git repository.")
|
||
}));
|
||
}
|
||
|
||
#[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);
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
|
||
|
||
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(),
|
||
}],
|
||
crate::bottom_pane::ShellPrefixHandling::Interpret,
|
||
);
|
||
|
||
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::FollowTranscript));
|
||
|
||
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);
|
||
chat.bottom_pane.set_vim_enabled(/*enabled*/ true);
|
||
|
||
chat.bottom_pane
|
||
.set_composer_text(command.into(), Vec::new(), Vec::new());
|
||
chat.handle_key_event(KeyCode::Esc.into());
|
||
chat.handle_key_event(KeyCode::Enter.into());
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
|
||
|
||
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);
|
||
}
|
||
}
|
||
chat.handle_key_event(KeyCode::Char('x').into());
|
||
chat.handle_key_event(KeyCode::Right.into());
|
||
chat.handle_key_event(KeyCode::Esc.into());
|
||
chat.handle_key_event(KeyCode::Char('.').into());
|
||
assert_eq!(chat.bottom_pane.composer_text(), "xx");
|
||
}
|
||
}
|
||
|
||
#[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");
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
|
||
|
||
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(),
|
||
}],
|
||
crate::bottom_pane::ShellPrefixHandling::Interpret,
|
||
);
|
||
|
||
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_requests_and_prefills_an_editable_title_suggestion() {
|
||
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.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_suggestion_loading", popup);
|
||
|
||
let request_id = match rx.try_recv() {
|
||
Ok(AppEvent::SuggestThreadName {
|
||
thread_id: requested_thread_id,
|
||
request_id,
|
||
}) => {
|
||
assert_eq!(requested_thread_id, thread_id);
|
||
request_id
|
||
}
|
||
other => panic!("expected title suggestion request, got {other:?}"),
|
||
};
|
||
|
||
chat.apply_thread_name_suggestion(thread_id, request_id, Some("Fix login timeout"));
|
||
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
assert_chatwidget_snapshot!("slash_rename_suggestion_prefilled", popup);
|
||
assert_eq!(chat.thread_name.as_deref(), Some("Current project title"));
|
||
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert_matches!(
|
||
rx.try_recv(),
|
||
Ok(AppEvent::CodexOp(Op::SetThreadName { name })) if name == "Fix login timeout"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_rename_preserves_manual_edits_when_suggestion_arrives() {
|
||
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::Rename);
|
||
|
||
let request_id = match rx.try_recv() {
|
||
Ok(AppEvent::SuggestThreadName {
|
||
thread_id: requested_thread_id,
|
||
request_id,
|
||
}) => {
|
||
assert_eq!(requested_thread_id, thread_id);
|
||
request_id
|
||
}
|
||
other => panic!("expected title suggestion request, got {other:?}"),
|
||
};
|
||
|
||
chat.handle_paste("My own title".to_string());
|
||
chat.apply_thread_name_suggestion(thread_id, request_id, Some("Generated title"));
|
||
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
assert!(popup.contains("My own title"));
|
||
assert!(!popup.contains("Generated title"));
|
||
assert!(!popup.contains("Generating a title suggestion"));
|
||
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert_matches!(
|
||
rx.try_recv(),
|
||
Ok(AppEvent::CodexOp(Op::SetThreadName { name })) if name == "My own 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_views_open_the_requested_summary_mode() {
|
||
use crate::analytics::TokenActivityView;
|
||
for (argument, expected) in [
|
||
("daily", TokenActivityView::Daily),
|
||
("weekly", TokenActivityView::Weekly),
|
||
("cumulative", TokenActivityView::Cumulative),
|
||
] {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
set_chatgpt_auth(&mut chat);
|
||
chat.dispatch_command_with_args(SlashCommand::Usage, argument.to_string(), Vec::new());
|
||
match rx.try_recv().unwrap() {
|
||
AppEvent::OpenAnalytics { view } => assert_eq!(view, Some(expected)),
|
||
other => panic!("expected Analytics, got {other:?}"),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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::OpenAnalytics {
|
||
view: Some(crate::analytics::TokenActivityView::Daily)
|
||
})
|
||
);
|
||
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::OpenAnalytics {
|
||
view: Some(crate::analytics::TokenActivityView::Daily)
|
||
})
|
||
);
|
||
assert!(!chat.has_chatgpt_account());
|
||
assert!(chat.has_codex_backend_auth());
|
||
}
|
||
|
||
#[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_exit_aliases_request_exit() {
|
||
for command in [SlashCommand::Quit, SlashCommand::Exit] {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.dispatch_command(command);
|
||
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_picker_uses_completed_commentary_during_active_turn() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
complete_turn_with_message(&mut chat, "turn-1", Some("Previous final response"));
|
||
handle_turn_started(&mut chat, "active");
|
||
let commentary = "Here is the command:\n\n```sh\necho current\n```";
|
||
let current_thread_id = chat.thread_id.map(|id| id.to_string()).unwrap_or_default();
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
|
||
current_thread_id,
|
||
"active".to_string(),
|
||
AppServerThreadItemPayload::AgentMessage {
|
||
moderation: None,
|
||
id: "active-commentary".to_string(),
|
||
text: commentary.to_string(),
|
||
phase: Some(MessagePhase::Commentary),
|
||
memory_citation: None,
|
||
delivery: None,
|
||
questions: None,
|
||
},
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
)),
|
||
/*replay_kind*/ None,
|
||
);
|
||
while rx.try_recv().is_ok() {}
|
||
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
insta::assert_snapshot!(render_bottom_popup(&chat, /*width*/ 80));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
(commentary.to_string(), "Whole response".to_string(),)
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_state_tracks_plan_item_completion() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
complete_turn_with_message(&mut chat, "previous", Some("```rust\nstale();\n```"));
|
||
let plan_text = "## Plan\n\n1. Build it\n2. Test it\n\n```sh\r\njust test \r\n```".to_string();
|
||
|
||
chat.handle_server_notification(
|
||
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
|
||
String::new(),
|
||
"turn-1".to_string(),
|
||
AppServerThreadItemPayload::Plan {
|
||
id: "plan-1".to_string(),
|
||
text: plan_text.clone(),
|
||
},
|
||
/*started_at_ms*/ None,
|
||
/*completed_at_ms*/ 0,
|
||
)),
|
||
/*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
|
||
);
|
||
|
||
let _ = drain_insert_history(&mut rx);
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
assert!(popup.contains("Whole response"), "picker: {popup}");
|
||
assert!(popup.contains("sh code"), "picker: {popup}");
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
(plan_text, "Whole response".to_string())
|
||
);
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
("just test \r\n".to_string(), "sh code".to_string())
|
||
);
|
||
chat.transcript.plan_delta_buffer = " ```sh\n echo hi \n ```\n".to_string();
|
||
chat.on_plan_item_completed(String::new());
|
||
assert_eq!(
|
||
chat.last_agent_markdown_text(),
|
||
Some("```sh\n echo hi \n ```")
|
||
);
|
||
let _ = drain_insert_history(&mut rx);
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
("echo hi \n".to_string(), "sh code".to_string())
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_picker_preserves_completed_source_whitespace_and_hides_directives() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
let source = concat!(
|
||
"Intro ::git-push{cwd=\"/repo\"}\r\n\r\n",
|
||
"```powershell\r\nWrite-Output value \r\nWrite-Output done\t\r\n```\r\n\r\n",
|
||
"> Keep **formatting** \r\n> > Nested quote\r\n> hidden ::git-stage{cwd=\"/repo\"}\r\n\r\n",
|
||
"> ::git-stage{cwd=\"/repo\"}\r\n"
|
||
);
|
||
complete_turn_with_message(&mut chat, "turn-1", Some(source));
|
||
while rx.try_recv().is_ok() {}
|
||
|
||
for (key, expected, label) in [
|
||
(
|
||
'1',
|
||
"Intro\n\n```powershell\nWrite-Output value \nWrite-Output done\t\n```\n\n> Keep **formatting** \n> > Nested quote\n> hidden\n\n>",
|
||
"Whole response",
|
||
),
|
||
(
|
||
'2',
|
||
"Write-Output value \r\nWrite-Output done\t\r\n",
|
||
"powershell code",
|
||
),
|
||
(
|
||
'3',
|
||
"Keep **formatting** \r\n> Nested quote\r\nhidden \r\n",
|
||
"Blockquote",
|
||
),
|
||
] {
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
assert_eq!(
|
||
render_bottom_popup(&chat, /*width*/ 100)
|
||
.matches("Blockquote")
|
||
.count(),
|
||
1
|
||
);
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE));
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
(expected.to_string(), label.to_string())
|
||
);
|
||
}
|
||
|
||
chat.transcript.reset_copy_history();
|
||
assert_eq!(
|
||
(
|
||
chat.transcript.last_agent_markdown.as_deref(),
|
||
chat.transcript.last_agent_source.as_deref()
|
||
),
|
||
(None, None)
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_picker_defers_queued_input_until_selection_or_cancellation_settles() {
|
||
for queued in ["/new", "/clear", "follow-up prompt"] {
|
||
for close_key in [KeyCode::Enter, KeyCode::Esc] {
|
||
let (mut chat, mut rx, mut op_rx) =
|
||
make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
chat.thread_id = Some(ThreadId::new());
|
||
complete_turn_with_message(&mut chat, "previous", Some("Previous response"));
|
||
handle_turn_started(&mut chat, "active");
|
||
queue_composer_text_with_tab(&mut chat, queued);
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
|
||
complete_turn_with_message(&mut chat, "active", Some("New response"));
|
||
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
|
||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Previous response"));
|
||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||
while let Ok(event) = rx.try_recv() {
|
||
assert!(!matches!(
|
||
event,
|
||
AppEvent::NewSession { .. } | AppEvent::ClearUi { .. }
|
||
));
|
||
}
|
||
|
||
chat.handle_key_event(KeyEvent::new(close_key, KeyModifiers::NONE));
|
||
if close_key == KeyCode::Enter {
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
(
|
||
"Previous response".to_string(),
|
||
"Whole response".to_string()
|
||
)
|
||
);
|
||
} else {
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::SettingsSelectionClosed));
|
||
}
|
||
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
|
||
chat.set_queue_autosend_suppressed(/*suppressed*/ false);
|
||
chat.maybe_send_next_queued_input();
|
||
assert!(chat.input_queue.queued_user_messages.is_empty());
|
||
match queued {
|
||
"/new" => assert!(
|
||
std::iter::from_fn(|| rx.try_recv().ok())
|
||
.any(|event| matches!(event, AppEvent::NewSession { .. }))
|
||
),
|
||
"/clear" => assert!(
|
||
std::iter::from_fn(|| rx.try_recv().ok())
|
||
.any(|event| matches!(event, AppEvent::ClearUi { .. }))
|
||
),
|
||
_ => {
|
||
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { items, .. } if items == vec![UserInput::Text { text: queued.to_string(), text_elements: Vec::new() }])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_picker_previews_whole_response_code_blocks_and_blockquotes() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
chat.transcript.last_agent_markdown = Some(
|
||
"Overview\n\n```python\nprint('first')\nprint('second')\n```\n\n> Important quote\n\n```sh\necho done\n```"
|
||
.to_string(),
|
||
);
|
||
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
|
||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||
for expected in [
|
||
"1. Whole response",
|
||
"Overview",
|
||
"2. python code",
|
||
"print('first')",
|
||
"3. Blockquote",
|
||
"Important quote",
|
||
"4. sh code",
|
||
"echo done",
|
||
] {
|
||
assert!(popup.contains(expected), "expected {expected:?} in {popup}");
|
||
}
|
||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_picker_copies_status_fields_and_preserves_source_after_copying() {
|
||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.5")).await;
|
||
let session_id = "00000000-0000-0000-0000-000000000123";
|
||
chat.thread_id = Some(ThreadId::from_string(session_id).expect("valid thread ID"));
|
||
chat.thread_name = Some("Clipboard example".to_string());
|
||
chat.config.cwd = test_path_buf("/workspace").abs();
|
||
let directory = chat.config.cwd.display().to_string();
|
||
chat.dispatch_command(SlashCommand::Status);
|
||
drain_insert_history(&mut rx);
|
||
|
||
// The picker must copy the status that was displayed, not newer widget settings.
|
||
chat.config.cwd = test_path_buf("/another-workspace").abs();
|
||
chat.thread_name = Some("New name".to_string());
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
assert_chatwidget_snapshot!(
|
||
"slash_copy_picker_status_fields",
|
||
render_bottom_popup(&chat, /*width*/ 100)
|
||
.replace(&directory, "[[workspace]]")
|
||
.replace(crate::version::CODEX_CLI_VERSION, "VERSION"),
|
||
);
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
let (whole_status, label) = next_copy_selection(&mut rx);
|
||
assert_eq!(label, "Whole status");
|
||
assert_chatwidget_snapshot!(
|
||
"slash_copy_whole_status",
|
||
whole_status
|
||
.replace(&directory, "[[workspace]]")
|
||
.replace(crate::version::CODEX_CLI_VERSION, "VERSION"),
|
||
);
|
||
let expected = [
|
||
("Whole status", whole_status.as_str()),
|
||
("Model", "gpt-5.5"),
|
||
("Directory", directory.as_str()),
|
||
("Thread name", "Clipboard example"),
|
||
("Session ID", session_id),
|
||
];
|
||
for (index, (label, value)) in expected.into_iter().enumerate() {
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
let shortcut =
|
||
char::from_digit((index + 1) as u32, /*radix*/ 10).expect("numeric shortcut");
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char(shortcut), KeyModifiers::NONE));
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
(value.to_string(), label.to_string())
|
||
);
|
||
chat.show_copy_result(label, Ok(crate::clipboard_copy::CopyStatus::Confirmed));
|
||
drain_insert_history(&mut rx);
|
||
assert!(chat.bottom_pane.no_modal_or_popup_active());
|
||
}
|
||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_status_omits_missing_fields_and_copies_full_directory() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||
chat.config.cwd = test_path_buf(&format!("/workspace/{}", "nested/".repeat(/*n*/ 20))).abs();
|
||
let directory = chat.config.cwd.display().to_string();
|
||
chat.thread_name = Some(String::new());
|
||
chat.dispatch_command(SlashCommand::Status);
|
||
drain_insert_history(&mut rx);
|
||
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
assert!(popup.contains("2. Model"));
|
||
assert!(popup.contains("3. Directory"));
|
||
assert!(!popup.contains("Thread name"));
|
||
assert!(!popup.contains("Session ID"));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
(directory, "Directory".to_string())
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_status_yields_to_later_turns_and_commands_even_after_refresh() {
|
||
enum NextTurn {
|
||
Response,
|
||
UserTurn,
|
||
Command,
|
||
InlineCommand,
|
||
RejectedInlineCommand,
|
||
ServiceTier,
|
||
ShellCommand,
|
||
}
|
||
for next in [
|
||
NextTurn::Response,
|
||
NextTurn::UserTurn,
|
||
NextTurn::Command,
|
||
NextTurn::InlineCommand,
|
||
NextTurn::RejectedInlineCommand,
|
||
NextTurn::ServiceTier,
|
||
NextTurn::ShellCommand,
|
||
] {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
chat.thread_id = Some(ThreadId::new());
|
||
complete_turn_with_message(&mut chat, "turn-1", Some("Original reply"));
|
||
set_chatgpt_auth(&mut chat);
|
||
chat.dispatch_command(SlashCommand::Status);
|
||
drain_insert_history(&mut rx);
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("1. Whole status"));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||
drain_insert_history(&mut rx);
|
||
|
||
match next {
|
||
NextTurn::Response => {
|
||
complete_turn_with_message(&mut chat, "turn-2", Some("Next reply"))
|
||
}
|
||
NextTurn::UserTurn => chat.submit_user_message(UserMessage::from("Next question")),
|
||
NextTurn::Command => chat.dispatch_command(SlashCommand::Pwd),
|
||
NextTurn::InlineCommand => chat.dispatch_command_with_args(
|
||
SlashCommand::Rename,
|
||
"Renamed thread".to_string(),
|
||
Vec::new(),
|
||
),
|
||
NextTurn::RejectedInlineCommand => {
|
||
handle_turn_started(&mut chat, "turn-2");
|
||
chat.dispatch_command_with_args(SlashCommand::Cd, "/tmp".to_string(), Vec::new());
|
||
}
|
||
NextTurn::ServiceTier => chat.handle_service_tier_command_dispatch(fast_tier_command()),
|
||
NextTurn::ShellCommand => chat.submit_user_message(UserMessage::from("!echo hello")),
|
||
}
|
||
chat.finish_status_rate_limit_refresh(/*request_id*/ 0, Vec::new());
|
||
drain_insert_history(&mut rx);
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||
let expected = if matches!(next, NextTurn::Response) {
|
||
"Next reply"
|
||
} else {
|
||
"Original reply"
|
||
};
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
(expected.to_string(), "Whole response".to_string())
|
||
);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_status_yields_to_queued_commands_after_queued_status() {
|
||
for command in ["/rename Queued rename", "!echo hello"] {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
chat.thread_id = Some(ThreadId::new());
|
||
handle_turn_started(&mut chat, "turn-1");
|
||
queue_composer_text_with_tab(&mut chat, "/status");
|
||
queue_composer_text_with_tab(&mut chat, command);
|
||
complete_turn_with_message(&mut chat, "turn-1", Some("Original reply"));
|
||
let history = drain_insert_history(&mut rx);
|
||
assert!(
|
||
history
|
||
.iter()
|
||
.any(|lines| lines_to_single_string(lines).contains("/status"))
|
||
);
|
||
assert!(chat.input_queue.queued_user_messages.is_empty());
|
||
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
("Original reply".to_string(), "Whole response".to_string())
|
||
);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_picker_waits_for_submission_after_typing_or_autocomplete() {
|
||
for select_from_autocomplete in [false, true] {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
chat.transcript.last_agent_markdown = Some("Ready to copy".to_string());
|
||
chat.bottom_pane
|
||
.set_composer_text("/cop".to_string(), Vec::new(), Vec::new());
|
||
|
||
let key = if select_from_autocomplete {
|
||
KeyCode::Tab
|
||
} else {
|
||
KeyCode::Char('y')
|
||
};
|
||
chat.handle_key_event(KeyEvent::new(key, KeyModifiers::NONE));
|
||
if !select_from_autocomplete {
|
||
std::thread::sleep(crate::bottom_pane::ChatComposer::recommended_paste_flush_delay());
|
||
chat.handle_paste_burst_tick(FrameRequester::test_dummy());
|
||
}
|
||
|
||
assert_eq!(chat.bottom_pane.composer_text().trim(), "/copy");
|
||
assert!(
|
||
!render_bottom_popup(&chat, /*width*/ 80).contains("Whole response"),
|
||
"preparing /copy must not open the picker"
|
||
);
|
||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
assert!(popup.contains("Whole response"), "picker: {popup}");
|
||
assert!(chat.bottom_pane.composer_text().is_empty());
|
||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_picker_waits_for_submission_after_paste() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
chat.transcript.last_agent_markdown = Some("Ready to copy".to_string());
|
||
|
||
chat.handle_paste("/copy".to_string());
|
||
|
||
assert_eq!(chat.bottom_pane.composer_text(), "/copy");
|
||
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Whole response"));
|
||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
|
||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Whole response"));
|
||
assert!(chat.bottom_pane.composer_text().is_empty());
|
||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_picker_numeric_shortcuts_copy_whole_response_and_exact_code() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
let markdown = "Intro\n\n```rust\nfn main() {\n run();\n}\n```";
|
||
chat.transcript.last_agent_markdown = Some(markdown.to_string());
|
||
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
(markdown.to_string(), "Whole response".to_string())
|
||
);
|
||
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
(
|
||
"fn main() {\n run();\n}\n".to_string(),
|
||
"rust code".to_string()
|
||
)
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_picker_renders_only_whole_responses_as_markdown() {
|
||
use crate::clipboard_copy::CopyFormat;
|
||
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
let markdown = "**Intro**\n\n```text\n# literal *code*\n```\n\n> **Quote**\n";
|
||
chat.transcript.last_agent_markdown = Some(markdown.to_string());
|
||
for (key, expected) in [
|
||
('1', (markdown, "Whole response", CopyFormat::Markdown)),
|
||
(
|
||
'2',
|
||
("# literal *code*\n", "text code", CopyFormat::PlainText),
|
||
),
|
||
('3', ("**Quote**\n", "Blockquote", CopyFormat::PlainText)),
|
||
] {
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE));
|
||
let event = rx.try_recv().expect("selected clipboard content");
|
||
let AppEvent::CopySelection {
|
||
text,
|
||
label,
|
||
format,
|
||
} = event
|
||
else {
|
||
panic!("expected selected clipboard content, got {event:?}");
|
||
};
|
||
assert_eq!((text.as_ref(), label.as_str(), format), expected);
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::SettingsSelectionClosed));
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_picker_preserves_nested_blockquote_markdown_and_code() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
chat.transcript.last_agent_markdown =
|
||
Some("> **outer**\n> > _nested_\n> ```sh\n> nested()\n> ```\n".to_string());
|
||
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
(
|
||
"**outer**\n> _nested_\n```sh\nnested()\n```\n".to_string(),
|
||
"Blockquote".to_string()
|
||
)
|
||
);
|
||
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
("nested()\n".to_string(), "sh code".to_string())
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_picker_supports_arrow_navigation_enter_and_scrolling() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
chat.transcript.last_agent_markdown = Some(
|
||
(1..=11)
|
||
.map(|index| format!("```sh\nexample_{index}()\n```\n"))
|
||
.collect(),
|
||
);
|
||
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
for _ in 0..11 {
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||
}
|
||
assert!(
|
||
render_bottom_popup(&chat, /*width*/ 80).contains("example_11()"),
|
||
"later rows must remain reachable through scrolling"
|
||
);
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
("example_10()\n".to_string(), "sh code".to_string())
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_picker_escape_dismisses_without_copying() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
chat.transcript.last_agent_markdown = Some("Nothing copied".to_string());
|
||
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
assert!(!chat.bottom_pane.no_modal_or_popup_active());
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||
|
||
assert!(chat.bottom_pane.no_modal_or_popup_active());
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::SettingsSelectionClosed));
|
||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_copy_picker_remains_available_from_parent_owned_threads() {
|
||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
chat.transcript.last_agent_markdown = Some("Safe local copy".to_string());
|
||
chat.set_parent_owned_thread();
|
||
chat.bottom_pane
|
||
.set_composer_text("/cop".to_string(), Vec::new(), Vec::new());
|
||
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Whole response"));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
|
||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Whole response"));
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
||
|
||
assert_eq!(
|
||
next_copy_selection(&mut rx),
|
||
("Safe local copy".to_string(), "Whole response".to_string())
|
||
);
|
||
assert!(
|
||
op_rx.try_recv().is_err(),
|
||
"copy must not submit an agent turn"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_daybreak_offers_an_application_when_unavailable() {
|
||
assert_daybreak_catalog_and_account_gates("openai").await;
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_daybreak_custom_provider_requires_capability() {
|
||
assert_daybreak_catalog_and_account_gates("proxy").await;
|
||
}
|
||
|
||
async fn assert_daybreak_catalog_and_account_gates(provider_id: &str) {
|
||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.has_chatgpt_account = true;
|
||
chat.config.model_provider_id = provider_id.into();
|
||
if provider_id == "proxy" {
|
||
for capability in [None, Some(false)] {
|
||
chat.config.model_provider.capabilities =
|
||
Some(codex_model_provider_info::ModelProviderCapabilities {
|
||
cyber_access_programs: capability,
|
||
..Default::default()
|
||
});
|
||
chat.config
|
||
.model_providers
|
||
.insert(provider_id.into(), chat.config.model_provider.clone());
|
||
assert!(!chat.daybreak_account_eligible());
|
||
assert!(chat.daybreak_command_description().is_none());
|
||
}
|
||
chat.config.model_provider.capabilities =
|
||
Some(codex_model_provider_info::ModelProviderCapabilities {
|
||
cyber_access_programs: Some(true),
|
||
..Default::default()
|
||
});
|
||
}
|
||
chat.config
|
||
.model_providers
|
||
.insert(provider_id.into(), chat.config.model_provider.clone());
|
||
// Session updates change the provider ID; eligibility must not use the startup definition.
|
||
chat.config.model_provider.capabilities = None;
|
||
assert!(chat.daybreak_account_eligible());
|
||
chat.config.model_provider_id = "unknown-provider".into();
|
||
assert!(!chat.daybreak_account_eligible());
|
||
chat.config.model_provider_id = provider_id.into();
|
||
let mut model = crate::test_support::TEST_MODEL_PRESETS[0].clone();
|
||
model.available_access_programs = Some(codex_protocol::openai_models::ModelAccessPrograms {
|
||
cyber: vec![codex_protocol::turn_input::CyberAccessProgram::Standard],
|
||
});
|
||
chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![model.clone()]));
|
||
|
||
chat.set_daybreak_enabled(/*enabled*/ false);
|
||
chat.bottom_pane
|
||
.set_composer_text("/daybreak".to_string(), Vec::new(), Vec::new());
|
||
assert_chatwidget_snapshot!(
|
||
"slash_daybreak_help_unavailable",
|
||
render_bottom_popup(&chat, /*width*/ 80)
|
||
.lines()
|
||
.next()
|
||
.unwrap()
|
||
);
|
||
|
||
chat.dispatch_command(SlashCommand::Daybreak);
|
||
|
||
let cells = drain_insert_history(&mut rx);
|
||
assert_chatwidget_snapshot!(
|
||
"slash_daybreak_unavailable",
|
||
lines_to_single_string(&cells[0])
|
||
);
|
||
|
||
chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(Vec::new()));
|
||
chat.dispatch_command(SlashCommand::Daybreak);
|
||
let cells = drain_insert_history(&mut rx);
|
||
assert_chatwidget_snapshot!(
|
||
"slash_daybreak_catalog_unknown",
|
||
lines_to_single_string(&cells[0])
|
||
);
|
||
|
||
chat.has_chatgpt_account = false;
|
||
chat.dispatch_command(SlashCommand::Daybreak);
|
||
let cells = drain_insert_history(&mut rx);
|
||
assert_chatwidget_snapshot!(
|
||
"slash_daybreak_signed_out",
|
||
lines_to_single_string(&cells[0])
|
||
);
|
||
chat.has_chatgpt_account = true;
|
||
|
||
model
|
||
.available_access_programs
|
||
.as_mut()
|
||
.unwrap()
|
||
.cyber
|
||
.push(codex_protocol::turn_input::CyberAccessProgram::DaybreakBlue);
|
||
chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![model]));
|
||
chat.has_chatgpt_account = false;
|
||
chat.status_account_display = Some(StatusAccountDisplay::ApiKey);
|
||
chat.set_feature_enabled(Feature::ApiKeyCyberAccessPrograms, /*enabled*/ true);
|
||
chat.set_daybreak_enabled(/*enabled*/ false);
|
||
chat.bottom_pane
|
||
.set_composer_text(String::new(), Vec::new(), Vec::new());
|
||
chat.bottom_pane
|
||
.set_composer_text("/daybreak".to_string(), Vec::new(), Vec::new());
|
||
assert_chatwidget_snapshot!(
|
||
"slash_daybreak_api_key_help",
|
||
render_bottom_popup(&chat, /*width*/ 80)
|
||
.lines()
|
||
.next()
|
||
.unwrap()
|
||
);
|
||
chat.set_daybreak_enabled(/*enabled*/ true);
|
||
chat.set_feature_enabled(Feature::ApiKeyCyberAccessPrograms, /*enabled*/ false);
|
||
chat.bottom_pane
|
||
.set_composer_text(String::new(), Vec::new(), Vec::new());
|
||
chat.bottom_pane
|
||
.set_composer_text("/daybreak".to_string(), Vec::new(), Vec::new());
|
||
insta::assert_snapshot!(
|
||
render_bottom_popup(&chat, /*width*/ 80)
|
||
.lines()
|
||
.filter(|line| line.contains("broader access"))
|
||
.collect::<Vec<_>>()
|
||
.join("\n"),
|
||
@""
|
||
);
|
||
chat.has_chatgpt_account = true;
|
||
chat.status_account_display = None;
|
||
for (enabled, name) in [
|
||
(false, "slash_daybreak_help_enable"),
|
||
(true, "slash_daybreak_help_disable"),
|
||
] {
|
||
chat.set_daybreak_enabled(enabled);
|
||
chat.bottom_pane
|
||
.set_composer_text(String::new(), Vec::new(), Vec::new());
|
||
chat.bottom_pane
|
||
.set_composer_text("/daybreak".to_string(), Vec::new(), Vec::new());
|
||
assert_chatwidget_snapshot!(
|
||
name,
|
||
render_bottom_popup(&chat, /*width*/ 80)
|
||
.lines()
|
||
.next()
|
||
.unwrap()
|
||
);
|
||
}
|
||
chat.set_feature_enabled(Feature::CliDaybreak, /*enabled*/ false);
|
||
chat.set_daybreak_enabled(/*enabled*/ true);
|
||
assert!(!chat.daybreak_enabled);
|
||
assert!(chat.daybreak_command_description().is_none());
|
||
chat.dispatch_command(SlashCommand::Daybreak);
|
||
assert!(drain_insert_history(&mut rx).is_empty());
|
||
}
|
||
|
||
#[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.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Terminal;
|
||
|
||
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 slash_export_opens_destination_picker() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
chat.thread_id = Some(
|
||
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread ID"),
|
||
);
|
||
handle_turn_started(&mut chat, "turn-1");
|
||
queue_composer_text_with_tab(&mut chat, "/export");
|
||
complete_turn_with_message(&mut chat, "turn-1", /*message*/ None);
|
||
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
assert_chatwidget_snapshot!("slash_export_destination_picker", popup);
|
||
chat.show_transcript_export_file_prompt();
|
||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||
assert_chatwidget_snapshot!("slash_export_filename_prompt", popup);
|
||
|
||
let statuses = "Saved conversation to conversation.md\nCopied conversation to clipboard";
|
||
for message in statuses.lines() {
|
||
chat.add_info_message(message.into(), /*hint*/ None);
|
||
}
|
||
chat.add_error_message("Copy failed: clipboard unavailable".to_string());
|
||
chat.add_error_message("Export failed: missing parent".to_string());
|
||
let cells = drain_insert_history_normalized(&mut rx);
|
||
assert_chatwidget_snapshot!(
|
||
"slash_export_completion_message",
|
||
cells
|
||
.iter()
|
||
.map(|cell| lines_to_single_string(cell).trim().to_string())
|
||
.collect::<Vec<_>>()
|
||
.join("\n"),
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn copy_action_reports_when_no_agent_response_exists() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
let (code, modifiers) = crate::keymap::RuntimeKeymap::defaults().app.copy[0].parts();
|
||
chat.handle_key_event(KeyEvent::new(code, modifiers));
|
||
|
||
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::F(4), KeyModifiers::NONE));
|
||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||
assert!(popup.contains("global.focus_activity"));
|
||
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;
|
||
for (code, modifiers) in [
|
||
(KeyCode::Char('v'), KeyModifiers::CONTROL),
|
||
(KeyCode::Char('V'), KeyModifiers::CONTROL),
|
||
(
|
||
KeyCode::Char('V'),
|
||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||
),
|
||
(KeyCode::Char('v'), KeyModifiers::ALT),
|
||
] {
|
||
let paste = KeyEvent::new(code, modifiers);
|
||
assert_matches!(
|
||
chat.handle_key_event(paste),
|
||
crate::chatwidget::KeyEventAction::PasteImage { text_fallback, .. }
|
||
if text_fallback == (cfg!(windows) && modifiers == KeyModifiers::CONTROL)
|
||
.then_some(code)
|
||
);
|
||
chat.handle_key_event(KeyEvent {
|
||
kind: KeyEventKind::Release,
|
||
..paste
|
||
});
|
||
}
|
||
// Crossterm maps VK_INSERT + SHIFT_PRESSED directly to Insert + Shift on Windows.
|
||
let shift_insert = KeyEvent::new(KeyCode::Insert, KeyModifiers::SHIFT);
|
||
if cfg!(windows) {
|
||
chat.bottom_pane.set_disable_paste_burst(/*disabled*/ false);
|
||
chat.handle_key_event(KeyEvent::from(KeyCode::Char('q')));
|
||
assert_matches!(
|
||
chat.handle_key_event(shift_insert),
|
||
KeyEventAction::PasteImage {
|
||
text_fallback: Some(KeyCode::Insert),
|
||
..
|
||
}
|
||
);
|
||
assert_eq!(chat.bottom_pane.composer_text(), "q");
|
||
assert_matches!(chat.handle_key_event(shift_insert), KeyEventAction::None);
|
||
chat.handle_key_event(KeyEvent {
|
||
kind: KeyEventKind::Release,
|
||
..shift_insert
|
||
});
|
||
chat.bottom_pane
|
||
.set_composer_text(String::new(), Vec::new(), Vec::new());
|
||
}
|
||
let mut keymap_config = chat.config_ref().tui_keymap.clone();
|
||
keymap_config.global.copy = Some(codex_config::types::KeybindingsSpec::One(
|
||
codex_config::types::KeybindingSpec("alt-v".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"
|
||
);
|
||
|
||
assert_matches!(
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)),
|
||
crate::chatwidget::KeyEventAction::None
|
||
);
|
||
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 legacy_image_paste_ignores_buffered_press_until_other_input() {
|
||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
let paste = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL);
|
||
|
||
assert_matches!(
|
||
chat.handle_key_event(paste),
|
||
KeyEventAction::PasteImage { .. }
|
||
);
|
||
assert_matches!(chat.handle_key_event(paste), KeyEventAction::None);
|
||
|
||
chat.suppress_image_paste_until = Instant::now() - Duration::from_secs(/*secs*/ 1);
|
||
assert_matches!(
|
||
chat.handle_key_event(paste),
|
||
KeyEventAction::PasteImage { .. }
|
||
);
|
||
|
||
chat.suppress_image_paste_until = Instant::now() + Duration::from_secs(/*secs*/ 1);
|
||
chat.handle_key_event(KeyEvent::from(KeyCode::Char('x')));
|
||
assert_matches!(
|
||
chat.handle_key_event(paste),
|
||
KeyEventAction::PasteImage { .. }
|
||
);
|
||
|
||
chat.suppress_image_paste_until = Instant::now() + Duration::from_secs(/*secs*/ 1);
|
||
chat.handle_key_event(KeyEvent {
|
||
kind: KeyEventKind::Release,
|
||
..paste
|
||
});
|
||
assert_matches!(
|
||
chat.handle_key_event(paste),
|
||
KeyEventAction::PasteImage { .. }
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn copy_shortcut_submits_markdown_and_reports_completion_once() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.transcript.last_agent_markdown = Some("copy me".to_string());
|
||
assert_matches!(
|
||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL)),
|
||
crate::chatwidget::KeyEventAction::CopyLastResponse(text) if &*text == "copy me"
|
||
);
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
|
||
assert!(rx.try_recv().is_err());
|
||
chat.show_copy_result(
|
||
"last message",
|
||
Ok(crate::clipboard_copy::CopyStatus::Pending(1)),
|
||
);
|
||
let pending = lines_to_single_string(&drain_insert_history(&mut rx)[0]);
|
||
insta::assert_snapshot!("pending_copy", pending);
|
||
let completion = (1, Ok(crate::clipboard_copy::CopyStatus::Confirmed));
|
||
chat.finish_clipboard(&completion, /*composer_visible*/ true);
|
||
let result = lines_to_single_string(&drain_insert_history(&mut rx)[0]);
|
||
assert!(result.contains("Copied last message to clipboard"));
|
||
chat.finish_clipboard(&completion, /*composer_visible*/ true);
|
||
assert!(drain_insert_history(&mut rx).is_empty());
|
||
chat.show_copy_result(
|
||
"last message",
|
||
Ok(crate::clipboard_copy::CopyStatus::Unconfirmed),
|
||
);
|
||
insta::assert_snapshot!(
|
||
"unconfirmed_copy",
|
||
lines_to_single_string(&drain_insert_history(&mut rx)[0])
|
||
);
|
||
}
|
||
|
||
#[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_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::FollowTranscript));
|
||
|
||
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::FollowTranscript));
|
||
|
||
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::FollowTranscript));
|
||
|
||
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_cancellation_keeps_task_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.dispatch_command(SlashCommand::Archive);
|
||
|
||
assert!(chat.bottom_pane.has_active_view());
|
||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||
|
||
assert!(!chat.bottom_pane.has_active_view());
|
||
assert!(chat.bottom_pane.is_task_running());
|
||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_memory_maintenance_reports_stubbed_feature() {
|
||
for command in [SlashCommand::MemoryDrop, SlashCommand::MemoryUpdate] {
|
||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
|
||
chat.dispatch_command(command);
|
||
|
||
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_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
|
||
|
||
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_login_targets_the_active_thread() {
|
||
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 login enterprise");
|
||
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
|
||
assert_matches!(
|
||
rx.try_recv(),
|
||
Ok(AppEvent::StartMcpLogin { name, thread_id: actual_thread_id })
|
||
if name == "enterprise" && 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 | login <name>]"),
|
||
"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_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() {
|
||
for running in [false, true] {
|
||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.bottom_pane.set_task_running(running);
|
||
|
||
chat.dispatch_command(SlashCommand::Archive);
|
||
|
||
assert!(chat.bottom_pane.has_active_view());
|
||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||
|
||
let popup = render_bottom_popup(&chat, /*width*/ 80);
|
||
if running {
|
||
assert_chatwidget_snapshot!("slash_archive_running_confirmation_popup", popup);
|
||
} else {
|
||
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_command_center", 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::FollowTranscript));
|
||
|
||
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::FollowTranscript));
|
||
|
||
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_pet_disable_aliases_work_on_unsupported_terminal() {
|
||
for input in ["/pets disable", "/pet hide"] {
|
||
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(input.to_string(), Vec::new(), Vec::new());
|
||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
|
||
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_in_tmux_shows_notice_and_preserves_draft() {
|
||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
force_tmux_pet_image_unsupported(&mut chat);
|
||
chat.bottom_pane
|
||
.set_composer_text("Keep this draft".to_string(), Vec::new(), Vec::new());
|
||
|
||
chat.dispatch_command(SlashCommand::Pets);
|
||
|
||
assert!(chat.bottom_pane.has_active_view());
|
||
assert_chatwidget_snapshot!(
|
||
"slash_pets_unavailable_tmux_40",
|
||
render_bottom_popup(&chat, /*width*/ 40),
|
||
);
|
||
let cells = drain_insert_history_transcript(&mut rx);
|
||
let rendered = cells
|
||
.iter()
|
||
.map(|lines| lines_to_single_string(lines))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(rendered.contains("Pets are unavailable in this tmux pane."));
|
||
assert!(rendered.contains("outside tmux"));
|
||
|
||
chat.handle_key_event(KeyEvent::from(KeyCode::Esc));
|
||
assert!(!chat.bottom_pane.has_active_view());
|
||
assert_eq!(chat.bottom_pane.composer_text(), "Keep this draft");
|
||
}
|
||
|
||
#[tokio::test]
|
||
#[serial]
|
||
async fn slash_pets_with_arg_on_unsupported_terminal_shows_notice_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));
|
||
|
||
assert!(chat.bottom_pane.has_active_view());
|
||
assert!(
|
||
render_bottom_popup(&chat, /*width*/ 80)
|
||
.contains("Pets are unavailable in this tmux pane.")
|
||
);
|
||
let cells = drain_insert_history_transcript(&mut rx);
|
||
let rendered = cells
|
||
.iter()
|
||
.map(|lines| lines_to_single_string(lines))
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
assert!(rendered.contains("Pets are unavailable in this tmux pane."));
|
||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||
|
||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||
assert!(!chat.bottom_pane.has_active_view());
|
||
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());
|
||
assert!(
|
||
render_bottom_popup(&chat, /*width*/ 80)
|
||
.contains("Pets aren’t available in this terminal.")
|
||
);
|
||
let cells = drain_insert_history_transcript(&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());
|
||
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Pets require iTerm2 3.6 or newer."));
|
||
let cells = drain_insert_history_transcript(&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::FollowTranscript));
|
||
|
||
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_pwd_and_cwd_alias_display_current_working_directory_from_composer() {
|
||
let mut output = Vec::new();
|
||
for (command, side) in [("/pwd", false), ("/cwd", true), ("/pwd x", false)] {
|
||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.set_side_conversation_active(side);
|
||
chat.bottom_pane.set_task_running(!side);
|
||
chat.bottom_pane
|
||
.set_composer_text(command.to_string(), Vec::new(), Vec::new());
|
||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||
let [cell]: [_; 1] = drain_insert_history(&mut rx).try_into().expect("one cell");
|
||
output.push(normalize_snapshot_paths(lines_to_single_string(&cell)));
|
||
}
|
||
insta::assert_snapshot!(output.join(""), @r"
|
||
• Current working directory: /tmp/project
|
||
• Current working directory: /tmp/project
|
||
■ Usage: /pwd
|
||
");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_cd_changes_current_session_after_replay_and_defaults_to_home() {
|
||
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.thread_name = Some("Completed task".to_string());
|
||
chat.forked_from = Some(ThreadId::new());
|
||
let (status, duration, error) = (AppServerTurnStatus::Completed, None, None);
|
||
let turn = app_server_turn("turn-1", status, duration, error);
|
||
chat.replay_thread_turns(vec![turn], ReplayKind::ResumeInitialMessages);
|
||
drain_insert_history(&mut rx);
|
||
assert!(!chat.can_change_working_directory(ThreadId::new()));
|
||
let drain = chat.submit_queued_slash_prompt(UserMessage::from("/cd /tmp").into());
|
||
assert_eq!(drain, QueueDrain::Stop);
|
||
chat.bottom_pane
|
||
.set_composer_text("/cd".to_string(), Vec::new(), Vec::new());
|
||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||
for expected in ["/tmp", "~"] {
|
||
// Only the interactive submission adds a follow event; queued dispatch already owns it.
|
||
if expected == "~" {
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::FollowTranscript));
|
||
}
|
||
assert_matches!(
|
||
rx.try_recv(),
|
||
Ok(AppEvent::ChangeWorkingDirectory { thread_id: actual, requested_cwd })
|
||
if actual == thread_id && requested_cwd == std::path::Path::new(expected)
|
||
);
|
||
}
|
||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn rejected_queued_cd_drains_following_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, "/cd /tmp");
|
||
queue_composer_text_with_tab(&mut chat, "follow-up");
|
||
complete_turn_with_message(&mut chat, "turn-1", /*message*/ None);
|
||
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { .. });
|
||
assert!(chat.input_queue.queued_user_messages.is_empty());
|
||
while let Ok(event) = rx.try_recv() {
|
||
assert!(!matches!(event, AppEvent::ChangeWorkingDirectory { .. }));
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn slash_cd_rejects_pending_input_and_unsupported_session_ownership() {
|
||
let mut errors = Vec::new();
|
||
for state in "new active pending queued steer side owned ephemeral mcp exec".split(' ') {
|
||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.thread_id = (state != "new").then(ThreadId::new);
|
||
let (queued, steer) = (UserMessage::from("q").into(), pending_steer("s"));
|
||
match state {
|
||
"active" => chat.bottom_pane.set_task_running(/*running*/ true),
|
||
"pending" => chat.input_queue.user_turn_pending_start = true,
|
||
"queued" => chat.input_queue.queued_user_messages.push_back(queued),
|
||
"steer" => chat.input_queue.pending_steers.push_back(steer),
|
||
"side" => chat.set_side_conversation_active(/*active*/ true),
|
||
"owned" => chat.set_parent_owned_thread(),
|
||
"ephemeral" => chat.config.ephemeral = true,
|
||
"exec" => chat.track_unified_exec_process_begin("call", Some("process"), "sleep"),
|
||
"mcp" => {
|
||
let cell =
|
||
history_cell::new_mcp_inventory_loading(/*animations_enabled*/ false);
|
||
chat.transcript.active_cell = Some(Box::new(cell));
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
chat.dispatch_command_with_args(SlashCommand::Cd, "/tmp".to_string(), Vec::new());
|
||
|
||
assert!(state != "mcp" || chat.transcript.active_cell.is_some());
|
||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||
let Ok(AppEvent::InsertHistoryCell(cell)) = rx.try_recv() else {
|
||
unreachable!("missing error for {state}");
|
||
};
|
||
errors.push(lines_to_single_string(&cell.display_lines(/*width*/ 200)));
|
||
assert!(rx.try_recv().is_err(), "{state}");
|
||
}
|
||
insta::assert_snapshot!(format!("{}{}", errors[0], errors[3]), @r"
|
||
■ The session must start before you can change its working directory.
|
||
■ Changing directories requires an idle primary session without queued input.
|
||
");
|
||
}
|
||
|
||
#[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 user_turn_preserves_flex_without_catalog_support() {
|
||
for fast_mode_enabled in [false, true] {
|
||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
||
chat.thread_id = Some(ThreadId::new());
|
||
set_chatgpt_auth(&mut chat);
|
||
let mut models = chat.model_catalog.try_list_models().expect("test catalog");
|
||
for model in &mut models {
|
||
model.service_tiers.clear();
|
||
model.default_service_tier = None;
|
||
}
|
||
chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(models));
|
||
chat.set_feature_enabled(Feature::FastMode, fast_mode_enabled);
|
||
chat.set_service_tier(Some(ServiceTier::Flex.request_value().to_string()));
|
||
|
||
assert_eq!(
|
||
chat.current_service_tier(),
|
||
Some(ServiceTier::Flex.request_value())
|
||
);
|
||
chat.bottom_pane
|
||
.set_composer_text("hello".to_string(), Vec::new(), Vec::new());
|
||
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||
|
||
let Op::UserTurn { service_tier, .. } = next_submit_op(&mut op_rx) else {
|
||
panic!("expected a user turn");
|
||
};
|
||
assert_eq!(
|
||
service_tier,
|
||
Some(Some(ServiceTier::Flex.request_value().to_string()))
|
||
);
|
||
}
|
||
}
|
||
|
||
#[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())
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn transcript_copy_feedback_stays_in_the_footer_without_history_or_interrupts() {
|
||
let (mut chat, mut events, mut operations) =
|
||
make_chatwidget_manual(/*model_override*/ None).await;
|
||
let _ = drain_insert_history(&mut events);
|
||
chat.show_selection_copy_result(Ok(crate::clipboard_copy::CopyStatus::Confirmed));
|
||
assert_eq!(drain_insert_history(&mut events).len(), 0);
|
||
assert!(operations.try_recv().is_err());
|
||
insta::assert_snapshot!(
|
||
"transcript_copy_success",
|
||
render_bottom_popup(&chat, /*width*/ 80)
|
||
);
|
||
chat.show_selection_copy_result(Ok(crate::clipboard_copy::CopyStatus::Unconfirmed));
|
||
assert_eq!(drain_insert_history(&mut events).len(), 0);
|
||
assert!(operations.try_recv().is_err());
|
||
insta::assert_snapshot!(
|
||
"transcript_copy_unconfirmed",
|
||
render_bottom_popup(&chat, /*width*/ 80)
|
||
);
|
||
chat.open_warnings(&[Arc::new(history_cell::new_warning_event(
|
||
"selected café".into(),
|
||
))]);
|
||
chat.show_selection_copy_result(Err("clipboard unavailable".to_string()));
|
||
assert_eq!(drain_insert_history(&mut events).len(), 0);
|
||
assert!(operations.try_recv().is_err());
|
||
insta::assert_snapshot!(
|
||
"transcript_copy_failure",
|
||
render_bottom_popup(&chat, /*width*/ 80)
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn owned_copy_guard_defers_queued_input_until_released() {
|
||
for queued in ["/new", "/clear", "follow-up prompt"] {
|
||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||
chat.local_settings.transcript_mode = crate::transcript_mode::TranscriptMode::Owned;
|
||
chat.thread_id = Some(ThreadId::new());
|
||
complete_turn_with_message(&mut chat, "previous", Some("Previous response"));
|
||
handle_turn_started(&mut chat, "active");
|
||
queue_composer_text_with_tab(&mut chat, queued);
|
||
chat.dispatch_command(SlashCommand::Copy);
|
||
let guard = std::iter::from_fn(|| rx.try_recv().ok())
|
||
.find_map(|event| match event {
|
||
AppEvent::SelectTranscriptCopy { guard, .. } => Some(guard),
|
||
_ => None,
|
||
})
|
||
.unwrap();
|
||
complete_turn_with_message(&mut chat, "active", Some("New response"));
|
||
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
|
||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||
while let Ok(event) = rx.try_recv() {
|
||
assert!(!matches!(
|
||
event,
|
||
AppEvent::NewSession { .. } | AppEvent::ClearUi { .. }
|
||
));
|
||
}
|
||
drop(guard);
|
||
assert_matches!(rx.try_recv(), Ok(AppEvent::TranscriptCopyClosed));
|
||
chat.maybe_send_next_queued_input();
|
||
assert!(chat.input_queue.queued_user_messages.is_empty());
|
||
}
|
||
}
|