use super::*;
use pretty_assertions::assert_eq;

#[test]
fn plan_mode_nudge_matches_only_standalone_plain_text_keyword() {
    assert!(contains_plan_keyword("plan"));
    assert!(contains_plan_keyword("Make a Plan first."));
    assert!(!contains_plan_keyword("plane"));
    assert!(!contains_plan_keyword("planning"));
    assert!(contains_plan_keyword("/plan"));
    assert!(contains_plan_keyword("!plan"));
}

#[tokio::test]
async fn plan_mode_nudge_shows_only_for_eligible_default_mode_drafts() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
    chat.pre_draw_tick();
    assert!(chat.bottom_pane.plan_mode_nudge_visible());

    chat.set_composer_text("/plan".to_string(), Vec::new(), Vec::new());
    chat.pre_draw_tick();
    assert!(!chat.bottom_pane.plan_mode_nudge_visible());

    chat.set_composer_text("!plan".to_string(), Vec::new(), Vec::new());
    chat.pre_draw_tick();
    assert!(!chat.bottom_pane.plan_mode_nudge_visible());

    chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
        .expect("expected plan collaboration mode");
    chat.set_collaboration_mask(plan_mask);
    chat.pre_draw_tick();
    assert!(!chat.bottom_pane.plan_mode_nudge_visible());
}

#[tokio::test]
async fn plan_mode_nudge_hides_while_task_or_modal_is_active() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
    chat.pre_draw_tick();
    assert!(chat.bottom_pane.plan_mode_nudge_visible());

    chat.on_task_started();
    chat.pre_draw_tick();
    assert!(!chat.bottom_pane.plan_mode_nudge_visible());

    chat.on_task_complete(
        /*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,
    );
    chat.show_selection_view(SelectionViewParams {
        items: vec![SelectionItem {
            name: "Keep planning".to_string(),
            ..Default::default()
        }],
        ..Default::default()
    });
    chat.pre_draw_tick();
    assert!(!chat.bottom_pane.plan_mode_nudge_visible());
}

#[tokio::test]
async fn plan_mode_nudge_dismissal_is_scoped_to_current_thread() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    let first_thread = ThreadId::new();
    let second_thread = ThreadId::new();
    chat.thread_id = Some(first_thread);
    chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
    chat.pre_draw_tick();
    assert!(chat.bottom_pane.plan_mode_nudge_visible());

    chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
    chat.pre_draw_tick();
    assert!(!chat.bottom_pane.plan_mode_nudge_visible());

    chat.thread_id = Some(second_thread);
    chat.pre_draw_tick();
    assert!(chat.bottom_pane.plan_mode_nudge_visible());

    chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
    chat.pre_draw_tick();
    assert!(!chat.bottom_pane.plan_mode_nudge_visible());

    chat.thread_id = Some(first_thread);
    chat.pre_draw_tick();
    assert!(!chat.bottom_pane.plan_mode_nudge_visible());
}

#[tokio::test]
async fn plan_mode_nudge_shift_tab_uses_existing_mode_cycle_path() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
    chat.pre_draw_tick();
    assert!(chat.bottom_pane.plan_mode_nudge_visible());

    chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
    chat.pre_draw_tick();
    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
    assert!(!chat.bottom_pane.plan_mode_nudge_visible());
}

#[tokio::test]
async fn plan_mode_nudge_snapshot() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.set_token_info(Some(make_token_info(
        /*total_tokens*/ 50_000, /*context_window*/ 100_000,
    )));
    chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
    chat.pre_draw_tick();

    assert_chatwidget_snapshot!("plan_mode_nudge", render_bottom_popup(&chat, /*width*/ 80));
}

#[tokio::test]
async fn plan_mode_nudge_narrow_snapshot() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
    chat.pre_draw_tick();

    assert_chatwidget_snapshot!(
        "plan_mode_nudge_narrow",
        render_bottom_popup(&chat, /*width*/ 36)
    );
}

#[tokio::test]
async fn plan_implementation_popup_snapshot() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string());
    chat.open_plan_implementation_prompt();

    let popup = render_bottom_popup(&chat, /*width*/ 80);
    assert_chatwidget_snapshot!("plan_implementation_popup", popup);
}

#[tokio::test]
async fn plan_implementation_popup_context_usage_snapshot() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.set_token_info(Some(make_token_info(
        /*total_tokens*/ 90_000, /*context_window*/ 100_000,
    )));
    chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string());
    chat.open_plan_implementation_prompt();

    let popup = render_bottom_popup(&chat, /*width*/ 80);
    assert_chatwidget_snapshot!("plan_implementation_popup_context_usage", popup);
}

#[tokio::test]
async fn plan_implementation_popup_no_selected_snapshot() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string());
    chat.open_plan_implementation_prompt();
    chat.handle_key_event(KeyEvent::from(KeyCode::Down));

    let popup = render_bottom_popup(&chat, /*width*/ 80);
    assert_chatwidget_snapshot!("plan_implementation_popup_no_selected", popup);
}

#[tokio::test]
async fn plan_implementation_popup_yes_emits_submit_message_event() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.open_plan_implementation_prompt();

    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));

    let event = rx.try_recv().expect("expected AppEvent");
    let AppEvent::SubmitUserMessageWithMode {
        text,
        collaboration_mode,
    } = event
    else {
        panic!("expected SubmitUserMessageWithMode, got {event:?}");
    };
    assert_eq!(
        text,
        plan_implementation::PLAN_IMPLEMENTATION_CODING_MESSAGE
    );
    assert_eq!(collaboration_mode.mode, Some(ModeKind::Default));
}

#[tokio::test]
async fn plan_implementation_popup_clear_context_emits_clear_submit_event() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    let plan_markdown = "- Step 1\n- Step 2\n";
    chat.on_plan_item_completed(plan_markdown.to_string());
    let _ = drain_insert_history(&mut rx);
    chat.open_plan_implementation_prompt();

    chat.handle_key_event(KeyEvent::from(KeyCode::Down));
    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));

    let event = rx.try_recv().expect("expected AppEvent");
    let AppEvent::ClearUiAndSubmitUserMessage { text } = event else {
        panic!("expected ClearUiAndSubmitUserMessage, got {event:?}");
    };
    assert_eq!(
        text,
        "A previous agent produced the plan below to accomplish the user's task. \
        Implement the plan in a fresh context. Treat the plan as the source of \
        user intent, re-read files as needed, and carry the work through \
        implementation and verification.\n\n- Step 1\n- Step 2\n"
    );
}

#[tokio::test]
async fn plan_implementation_clear_context_requires_default_mode_and_plan() {
    let (chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    let default_mask = collaboration_modes::default_mode_mask(chat.model_catalog.as_ref())
        .expect("expected default collaboration mode");

    let params = plan_implementation::selection_view_params(
        /*default_mask*/ None,
        Some("- Step\n"),
        /*clear_context_usage_label*/ None,
    );
    assert_eq!(
        params.items[1].disabled_reason.as_deref(),
        Some(plan_implementation::PLAN_IMPLEMENTATION_DEFAULT_UNAVAILABLE)
    );

    let params = plan_implementation::selection_view_params(
        Some(default_mask.clone()),
        /*plan_markdown*/ None,
        /*clear_context_usage_label*/ None,
    );
    assert_eq!(
        params.items[1].disabled_reason.as_deref(),
        Some(plan_implementation::PLAN_IMPLEMENTATION_NO_APPROVED_PLAN)
    );

    let params = plan_implementation::selection_view_params(
        Some(default_mask.clone()),
        Some("  \n"),
        /*clear_context_usage_label*/ None,
    );
    assert_eq!(
        params.items[1].disabled_reason.as_deref(),
        Some(plan_implementation::PLAN_IMPLEMENTATION_NO_APPROVED_PLAN)
    );

    let params = plan_implementation::selection_view_params(
        Some(default_mask.clone()),
        Some("- Step\n"),
        /*clear_context_usage_label*/ None,
    );
    assert_eq!(params.items[1].disabled_reason, None);
    assert!(!params.items[1].actions.is_empty());

    assert_eq!(
        params.items[1].description.as_deref(),
        Some("Fresh thread with this plan.")
    );

    let params = plan_implementation::selection_view_params(
        Some(default_mask),
        Some("- Step\n"),
        Some("89% used"),
    );
    assert_eq!(
        params.items[1].description.as_deref(),
        Some("Fresh thread. Context: 89% used.")
    );
}

#[tokio::test]
async fn submit_user_message_with_mode_sets_coding_collaboration_mode() {
    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.thread_id = Some(ThreadId::new());
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);

    let default_mode = collaboration_modes::default_mode_mask(chat.model_catalog.as_ref())
        .expect("expected default collaboration mode");
    chat.submit_user_message_with_mode("Implement the plan.".to_string(), default_mode);

    match next_submit_op(&mut op_rx) {
        Op::UserTurn {
            collaboration_mode:
                Some(CollaborationMode {
                    mode: ModeKind::Default,
                    ..
                }),
            personality: None,
            ..
        } => {}
        other => {
            panic!("expected Op::UserTurn with default collab mode, got {other:?}")
        }
    }
}

#[tokio::test]
async fn reasoning_selection_in_plan_mode_opens_scope_prompt_event() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
    chat.thread_id = Some(ThreadId::new());
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
        .expect("expected plan collaboration mode");
    chat.set_collaboration_mask(plan_mask);
    let _ = drain_insert_history(&mut rx);
    set_chatgpt_auth(&mut chat);
    chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));

    let preset = get_available_model(&chat, "gpt-5.4");
    chat.open_reasoning_popup(preset);
    chat.handle_key_event(KeyEvent::from(KeyCode::Down));
    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));

    let event = rx.try_recv().expect("expected AppEvent");
    assert_matches!(
        event,
        AppEvent::OpenPlanReasoningScopePrompt {
            model,
            effort: Some(_)
        } if model == "gpt-5.4"
    );
}

#[tokio::test]
async fn reasoning_selection_in_plan_mode_without_effort_change_does_not_open_scope_prompt_event() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
    chat.thread_id = Some(ThreadId::new());
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
        .expect("expected plan collaboration mode");
    chat.set_collaboration_mask(plan_mask);
    let _ = drain_insert_history(&mut rx);
    set_chatgpt_auth(&mut chat);

    chat.set_reasoning_effort(Some(ReasoningEffortConfig::Medium));

    let preset = get_available_model(&chat, "gpt-5.4");
    chat.open_reasoning_popup(preset);
    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));

    let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
    assert!(
        events.iter().any(|event| matches!(
            event,
            AppEvent::UpdateModel(model) if model == "gpt-5.4"
        )),
        "expected model update event; events: {events:?}"
    );
    assert!(
        events
            .iter()
            .any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))),
        "expected reasoning update event; events: {events:?}"
    );
}

#[tokio::test]
async fn reasoning_selection_in_plan_mode_matching_plan_effort_but_different_global_opens_scope_prompt()
 {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
    chat.thread_id = Some(ThreadId::new());
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
        .expect("expected plan collaboration mode");
    chat.set_collaboration_mask(plan_mask);
    let _ = drain_insert_history(&mut rx);
    set_chatgpt_auth(&mut chat);

    // Reproduce: Plan effective reasoning remains the preset (medium), but the
    // global default differs (high). Pressing Enter on the current Plan choice
    // should open the scope prompt rather than silently rewriting the global default.
    chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));

    let preset = get_available_model(&chat, "gpt-5.4");
    chat.open_reasoning_popup(preset);
    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));

    let event = rx.try_recv().expect("expected AppEvent");
    assert_matches!(
        event,
        AppEvent::OpenPlanReasoningScopePrompt {
            model,
            effort: Some(ReasoningEffortConfig::Medium)
        } if model == "gpt-5.4"
    );
}

#[tokio::test]
async fn reasoning_shortcut_in_plan_mode_updates_plan_override_without_prompt_or_persist() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
    chat.thread_id = Some(ThreadId::new());
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
        .expect("expected plan collaboration mode");
    chat.set_collaboration_mask(plan_mask);
    let _ = drain_insert_history(&mut rx);
    chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));

    chat.handle_key_event(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT));

    let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
    assert!(
        events.iter().any(|event| matches!(
            event,
            AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High))
        )),
        "expected plan reasoning override update event; events: {events:?}"
    );
    assert!(
        events
            .iter()
            .all(|event| !matches!(event, AppEvent::OpenPlanReasoningScopePrompt { .. })),
        "expected no Plan reasoning scope prompt event; events: {events:?}"
    );
    assert!(
        events
            .iter()
            .all(|event| !matches!(event, AppEvent::PersistPlanModeReasoningEffort(_))),
        "expected no Plan reasoning persistence event; events: {events:?}"
    );
    assert!(
        events
            .iter()
            .all(|event| !matches!(event, AppEvent::PersistModelSelection { .. })),
        "expected no global model persistence event; events: {events:?}"
    );
    assert!(
        events
            .iter()
            .all(|event| !matches!(event, AppEvent::UpdateReasoningEffort(_))),
        "expected no global reasoning update event; events: {events:?}"
    );
}

#[tokio::test]
async fn plan_mode_reasoning_override_is_marked_current_in_reasoning_popup() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    set_chatgpt_auth(&mut chat);
    chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
    chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Low));

    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
        .expect("expected plan collaboration mode");
    chat.set_collaboration_mask(plan_mask);

    let preset = get_available_model(&chat, "gpt-5.4");
    chat.open_reasoning_popup(preset);

    let popup = render_bottom_popup(&chat, /*width*/ 100);
    assert!(popup.contains("Low (current)"));
    assert!(
        !popup.contains("High (current)"),
        "expected Plan override to drive current reasoning label, got: {popup}"
    );
}

#[tokio::test]
async fn reasoning_selection_in_plan_mode_model_switch_does_not_open_scope_prompt_event() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
    chat.thread_id = Some(ThreadId::new());
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
        .expect("expected plan collaboration mode");
    chat.set_collaboration_mask(plan_mask);
    let _ = drain_insert_history(&mut rx);
    set_chatgpt_auth(&mut chat);

    let preset = get_available_model(&chat, "gpt-5.2");
    chat.open_reasoning_popup(preset);
    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));

    let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
    assert!(
        events.iter().any(|event| matches!(
            event,
            AppEvent::UpdateModel(model) if model == "gpt-5.2"
        )),
        "expected model update event; events: {events:?}"
    );
    assert!(
        events
            .iter()
            .any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))),
        "expected reasoning update event; events: {events:?}"
    );
}

#[tokio::test]
async fn plan_reasoning_scope_popup_all_modes_persists_global_and_plan_override() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
    chat.open_plan_reasoning_scope_prompt("gpt-5.4".to_string(), Some(ReasoningEffortConfig::High));

    chat.handle_key_event(KeyEvent::from(KeyCode::Down));
    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));

    let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
    assert!(
        events.iter().any(|event| matches!(
            event,
            AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High))
        )),
        "expected plan override to be updated; events: {events:?}"
    );
    assert!(
        events.iter().any(|event| matches!(
            event,
            AppEvent::PersistPlanModeReasoningEffort(Some(ReasoningEffortConfig::High))
        )),
        "expected updated plan override to be persisted; events: {events:?}"
    );
    assert!(
        events.iter().any(|event| matches!(
            event,
            AppEvent::PersistModelSelection { model, effort: Some(ReasoningEffortConfig::High) }
                if model == "gpt-5.4"
        )),
        "expected global model reasoning selection persistence; events: {events:?}"
    );
}

#[test]
fn plan_mode_prompt_notification_uses_dedicated_type_name() {
    let notification = Notification::PlanModePrompt {
        title: PLAN_IMPLEMENTATION_TITLE.to_string(),
    };

    assert!(notification.allowed_for(&Notifications::Custom(
        vec!["plan-mode-prompt".to_string(),]
    )));
    assert!(!notification.allowed_for(&Notifications::Custom(vec![
        "approval-requested".to_string(),
    ])));
    assert_eq!(
        notification.display(),
        format!("Plan mode prompt: {PLAN_IMPLEMENTATION_TITLE}")
    );
}

#[tokio::test]
async fn open_plan_implementation_prompt_sets_pending_notification() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
    chat.config.tui_notifications.notifications =
        Notifications::Custom(vec!["plan-mode-prompt".to_string()]);

    chat.open_plan_implementation_prompt();

    assert_matches!(
        chat.pending_notification,
        Some(Notification::PlanModePrompt { ref title }) if title == PLAN_IMPLEMENTATION_TITLE
    );
}

#[tokio::test]
async fn open_plan_reasoning_scope_prompt_sets_pending_notification() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
    chat.config.tui_notifications.notifications =
        Notifications::Custom(vec!["plan-mode-prompt".to_string()]);

    chat.open_plan_reasoning_scope_prompt("gpt-5.4".to_string(), Some(ReasoningEffortConfig::High));

    assert_matches!(
        chat.pending_notification,
        Some(Notification::PlanModePrompt { ref title }) if title == PLAN_MODE_REASONING_SCOPE_TITLE
    );
}

#[tokio::test]
async fn agent_turn_complete_does_not_override_pending_plan_mode_prompt_notification() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;

    chat.open_plan_implementation_prompt();
    chat.notify(Notification::AgentTurnComplete {
        response: "done".to_string(),
    });

    assert_matches!(
        chat.pending_notification,
        Some(Notification::PlanModePrompt { ref title }) if title == PLAN_IMPLEMENTATION_TITLE
    );
}

#[tokio::test]
async fn request_user_input_notification_overrides_pending_agent_turn_complete_notification() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;

    chat.notify(Notification::AgentTurnComplete {
        response: "done".to_string(),
    });
    chat.handle_request_user_input_now(ToolRequestUserInputParams {
        thread_id: "thread-1".to_string(),
        item_id: "call-1".to_string(),
        turn_id: "turn-1".to_string(),
        questions: vec![ToolRequestUserInputQuestion {
            id: "reasoning_scope".to_string(),
            header: "Reasoning scope".to_string(),
            question: "Which reasoning scope should I use?".to_string(),
            is_other: false,
            is_secret: false,
            options: Some(vec![ToolRequestUserInputOption {
                label: "Plan only".to_string(),
                description: "Update only Plan mode.".to_string(),
            }]),
        }],
    });

    assert_matches!(
        chat.pending_notification,
        Some(Notification::PlanModePrompt { ref title }) if title == "Reasoning scope"
    );
}

#[tokio::test]
async fn handle_request_user_input_sets_pending_notification() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
    chat.config.tui_notifications.notifications =
        Notifications::Custom(vec!["plan-mode-prompt".to_string()]);

    chat.handle_request_user_input_now(ToolRequestUserInputParams {
        thread_id: "thread-1".to_string(),
        item_id: "call-1".to_string(),
        turn_id: "turn-1".to_string(),
        questions: vec![ToolRequestUserInputQuestion {
            id: "reasoning_scope".to_string(),
            header: "Reasoning scope".to_string(),
            question: "Which reasoning scope should I use?".to_string(),
            is_other: false,
            is_secret: false,
            options: Some(vec![ToolRequestUserInputOption {
                label: "Plan only".to_string(),
                description: "Update only Plan mode.".to_string(),
            }]),
        }],
    });

    assert_matches!(
        chat.pending_notification,
        Some(Notification::PlanModePrompt { ref title }) if title == "Reasoning scope"
    );
}

#[tokio::test]
async fn plan_reasoning_scope_popup_mentions_selected_reasoning() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
    chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Low));
    chat.open_plan_reasoning_scope_prompt(
        "gpt-5.4".to_string(),
        Some(ReasoningEffortConfig::Medium),
    );

    let popup = render_bottom_popup(&chat, /*width*/ 100);
    assert!(popup.contains("Choose where to apply medium reasoning."));
    assert!(popup.contains("Always use medium reasoning in Plan mode."));
    assert!(popup.contains("Apply to Plan mode override"));
    assert!(popup.contains("Apply to global default and Plan mode override"));
    assert!(popup.contains("user-chosen Plan override (low)"));
}

#[tokio::test]
async fn plan_reasoning_scope_popup_mentions_built_in_plan_default_when_no_override() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
    chat.open_plan_reasoning_scope_prompt(
        "gpt-5.4".to_string(),
        Some(ReasoningEffortConfig::Medium),
    );

    let popup = render_bottom_popup(&chat, /*width*/ 100);
    assert!(popup.contains("built-in Plan default (medium)"));
}

#[tokio::test]
async fn plan_reasoning_scope_popup_plan_only_does_not_update_all_modes_reasoning() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
    chat.open_plan_reasoning_scope_prompt("gpt-5.4".to_string(), Some(ReasoningEffortConfig::High));

    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));

    let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
    assert!(
        events.iter().any(|event| matches!(
            event,
            AppEvent::UpdatePlanModeReasoningEffort(Some(ReasoningEffortConfig::High))
        )),
        "expected plan-only reasoning update; events: {events:?}"
    );
    assert!(
        events
            .iter()
            .all(|event| !matches!(event, AppEvent::UpdateReasoningEffort(_))),
        "did not expect all-modes reasoning update; events: {events:?}"
    );
}

#[tokio::test]
async fn submit_user_message_with_mode_errors_when_mode_changes_during_running_turn() {
    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.thread_id = Some(ThreadId::new());
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask);
    chat.on_task_started();

    let default_mode = collaboration_modes::default_mask(chat.model_catalog.as_ref())
        .expect("expected default collaboration mode");
    chat.submit_user_message_with_mode("Implement the plan.".to_string(), default_mode);

    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
    assert!(chat.input_queue.queued_user_messages.is_empty());
    assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
    let rendered = drain_insert_history(&mut rx)
        .iter()
        .map(|lines| lines_to_single_string(lines))
        .collect::<Vec<_>>()
        .join("\n");
    assert!(
        rendered.contains("Cannot switch collaboration mode while a turn is running."),
        "expected running-turn error message, got: {rendered:?}"
    );
}

#[tokio::test]
async fn submit_user_message_blocks_when_thread_model_is_unavailable() {
    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    chat.thread_id = Some(ThreadId::new());
    chat.set_model("");
    chat.bottom_pane
        .set_composer_text("hello".to_string(), Vec::new(), Vec::new());

    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));

    assert_no_submit_op(&mut op_rx);
    let rendered = drain_insert_history(&mut rx)
        .iter()
        .map(|lines| lines_to_single_string(lines))
        .collect::<Vec<_>>()
        .join("\n");
    assert!(
        rendered.contains("Thread model is unavailable."),
        "expected unavailable-model error, got: {rendered:?}"
    );
}

#[tokio::test]
async fn submit_user_message_with_mode_allows_same_mode_during_running_turn() {
    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.thread_id = Some(ThreadId::new());
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask.clone());
    chat.on_task_started();

    chat.submit_user_message_with_mode("Continue planning.".to_string(), plan_mask);

    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
    assert!(chat.input_queue.queued_user_messages.is_empty());
    match next_submit_op(&mut op_rx) {
        Op::UserTurn {
            collaboration_mode:
                Some(CollaborationMode {
                    mode: ModeKind::Plan,
                    ..
                }),
            personality: None,
            ..
        } => {}
        other => {
            panic!("expected Op::UserTurn with plan collab mode, got {other:?}")
        }
    }
}

#[tokio::test]
async fn submit_user_message_with_mode_submits_when_plan_stream_is_not_active() {
    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.thread_id = Some(ThreadId::new());
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask);

    let default_mode = collaboration_modes::default_mask(chat.model_catalog.as_ref())
        .expect("expected default collaboration mode");
    let expected_mode = default_mode
        .mode
        .expect("expected default collaboration mode kind");
    chat.submit_user_message_with_mode("Implement the plan.".to_string(), default_mode);

    assert_eq!(chat.active_collaboration_mode_kind(), expected_mode);
    assert!(chat.input_queue.queued_user_messages.is_empty());
    match next_submit_op(&mut op_rx) {
        Op::UserTurn {
            collaboration_mode: Some(CollaborationMode { mode, .. }),
            personality: None,
            ..
        } => assert_eq!(mode, expected_mode),
        other => {
            panic!("expected Op::UserTurn with default collab mode, got {other:?}")
        }
    }
}

#[tokio::test]
async fn plan_implementation_popup_skips_replayed_turn_complete() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask);

    chat.replay_thread_turns(
        vec![AppServerTurn {
            id: "turn-1".to_string(),
            items_view: codex_app_server_protocol::TurnItemsView::Full,
            items: vec![AppServerThreadItem::AgentMessage {
                id: "msg-plan".to_string(),
                text: "Plan details".to_string(),
                phase: Some(MessagePhase::FinalAnswer),
                memory_citation: None,
            }],
            status: AppServerTurnStatus::Completed,
            error: None,
            started_at: None,
            completed_at: None,
            duration_ms: None,
        }],
        ReplayKind::ResumeInitialMessages,
    );

    let popup = render_bottom_popup(&chat, /*width*/ 80);
    assert!(
        !popup.contains(PLAN_IMPLEMENTATION_TITLE),
        "expected no plan popup for replayed turn, got {popup:?}"
    );
}

#[tokio::test]
async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_complete() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask);

    chat.on_task_started();
    chat.on_plan_delta("- Step 1\n- Step 2\n".to_string());
    chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string());

    chat.replay_thread_turns(
        vec![AppServerTurn {
            id: "turn-1".to_string(),
            items_view: codex_app_server_protocol::TurnItemsView::Full,
            items: vec![AppServerThreadItem::AgentMessage {
                id: "msg-plan-replay".to_string(),
                text: "Plan details".to_string(),
                phase: Some(MessagePhase::FinalAnswer),
                memory_citation: None,
            }],
            status: AppServerTurnStatus::Completed,
            error: None,
            started_at: None,
            completed_at: None,
            duration_ms: None,
        }],
        ReplayKind::ResumeInitialMessages,
    );
    let replay_popup = render_bottom_popup(&chat, /*width*/ 80);
    assert!(
        !replay_popup.contains(PLAN_IMPLEMENTATION_TITLE),
        "expected no prompt for replayed turn completion, got {replay_popup:?}"
    );

    complete_assistant_message(
        &mut chat,
        "msg-plan-live-1",
        "Plan details",
        Some(MessagePhase::FinalAnswer),
    );
    handle_turn_completed(&mut chat, "live-turn-complete-1", /*duration_ms*/ None);

    let popup = render_bottom_popup(&chat, /*width*/ 80);
    assert!(
        popup.contains(PLAN_IMPLEMENTATION_TITLE),
        "expected prompt for first live turn completion after replay, got {popup:?}"
    );

    chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
    let dismissed_popup = render_bottom_popup(&chat, /*width*/ 80);
    assert!(
        !dismissed_popup.contains(PLAN_IMPLEMENTATION_TITLE),
        "expected prompt to dismiss on Esc, got {dismissed_popup:?}"
    );

    complete_assistant_message(
        &mut chat,
        "msg-plan-live-2",
        "Plan details",
        Some(MessagePhase::FinalAnswer),
    );
    handle_turn_completed(&mut chat, "live-turn-complete-2", /*duration_ms*/ None);
    let duplicate_popup = render_bottom_popup(&chat, /*width*/ 80);
    assert!(
        !duplicate_popup.contains(PLAN_IMPLEMENTATION_TITLE),
        "expected no prompt for duplicate live completion, got {duplicate_popup:?}"
    );
}

#[tokio::test]
async fn plan_implementation_popup_skips_when_messages_queued() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask);
    chat.bottom_pane.set_task_running(/*running*/ true);
    chat.queue_user_message("Queued message".into());

    chat.on_task_complete(
        Some("Plan details".to_string()),
        /*duration_ms*/ None,
        /*from_replay*/ false,
    );

    let popup = render_bottom_popup(&chat, /*width*/ 80);
    assert!(
        !popup.contains(PLAN_IMPLEMENTATION_TITLE),
        "expected no plan popup with queued messages, got {popup:?}"
    );
}

#[tokio::test]
async fn plan_implementation_popup_skips_without_proposed_plan() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask);

    chat.on_task_started();
    chat.on_plan_update(UpdatePlanArgs {
        explanation: None,
        plan: vec![PlanItemArg {
            step: "First".to_string(),
            status: StepStatus::Pending,
        }],
    });
    chat.on_task_complete(
        /*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,
    );

    let popup = render_bottom_popup(&chat, /*width*/ 80);
    assert!(
        !popup.contains(PLAN_IMPLEMENTATION_TITLE),
        "expected no plan popup without proposed plan output, got {popup:?}"
    );
}

#[tokio::test]
async fn plan_implementation_popup_shows_after_proposed_plan_output() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask);

    chat.on_task_started();
    chat.on_plan_delta("- Step 1\n- Step 2\n".to_string());
    chat.on_plan_item_completed("- Step 1\n- Step 2\n".to_string());
    chat.on_task_complete(
        /*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,
    );

    let popup = render_bottom_popup(&chat, /*width*/ 80);
    assert!(
        popup.contains(PLAN_IMPLEMENTATION_TITLE),
        "expected plan popup after proposed plan output, got {popup:?}"
    );
}

#[tokio::test]
async fn plan_implementation_popup_skips_when_steer_follows_proposed_plan() {
    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask);
    chat.thread_id = Some(ThreadId::new());

    chat.on_task_started();
    chat.on_plan_item_completed(
        "- Step 1
- Step 2
"
        .to_string(),
    );
    chat.bottom_pane
        .set_composer_text("Please continue.".to_string(), Vec::new(), Vec::new());
    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

    match next_submit_op(&mut op_rx) {
        Op::UserTurn { items, .. } => assert_eq!(
            items,
            vec![UserInput::Text {
                text: "Please continue.".to_string(),
                text_elements: Vec::new(),
            }]
        ),
        other => panic!("expected Op::UserTurn, got {other:?}"),
    }

    complete_user_message(&mut chat, "user-1", "Please continue.");
    chat.on_task_complete(
        /*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,
    );

    let popup = render_bottom_popup(&chat, /*width*/ 80);
    assert!(
        !popup.contains(PLAN_IMPLEMENTATION_TITLE),
        "expected no plan popup after a steer follows the plan, got {popup:?}"
    );
}

#[tokio::test]
async fn plan_implementation_popup_shows_after_new_plan_follows_steer() {
    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask);
    chat.thread_id = Some(ThreadId::new());

    chat.on_task_started();
    chat.on_plan_item_completed(
        "- Initial plan
"
        .to_string(),
    );
    chat.bottom_pane
        .set_composer_text("Please revise.".to_string(), Vec::new(), Vec::new());
    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

    match next_submit_op(&mut op_rx) {
        Op::UserTurn { items, .. } => assert_eq!(
            items,
            vec![UserInput::Text {
                text: "Please revise.".to_string(),
                text_elements: Vec::new(),
            }]
        ),
        other => panic!("expected Op::UserTurn, got {other:?}"),
    }

    complete_user_message(&mut chat, "user-1", "Please revise.");
    chat.on_plan_item_completed(
        "- Revised plan
"
        .to_string(),
    );
    chat.on_task_complete(
        /*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,
    );

    let popup = render_bottom_popup(&chat, /*width*/ 80);
    assert!(
        popup.contains(PLAN_IMPLEMENTATION_TITLE),
        "expected plan popup after a newer plan follows the steer, got {popup:?}"
    );
}

#[tokio::test]
async fn plan_implementation_popup_skips_when_rate_limit_prompt_pending() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.has_chatgpt_account = true;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask);

    chat.on_task_started();
    chat.on_plan_update(UpdatePlanArgs {
        explanation: None,
        plan: vec![PlanItemArg {
            step: "First".to_string(),
            status: StepStatus::Pending,
        }],
    });
    chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0)));
    chat.on_task_complete(
        /*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,
    );

    let popup = render_bottom_popup(&chat, /*width*/ 80);
    assert!(
        popup.contains("Approaching rate limits"),
        "expected rate limit popup, got {popup:?}"
    );
    assert!(
        !popup.contains(PLAN_IMPLEMENTATION_TITLE),
        "expected plan popup to be skipped, got {popup:?}"
    );
}

#[tokio::test]
async fn plan_completion_restores_status_indicator_after_streaming_plan_output() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask);

    chat.on_task_started();
    assert_eq!(chat.bottom_pane.status_indicator_visible(), true);

    chat.on_plan_delta("- Step 1\n".to_string());
    chat.on_commit_tick();
    drain_insert_history(&mut rx);

    assert_eq!(chat.bottom_pane.status_indicator_visible(), false);
    assert_eq!(chat.bottom_pane.is_task_running(), true);

    chat.on_plan_item_completed("- Step 1\n".to_string());

    assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
    assert_eq!(chat.bottom_pane.is_task_running(), true);
}

#[tokio::test]
async fn submit_user_message_queues_while_compaction_turn_is_running() {
    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    let thread_id = ThreadId::new();
    chat.thread_id = Some(thread_id);
    chat.handle_server_notification(
        ServerNotification::TurnStarted(TurnStartedNotification {
            thread_id: thread_id.to_string(),
            turn: AppServerTurn {
                id: "turn-1".to_string(),
                items_view: codex_app_server_protocol::TurnItemsView::Full,
                items: Vec::new(),
                status: AppServerTurnStatus::InProgress,
                error: None,
                started_at: Some(0),
                completed_at: None,
                duration_ms: None,
            },
        }),
        /*replay_kind*/ None,
    );

    chat.submit_user_message(UserMessage::from("queued while compacting"));

    assert_eq!(chat.input_queue.pending_steers.len(), 1);
    match next_submit_op(&mut op_rx) {
        Op::UserTurn { items, .. } => assert_eq!(
            items,
            vec![UserInput::Text {
                text: "queued while compacting".to_string(),
                text_elements: Vec::new(),
            }]
        ),
        other => panic!("expected running-turn compact steer submit, got {other:?}"),
    }

    handle_error(
        &mut chat,
        "cannot steer a compact turn",
        Some(CodexErrorInfo::ActiveTurnNotSteerable {
            turn_kind: NonSteerableTurnKind::Compact,
        }),
    );

    assert!(chat.input_queue.pending_steers.is_empty());
    assert_eq!(
        chat.queued_user_message_texts(),
        vec!["queued while compacting"]
    );

    chat.handle_server_notification(
        ServerNotification::TurnCompleted(TurnCompletedNotification {
            thread_id: thread_id.to_string(),
            turn: AppServerTurn {
                id: "turn-1".to_string(),
                items_view: codex_app_server_protocol::TurnItemsView::Full,
                items: Vec::new(),
                status: AppServerTurnStatus::Completed,
                error: None,
                started_at: None,
                completed_at: Some(0),
                duration_ms: None,
            },
        }),
        /*replay_kind*/ None,
    );

    match next_submit_op(&mut op_rx) {
        Op::UserTurn { items, .. } => assert_eq!(
            items,
            vec![UserInput::Text {
                text: "queued while compacting".to_string(),
                text_elements: Vec::new(),
            }]
        ),
        other => panic!("expected queued compact follow-up Op::UserTurn, got {other:?}"),
    }
}

#[tokio::test(flavor = "multi_thread")]
async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() {
    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    let thread_id = ThreadId::new();
    let rollout_file = NamedTempFile::new().unwrap();
    let configured = crate::session_state::ThreadSessionState {
        thread_id,
        forked_from_id: None,
        fork_parent_title: None,
        thread_name: None,
        model: "test-model".to_string(),
        model_provider_id: "test-provider".to_string(),
        service_tier: None,
        approval_policy: AskForApproval::Never,
        approvals_reviewer: ApprovalsReviewer::User,
        permission_profile: PermissionProfile::read_only(),
        active_permission_profile: None,
        cwd: test_path_buf("/home/user/project").abs(),
        instruction_source_paths: Vec::new(),
        reasoning_effort: Some(ReasoningEffortConfig::default()),
        message_history: None,
        network_proxy: None,
        rollout_path: Some(rollout_file.path().to_path_buf()),
    };
    chat.handle_thread_session(configured);
    chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true);
    chat.bottom_pane
        .set_plugin_mentions(Some(vec![codex_plugin::PluginCapabilitySummary {
            config_name: "sample@test".to_string(),
            display_name: "Sample Plugin".to_string(),
            description: None,
            has_skills: true,
            mcp_server_names: Vec::new(),
            app_connector_ids: Vec::new(),
        }]));

    chat.submit_user_message(UserMessage {
        text: "$sample".to_string(),
        local_images: Vec::new(),
        remote_image_urls: Vec::new(),
        text_elements: Vec::new(),
        mention_bindings: vec![MentionBinding {
            mention: "sample".to_string(),
            path: "plugin://sample@test".to_string(),
        }],
    });

    let Op::UserTurn { items, .. } = next_submit_op(&mut op_rx) else {
        panic!("expected Op::UserTurn");
    };
    assert_eq!(
        items,
        vec![
            UserInput::Text {
                text: "$sample".to_string(),
                text_elements: Vec::new(),
            },
            UserInput::Mention {
                name: "Sample Plugin".to_string(),
                path: "plugin://sample@test".to_string(),
            },
        ]
    );
}

#[tokio::test]
async fn enter_submits_when_plan_stream_is_not_active() {
    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    chat.thread_id = Some(ThreadId::new());
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask);
    chat.on_task_started();

    chat.bottom_pane
        .set_composer_text("submitted immediately".to_string(), Vec::new(), Vec::new());
    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

    assert!(chat.input_queue.queued_user_messages.is_empty());
    match next_submit_op(&mut op_rx) {
        Op::UserTurn {
            personality: Some(Personality::Pragmatic),
            ..
        } => {}
        other => panic!("expected Op::UserTurn, got {other:?}"),
    }
}

#[tokio::test]
async fn collab_mode_shift_tab_cycles_only_when_idle() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;

    let initial = chat.current_collaboration_mode().clone();
    chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
    assert_eq!(chat.current_collaboration_mode(), &initial);

    chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default);
    assert_eq!(chat.current_collaboration_mode(), &initial);

    chat.on_task_started();
    let before = chat.active_collaboration_mode_kind();
    chat.handle_key_event(KeyEvent::from(KeyCode::BackTab));
    assert_eq!(chat.active_collaboration_mode_kind(), before);
}

#[tokio::test]
async fn mode_switch_surfaces_model_change_notification_when_effective_model_changes() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let default_model = chat.current_model().to_string();

    let mut plan_mask =
        collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
            .expect("expected plan collaboration mode");
    plan_mask.model = Some("gpt-5.4-mini".to_string());
    chat.set_collaboration_mask(plan_mask);

    let plan_messages = drain_insert_history(&mut rx)
        .iter()
        .map(|lines| lines_to_single_string(lines))
        .collect::<Vec<_>>()
        .join("\n");
    assert!(
        plan_messages.contains("Model changed to gpt-5.4-mini medium for Plan mode."),
        "expected Plan-mode model switch notice, got: {plan_messages:?}"
    );

    let default_mask = collaboration_modes::default_mask(chat.model_catalog.as_ref())
        .expect("expected default collaboration mode");
    chat.set_collaboration_mask(default_mask);

    let default_messages = drain_insert_history(&mut rx)
        .iter()
        .map(|lines| lines_to_single_string(lines))
        .collect::<Vec<_>>()
        .join("\n");
    let expected_default_message =
        format!("Model changed to {default_model} default for Default mode.");
    assert!(
        default_messages.contains(&expected_default_message),
        "expected Default-mode model switch notice, got: {default_messages:?}"
    );
}

#[tokio::test]
async fn mode_switch_surfaces_reasoning_change_notification_when_model_stays_same() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));

    let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
        .expect("expected plan collaboration mode");
    chat.set_collaboration_mask(plan_mask);

    let plan_messages = drain_insert_history(&mut rx)
        .iter()
        .map(|lines| lines_to_single_string(lines))
        .collect::<Vec<_>>()
        .join("\n");
    assert!(
        plan_messages.contains("Model changed to gpt-5.3-codex medium for Plan mode."),
        "expected reasoning-change notice in Plan mode, got: {plan_messages:?}"
    );
}

#[tokio::test]
async fn plan_slash_command_switches_to_plan_mode() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let initial = chat.current_collaboration_mode().clone();

    chat.dispatch_command(SlashCommand::Plan);

    while let Ok(event) = rx.try_recv() {
        assert!(
            matches!(event, AppEvent::InsertHistoryCell(_)),
            "plan should not emit a non-history app event: {event:?}"
        );
    }
    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
    assert_eq!(chat.current_collaboration_mode(), &initial);
}

#[tokio::test]
async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() {
    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);

    let configured = crate::session_state::ThreadSessionState {
        thread_id: ThreadId::new(),
        forked_from_id: None,
        fork_parent_title: None,
        thread_name: None,
        model: "test-model".to_string(),
        model_provider_id: "test-provider".to_string(),
        service_tier: None,
        approval_policy: AskForApproval::Never,
        approvals_reviewer: ApprovalsReviewer::User,
        permission_profile: PermissionProfile::read_only(),
        active_permission_profile: None,
        cwd: test_path_buf("/home/user/project").abs(),
        instruction_source_paths: Vec::new(),
        reasoning_effort: Some(ReasoningEffortConfig::default()),
        message_history: None,
        network_proxy: None,
        rollout_path: None,
    };
    chat.handle_thread_session(configured);

    chat.bottom_pane
        .set_composer_text("/plan build the plan".to_string(), Vec::new(), Vec::new());
    chat.handle_key_event(KeyEvent::from(KeyCode::Enter));

    let items = match next_submit_op(&mut op_rx) {
        Op::UserTurn { items, .. } => items,
        other => panic!("expected Op::UserTurn, got {other:?}"),
    };
    assert_eq!(items.len(), 1);
    assert_eq!(
        items[0],
        UserInput::Text {
            text: "build the plan".to_string(),
            text_elements: Vec::new(),
        }
    );
    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
}

#[tokio::test]
async fn collaboration_modes_defaults_to_code_on_startup() {
    let chat = make_startup_chat_with_cli_overrides(vec![(
        "features.collaboration_modes".to_string(),
        TomlValue::Boolean(true),
    )])
    .await;
    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default);
    assert_eq!(
        chat.current_model(),
        crate::legacy_core::test_support::get_model_offline(chat.config.model.as_deref())
    );
}

#[tokio::test]
async fn vim_mode_default_disabled_starts_composer_in_insert_mode() {
    let chat = make_startup_chat_with_cli_overrides(Vec::new()).await;
    assert!(!chat.bottom_pane.composer_is_vim_enabled());
}

#[tokio::test]
async fn vim_mode_default_enabled_starts_composer_in_normal_mode() {
    let chat = make_startup_chat_with_cli_overrides(vec![(
        "tui.vim_mode_default".to_string(),
        TomlValue::Boolean(true),
    )])
    .await;

    assert!(chat.bottom_pane.composer_is_vim_enabled());
    assert!(chat.composer_is_empty());
    let mut chat = chat;
    chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
    assert_eq!(chat.bottom_pane.composer_text(), "");
}

async fn make_startup_chat_with_cli_overrides(
    cli_overrides: Vec<(String, TomlValue)>,
) -> ChatWidget {
    let codex_home = tempdir().expect("tempdir");
    let cfg = ConfigBuilder::default()
        .codex_home(codex_home.path().to_path_buf())
        .cli_overrides(cli_overrides)
        .build()
        .await
        .expect("config");
    let resolved_model = crate::legacy_core::test_support::get_model_offline(cfg.model.as_deref());
    let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());
    let init = ChatWidgetInit {
        config: cfg.clone(),
        environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
        frame_requester: FrameRequester::test_dummy(),
        app_event_tx: AppEventSender::new(unbounded_channel::<AppEvent>().0),
        workspace_command_runner: None,
        initial_user_message: None,
        enhanced_keys_supported: false,
        has_chatgpt_account: false,
        model_catalog: test_model_catalog(&cfg),
        feedback: codex_feedback::CodexFeedback::new(),
        is_first_run: true,
        status_account_display: None,
        runtime_model_provider_base_url: None,
        initial_plan_type: None,
        model: Some(resolved_model),
        startup_tooltip_override: None,
        status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
        terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
        session_telemetry,
    };

    ChatWidget::new_with_app_event(init)
}

#[tokio::test]
async fn set_model_updates_active_collaboration_mask() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask);

    chat.set_model("gpt-5.4-mini");

    assert_eq!(chat.current_model(), "gpt-5.4-mini");
    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
}

#[tokio::test]
async fn set_reasoning_effort_updates_active_collaboration_mask() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask);

    chat.set_reasoning_effort(/*effort*/ None);

    assert_eq!(
        chat.current_reasoning_effort(),
        Some(ReasoningEffortConfig::Medium)
    );
    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
}

#[tokio::test]
async fn set_reasoning_effort_does_not_override_active_plan_override() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await;
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
    chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High));
    let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
        .expect("expected plan collaboration mask");
    chat.set_collaboration_mask(plan_mask);

    chat.set_reasoning_effort(Some(ReasoningEffortConfig::Low));

    assert_eq!(
        chat.current_reasoning_effort(),
        Some(ReasoningEffortConfig::High)
    );
    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
}

#[tokio::test]
async fn collab_mode_is_sent_after_enabling() {
    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    chat.thread_id = Some(ThreadId::new());
    chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);

    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 {
            collaboration_mode:
                Some(CollaborationMode {
                    mode: ModeKind::Default,
                    ..
                }),
            personality: Some(Personality::Pragmatic),
            ..
        } => {}
        other => {
            panic!("expected Op::UserTurn, got {other:?}")
        }
    }
}

#[tokio::test]
async fn collab_mode_applies_default_preset() {
    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    chat.thread_id = Some(ThreadId::new());

    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 {
            collaboration_mode:
                Some(CollaborationMode {
                    mode: ModeKind::Default,
                    ..
                }),
            personality: Some(Personality::Pragmatic),
            ..
        } => {}
        other => {
            panic!("expected Op::UserTurn with default collaboration_mode, got {other:?}")
        }
    }

    assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default);
    assert_eq!(chat.current_collaboration_mode().mode, ModeKind::Default);
}

#[tokio::test]
async fn user_turn_includes_personality_from_config() {
    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
    chat.set_feature_enabled(Feature::Personality, /*enabled*/ true);
    chat.thread_id = Some(ThreadId::new());
    chat.set_model("gpt-5.3-codex");
    chat.set_personality(Personality::Friendly);

    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 {
            personality: Some(Personality::Friendly),
            ..
        } => {}
        other => panic!("expected Op::UserTurn with friendly personality, got {other:?}"),
    }
}

#[tokio::test]
async fn plan_update_renders_history_cell() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    let update = UpdatePlanArgs {
        explanation: Some("Adapting plan".to_string()),
        plan: vec![
            PlanItemArg {
                step: "Explore codebase".into(),
                status: StepStatus::Completed,
            },
            PlanItemArg {
                step: "Implement feature".into(),
                status: StepStatus::InProgress,
            },
            PlanItemArg {
                step: "Write tests".into(),
                status: StepStatus::Pending,
            },
        ],
    };
    chat.on_plan_update(update);
    let cells = drain_insert_history(&mut rx);
    assert!(!cells.is_empty(), "expected plan update cell to be sent");
    let blob = lines_to_single_string(cells.last().unwrap());
    assert!(
        blob.contains("Updated Plan"),
        "missing plan header: {blob:?}"
    );
    assert!(blob.contains("Explore codebase"));
    assert!(blob.contains("Implement feature"));
    assert!(blob.contains("Write tests"));
}
