use super::*;
use crate::app_event::ConnectorsSnapshot;
use codex_protocol::models::ManagedFileSystemPermissions;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::permissions::NetworkSandboxPolicy;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::collections::VecDeque;

#[tokio::test]
async fn submission_preserves_text_elements_and_local_images() {
    let (mut chat, mut 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(),
        runtime_workspace_roots: Vec::new(),
        instruction_source_paths: Vec::new(),
        reasoning_effort: Some(ReasoningEffortConfig::default()),
        collaboration_mode: None,
        personality: None,
        message_history: None,
        network_proxy: None,
        rollout_path: Some(rollout_file.path().to_path_buf()),
    };
    chat.handle_thread_session(configured);
    drain_insert_history(&mut rx);

    let placeholder = "[Image #1]";
    let text = format!("{placeholder} submit");
    let text_elements = vec![TextElement::new(
        (0..placeholder.len()).into(),
        Some(placeholder.to_string()),
    )];
    let local_images = vec![PathBuf::from("/tmp/submitted.png")];

    chat.bottom_pane
        .set_composer_text(text.clone(), text_elements.clone(), local_images.clone());
    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

    let items = match next_submit_op(&mut op_rx) {
        Op::UserTurn { items, .. } => items,
        other => panic!("expected Op::UserTurn, got {other:?}"),
    };
    assert_eq!(items.len(), 2);
    assert_eq!(
        items[0],
        UserInput::LocalImage {
            path: local_images[0].clone(),
            detail: None,
        }
    );
    assert_eq!(
        items[1],
        UserInput::Text {
            text: text.clone(),
            text_elements: text_elements.clone().into_iter().map(Into::into).collect(),
        }
    );

    let mut user_cell = None;
    while let Ok(ev) = rx.try_recv() {
        if let AppEvent::InsertHistoryCell(cell) = ev
            && let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
        {
            user_cell = Some((
                cell.message.clone(),
                cell.text_elements.clone(),
                cell.local_image_paths.clone(),
                cell.remote_image_urls.clone(),
            ));
            break;
        }
    }

    let (stored_message, stored_elements, stored_images, stored_remote_image_urls) =
        user_cell.expect("expected submitted user history cell");
    assert_eq!(stored_message, text);
    assert_eq!(stored_elements, text_elements);
    assert_eq!(stored_images, local_images);
    assert!(stored_remote_image_urls.is_empty());
}

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

    let thread_id = ThreadId::new();
    let rollout_file = NamedTempFile::new().unwrap();
    let expected_permission_profile: PermissionProfile = PermissionProfile::Managed {
        network: NetworkSandboxPolicy::Restricted,
        file_system: ManagedFileSystemPermissions::Restricted {
            entries: vec![
                FileSystemSandboxEntry {
                    path: FileSystemPath::Special {
                        value: FileSystemSpecialPath::Root,
                    },
                    access: FileSystemAccessMode::Read,
                },
                FileSystemSandboxEntry {
                    path: FileSystemPath::GlobPattern {
                        pattern: "/home/user/project/secrets/**".to_string(),
                    },
                    access: FileSystemAccessMode::Deny,
                },
            ],
            glob_scan_max_depth: None,
        },
    };
    let expected_active_permission_profile = ActivePermissionProfile::new("custom");
    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: expected_permission_profile,
        active_permission_profile: Some(expected_active_permission_profile.clone()),
        cwd: test_path_buf("/home/user/project").abs(),
        runtime_workspace_roots: Vec::new(),
        instruction_source_paths: Vec::new(),
        reasoning_effort: Some(ReasoningEffortConfig::default()),
        collaboration_mode: None,
        personality: None,
        message_history: None,
        network_proxy: None,
        rollout_path: Some(rollout_file.path().to_path_buf()),
    };
    chat.handle_thread_session(configured);
    drain_insert_history(&mut rx);

    chat.bottom_pane.set_composer_text(
        "submit with configured permissions".to_string(),
        Vec::new(),
        Vec::new(),
    );
    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

    let active_permission_profile = match next_submit_op(&mut op_rx) {
        Op::UserTurn {
            active_permission_profile,
            ..
        } => active_permission_profile,
        other => panic!("expected Op::UserTurn, got {other:?}"),
    };
    assert_eq!(
        active_permission_profile,
        Some(expected_active_permission_profile)
    );
}

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

    let thread_id = ThreadId::new();
    let rollout_file = NamedTempFile::new().unwrap();
    let expected_permission_profile: PermissionProfile = PermissionProfile::Managed {
        network: NetworkSandboxPolicy::Restricted,
        file_system: ManagedFileSystemPermissions::Unrestricted,
    };
    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: expected_permission_profile,
        active_permission_profile: None,
        cwd: test_path_buf("/home/user/project").abs(),
        runtime_workspace_roots: Vec::new(),
        instruction_source_paths: Vec::new(),
        reasoning_effort: Some(ReasoningEffortConfig::default()),
        collaboration_mode: None,
        personality: None,
        message_history: None,
        network_proxy: None,
        rollout_path: Some(rollout_file.path().to_path_buf()),
    };
    chat.handle_thread_session(configured);
    drain_insert_history(&mut rx);

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

    let active_permission_profile = match next_submit_op(&mut op_rx) {
        Op::UserTurn {
            active_permission_profile,
            ..
        } => active_permission_profile,
        other => panic!("expected Op::UserTurn, got {other:?}"),
    };
    assert_eq!(active_permission_profile, None);
}

#[tokio::test]
async fn submission_with_remote_and_local_images_keeps_local_placeholder_numbering() {
    let (mut chat, mut 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(),
        runtime_workspace_roots: Vec::new(),
        instruction_source_paths: Vec::new(),
        reasoning_effort: Some(ReasoningEffortConfig::default()),
        collaboration_mode: None,
        personality: None,
        message_history: None,
        network_proxy: None,
        rollout_path: Some(rollout_file.path().to_path_buf()),
    };
    chat.handle_thread_session(configured);
    drain_insert_history(&mut rx);

    let remote_url = "https://example.com/remote.png".to_string();
    chat.set_remote_image_urls(vec![remote_url.clone()]);

    let placeholder = "[Image #2]";
    let text = format!("{placeholder} submit mixed");
    let text_elements = vec![TextElement::new(
        (0..placeholder.len()).into(),
        Some(placeholder.to_string()),
    )];
    let local_images = vec![PathBuf::from("/tmp/submitted-mixed.png")];

    chat.bottom_pane
        .set_composer_text(text.clone(), text_elements.clone(), local_images.clone());
    assert_eq!(chat.bottom_pane.composer_text(), "[Image #2] submit mixed");
    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

    let items = match next_submit_op(&mut op_rx) {
        Op::UserTurn { items, .. } => items,
        other => panic!("expected Op::UserTurn, got {other:?}"),
    };
    assert_eq!(items.len(), 3);
    assert_eq!(
        items[0],
        UserInput::Image {
            url: remote_url.clone(),
            detail: None,
        }
    );
    assert_eq!(
        items[1],
        UserInput::LocalImage {
            path: local_images[0].clone(),
            detail: None,
        }
    );
    assert_eq!(
        items[2],
        UserInput::Text {
            text: text.clone(),
            text_elements: text_elements.clone().into_iter().map(Into::into).collect(),
        }
    );
    assert_eq!(text_elements[0].placeholder(&text), Some("[Image #2]"));

    let mut user_cell = None;
    while let Ok(ev) = rx.try_recv() {
        if let AppEvent::InsertHistoryCell(cell) = ev
            && let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
        {
            user_cell = Some((
                cell.message.clone(),
                cell.text_elements.clone(),
                cell.local_image_paths.clone(),
                cell.remote_image_urls.clone(),
            ));
            break;
        }
    }

    let (stored_message, stored_elements, stored_images, stored_remote_image_urls) =
        user_cell.expect("expected submitted user history cell");
    assert_eq!(stored_message, text);
    assert_eq!(stored_elements, text_elements);
    assert_eq!(stored_images, local_images);
    assert_eq!(stored_remote_image_urls, vec![remote_url]);
}

#[tokio::test]
async fn enter_with_only_remote_images_submits_user_turn() {
    let (mut chat, mut 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(),
        runtime_workspace_roots: Vec::new(),
        instruction_source_paths: Vec::new(),
        reasoning_effort: Some(ReasoningEffortConfig::default()),
        collaboration_mode: None,
        personality: None,
        message_history: None,
        network_proxy: None,
        rollout_path: Some(rollout_file.path().to_path_buf()),
    };
    chat.handle_thread_session(configured);
    drain_insert_history(&mut rx);

    let remote_url = "https://example.com/remote-only.png".to_string();
    chat.set_remote_image_urls(vec![remote_url.clone()]);
    assert_eq!(chat.bottom_pane.composer_text(), "");

    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

    let (items, summary) = match next_submit_op(&mut op_rx) {
        Op::UserTurn { items, summary, .. } => (items, summary),
        other => panic!("expected Op::UserTurn, got {other:?}"),
    };
    assert_eq!(
        items,
        vec![UserInput::Image {
            url: remote_url.clone(),
            detail: None,
        }]
    );
    assert_eq!(summary, None);
    assert!(chat.remote_image_urls().is_empty());

    let mut user_cell = None;
    while let Ok(ev) = rx.try_recv() {
        if let AppEvent::InsertHistoryCell(cell) = ev
            && let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
        {
            user_cell = Some((cell.message.clone(), cell.remote_image_urls.clone()));
            break;
        }
    }

    let (stored_message, stored_remote_image_urls) =
        user_cell.expect("expected submitted user history cell");
    assert_eq!(stored_message, String::new());
    assert_eq!(stored_remote_image_urls, vec![remote_url]);
}

#[tokio::test]
async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() {
    let (mut chat, mut 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(),
        runtime_workspace_roots: Vec::new(),
        instruction_source_paths: Vec::new(),
        reasoning_effort: Some(ReasoningEffortConfig::default()),
        collaboration_mode: None,
        personality: None,
        message_history: None,
        network_proxy: None,
        rollout_path: Some(rollout_file.path().to_path_buf()),
    };
    chat.handle_thread_session(configured);
    drain_insert_history(&mut rx);

    let remote_url = "https://example.com/remote-only.png".to_string();
    chat.set_remote_image_urls(vec![remote_url.clone()]);
    assert_eq!(chat.bottom_pane.composer_text(), "");

    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT));

    assert_no_submit_op(&mut op_rx);
    assert_eq!(chat.remote_image_urls(), vec![remote_url]);
}

#[tokio::test]
async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() {
    let (mut chat, mut 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(),
        runtime_workspace_roots: Vec::new(),
        instruction_source_paths: Vec::new(),
        reasoning_effort: Some(ReasoningEffortConfig::default()),
        collaboration_mode: None,
        personality: None,
        message_history: None,
        network_proxy: None,
        rollout_path: Some(rollout_file.path().to_path_buf()),
    };
    chat.handle_thread_session(configured);
    drain_insert_history(&mut rx);

    let remote_url = "https://example.com/remote-only.png".to_string();
    chat.set_remote_image_urls(vec![remote_url.clone()]);

    chat.open_review_popup();
    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

    assert_eq!(chat.remote_image_urls(), vec![remote_url]);
    assert_no_submit_op(&mut op_rx);
}

#[tokio::test]
async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() {
    let (mut chat, mut 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(),
        runtime_workspace_roots: Vec::new(),
        instruction_source_paths: Vec::new(),
        reasoning_effort: Some(ReasoningEffortConfig::default()),
        collaboration_mode: None,
        personality: None,
        message_history: None,
        network_proxy: None,
        rollout_path: Some(rollout_file.path().to_path_buf()),
    };
    chat.handle_thread_session(configured);
    drain_insert_history(&mut rx);

    let remote_url = "https://example.com/remote-only.png".to_string();
    chat.set_remote_image_urls(vec![remote_url.clone()]);
    chat.bottom_pane.set_composer_input_enabled(
        /*enabled*/ false,
        Some("Input disabled for test.".to_string()),
    );

    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

    assert_eq!(chat.remote_image_urls(), vec![remote_url]);
    assert_no_submit_op(&mut op_rx);
}

#[tokio::test]
async fn submission_prefers_selected_duplicate_skill_path() {
    let (mut chat, mut 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(),
        runtime_workspace_roots: Vec::new(),
        instruction_source_paths: Vec::new(),
        reasoning_effort: Some(ReasoningEffortConfig::default()),
        collaboration_mode: None,
        personality: None,
        message_history: None,
        network_proxy: None,
        rollout_path: Some(rollout_file.path().to_path_buf()),
    };
    chat.handle_thread_session(configured);
    drain_insert_history(&mut rx);

    let repo_skill_path = test_path_buf("/tmp/repo/figma/SKILL.md").abs();
    let user_skill_path = test_path_buf("/tmp/user/figma/SKILL.md").abs();
    chat.set_skills(Some(vec![
        SkillMetadata {
            name: "figma".to_string(),
            description: "Repo skill".to_string(),
            short_description: None,
            interface: None,
            dependencies: None,
            policy: None,
            path_to_skills_md: repo_skill_path,
            scope: crate::test_support::skill_scope_repo(),
            plugin_id: None,
        },
        SkillMetadata {
            name: "figma".to_string(),
            description: "User skill".to_string(),
            short_description: None,
            interface: None,
            dependencies: None,
            policy: None,
            path_to_skills_md: user_skill_path.clone(),
            scope: crate::test_support::skill_scope_user(),
            plugin_id: None,
        },
    ]));

    chat.bottom_pane.set_composer_text_with_mention_bindings(
        "please use $figma now".to_string(),
        Vec::new(),
        Vec::new(),
        vec![MentionBinding {
            sigil: '$',
            mention: "figma".to_string(),
            path: user_skill_path.to_string_lossy().into_owned(),
        }],
    );
    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

    let items = match next_submit_op(&mut op_rx) {
        Op::UserTurn { items, .. } => items,
        other => panic!("expected Op::UserTurn, got {other:?}"),
    };
    let selected_skill_paths = items
        .iter()
        .filter_map(|item| match item {
            UserInput::Skill { path, .. } => Some(path.clone()),
            _ => None,
        })
        .collect::<Vec<_>>();
    assert_eq!(selected_skill_paths, vec![user_skill_path.to_path_buf()]);
}

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

    let placeholder = "[Image #1]";
    let text = format!("{placeholder} check $file");
    let text_elements = vec![TextElement::new(
        (0..placeholder.len()).into(),
        Some(placeholder.to_string()),
    )];
    let local_images = vec![LocalImageAttachment {
        placeholder: placeholder.to_string(),
        path: PathBuf::from("/tmp/blocked.png"),
    }];
    let mention_bindings = vec![MentionBinding {
        sigil: '$',
        mention: "file".to_string(),
        path: "/tmp/skills/file/SKILL.md".to_string(),
    }];

    chat.restore_blocked_image_submission(
        text.clone(),
        text_elements,
        local_images.clone(),
        mention_bindings.clone(),
        Vec::new(),
    );

    let mention_start = text.find("$file").expect("mention token exists");
    let expected_elements = vec![
        TextElement::new((0..placeholder.len()).into(), Some(placeholder.to_string())),
        TextElement::new(
            (mention_start..mention_start + "$file".len()).into(),
            Some("$file".to_string()),
        ),
    ];
    assert_eq!(chat.bottom_pane.composer_text(), text);
    assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements);
    assert_eq!(
        chat.bottom_pane.composer_local_image_paths(),
        vec![local_images[0].path.clone()],
    );
    assert_eq!(chat.bottom_pane.take_mention_bindings(), mention_bindings);

    let cells = drain_insert_history(&mut rx);
    let warning = cells
        .last()
        .map(|lines| lines_to_single_string(lines))
        .expect("expected warning cell");
    assert!(
        warning.contains("does not support image inputs"),
        "expected image warning, got: {warning:?}"
    );
}

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

    let first_placeholder = "[Image #2]";
    let second_placeholder = "[Image #3]";
    let text = format!("{first_placeholder} first\n{second_placeholder} second");
    let second_start = text.find(second_placeholder).expect("second placeholder");
    let text_elements = vec![
        TextElement::new(
            (0..first_placeholder.len()).into(),
            Some(first_placeholder.to_string()),
        ),
        TextElement::new(
            (second_start..second_start + second_placeholder.len()).into(),
            Some(second_placeholder.to_string()),
        ),
    ];
    let local_images = vec![
        LocalImageAttachment {
            placeholder: first_placeholder.to_string(),
            path: PathBuf::from("/tmp/blocked-first.png"),
        },
        LocalImageAttachment {
            placeholder: second_placeholder.to_string(),
            path: PathBuf::from("/tmp/blocked-second.png"),
        },
    ];
    let remote_image_urls = vec!["https://example.com/blocked-remote.png".to_string()];

    chat.restore_blocked_image_submission(
        text.clone(),
        text_elements.clone(),
        local_images.clone(),
        Vec::new(),
        remote_image_urls.clone(),
    );

    assert_eq!(chat.bottom_pane.composer_text(), text);
    assert_eq!(chat.bottom_pane.composer_text_elements(), text_elements);
    assert_eq!(chat.bottom_pane.composer_local_images(), local_images);
    assert_eq!(chat.remote_image_urls(), remote_image_urls);
}

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

    let first_placeholder = "[Image #2]";
    let second_placeholder = "[Image #3]";
    let text = format!("{first_placeholder} first\n{second_placeholder} second");
    let second_start = text.find(second_placeholder).expect("second placeholder");
    let text_elements = vec![
        TextElement::new(
            (0..first_placeholder.len()).into(),
            Some(first_placeholder.to_string()),
        ),
        TextElement::new(
            (second_start..second_start + second_placeholder.len()).into(),
            Some(second_placeholder.to_string()),
        ),
    ];
    let local_images = vec![
        LocalImageAttachment {
            placeholder: first_placeholder.to_string(),
            path: PathBuf::from("/tmp/queued-first.png"),
        },
        LocalImageAttachment {
            placeholder: second_placeholder.to_string(),
            path: PathBuf::from("/tmp/queued-second.png"),
        },
    ];
    let remote_image_urls = vec!["https://example.com/queued-remote.png".to_string()];

    chat.restore_user_message_to_composer(UserMessage {
        text: text.clone(),
        local_images: local_images.clone(),
        remote_image_urls: remote_image_urls.clone(),
        text_elements: text_elements.clone(),
        mention_bindings: Vec::new(),
    });

    assert_eq!(chat.bottom_pane.composer_text(), text);
    assert_eq!(chat.bottom_pane.composer_cursor(), text.len());
    assert_eq!(chat.bottom_pane.composer_text_elements(), text_elements);
    assert_eq!(chat.bottom_pane.composer_local_images(), local_images);
    assert_eq!(chat.remote_image_urls(), remote_image_urls);
}

#[tokio::test]
async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() {
    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::plan_mask(chat.model_catalog.as_ref())
        .expect("expected plan collaboration mode");
    let expected_mode = plan_mask
        .mode
        .expect("expected mode kind on plan collaboration mode");

    chat.set_collaboration_mask(plan_mask);
    chat.on_task_started();
    chat.input_queue.queued_user_messages.push_back(
        UserMessage {
            text: "Implement the plan.".to_string(),
            local_images: Vec::new(),
            remote_image_urls: Vec::new(),
            text_elements: Vec::new(),
            mention_bindings: Vec::new(),
        }
        .into(),
    );
    chat.refresh_pending_input_preview();

    handle_turn_interrupted(&mut chat, "turn-1");

    assert_eq!(chat.bottom_pane.composer_text(), "Implement the plan.");
    assert!(chat.input_queue.queued_user_messages.is_empty());
    assert_eq!(chat.active_collaboration_mode_kind(), expected_mode);

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

    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 active mode, got {other:?}")
        }
    }
    assert_eq!(chat.active_collaboration_mode_kind(), expected_mode);
}

#[tokio::test]
async fn remap_placeholders_uses_attachment_labels() {
    let placeholder_one = "[Image #1]";
    let placeholder_two = "[Image #2]";
    let text = format!("{placeholder_two} before {placeholder_one}");
    let elements = vec![
        TextElement::new(
            (0..placeholder_two.len()).into(),
            Some(placeholder_two.to_string()),
        ),
        TextElement::new(
            ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(),
            Some(placeholder_one.to_string()),
        ),
    ];

    let attachments = vec![
        LocalImageAttachment {
            placeholder: placeholder_one.to_string(),
            path: PathBuf::from("/tmp/one.png"),
        },
        LocalImageAttachment {
            placeholder: placeholder_two.to_string(),
            path: PathBuf::from("/tmp/two.png"),
        },
    ];
    let message = UserMessage {
        text,
        text_elements: elements,
        local_images: attachments,
        remote_image_urls: vec!["https://example.com/a.png".to_string()],
        mention_bindings: Vec::new(),
    };
    let mut next_label = 3usize;
    let remapped = remap_placeholders_for_message(message, &mut next_label);

    assert_eq!(remapped.text, "[Image #4] before [Image #3]");
    assert_eq!(
        remapped.text_elements,
        vec![
            TextElement::new(
                (0.."[Image #4]".len()).into(),
                Some("[Image #4]".to_string()),
            ),
            TextElement::new(
                ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(),
                Some("[Image #3]".to_string()),
            ),
        ]
    );
    assert_eq!(
        remapped.local_images,
        vec![
            LocalImageAttachment {
                placeholder: "[Image #3]".to_string(),
                path: PathBuf::from("/tmp/one.png"),
            },
            LocalImageAttachment {
                placeholder: "[Image #4]".to_string(),
                path: PathBuf::from("/tmp/two.png"),
            },
        ]
    );
    assert_eq!(
        remapped.remote_image_urls,
        vec!["https://example.com/a.png".to_string()]
    );
}

#[tokio::test]
async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() {
    let placeholder_one = "[Image #1]";
    let placeholder_two = "[Image #2]";
    let text = format!("{placeholder_two} before {placeholder_one}");
    let elements = vec![
        TextElement::new((0..placeholder_two.len()).into(), /*placeholder*/ None),
        TextElement::new(
            ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(),
            /*placeholder*/ None,
        ),
    ];

    let attachments = vec![
        LocalImageAttachment {
            placeholder: placeholder_one.to_string(),
            path: PathBuf::from("/tmp/one.png"),
        },
        LocalImageAttachment {
            placeholder: placeholder_two.to_string(),
            path: PathBuf::from("/tmp/two.png"),
        },
    ];
    let message = UserMessage {
        text,
        text_elements: elements,
        local_images: attachments,
        remote_image_urls: Vec::new(),
        mention_bindings: Vec::new(),
    };
    let mut next_label = 3usize;
    let remapped = remap_placeholders_for_message(message, &mut next_label);

    assert_eq!(remapped.text, "[Image #4] before [Image #3]");
    assert_eq!(
        remapped.text_elements,
        vec![
            TextElement::new(
                (0.."[Image #4]".len()).into(),
                Some("[Image #4]".to_string()),
            ),
            TextElement::new(
                ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(),
                Some("[Image #3]".to_string()),
            ),
        ]
    );
    assert_eq!(
        remapped.local_images,
        vec![
            LocalImageAttachment {
                placeholder: "[Image #3]".to_string(),
                path: PathBuf::from("/tmp/one.png"),
            },
            LocalImageAttachment {
                placeholder: "[Image #4]".to_string(),
                path: PathBuf::from("/tmp/two.png"),
            },
        ]
    );
}

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

    // Simulate running task so submissions would normally be queued.
    chat.bottom_pane.set_task_running(/*running*/ true);

    // Press Enter with an empty composer.
    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

    // Ensure nothing was queued.
    assert!(chat.input_queue.queued_user_messages.is_empty());
}

#[tokio::test]
async fn output_free_interrupted_turn_requests_prompt_restore() {
    let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    let prompt = UserMessage::from("revise this prompt");
    chat.record_cancel_edit_candidate(prompt.clone());
    handle_turn_started(&mut chat, "turn-1");

    chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());
    assert_matches!(
        op_rx.try_recv(),
        Ok(Op::Interrupt {
            behavior: crate::app_command::InterruptBehavior::RestorePromptIfNoOutput,
        })
    );
    handle_turn_interrupted(&mut chat, "turn-1");

    assert_matches!(rx.try_recv(), Ok(AppEvent::RestoreCancelledTurn(restored)) if restored == prompt);
}

#[tokio::test]
async fn visible_output_prevents_cancelled_turn_prompt_restore() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    chat.record_cancel_edit_candidate(UserMessage::from("revise this prompt"));
    handle_turn_started(&mut chat, "turn-1");
    chat.on_agent_message_delta("visible output".to_string());
    chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());

    handle_turn_interrupted(&mut chat, "turn-1");

    while let Ok(event) = rx.try_recv() {
        assert!(!matches!(event, AppEvent::RestoreCancelledTurn(_)));
    }
}

#[tokio::test]
async fn thinking_status_keeps_cancelled_turn_prompt_restore_eligible() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    let prompt = UserMessage::from("revise this prompt");
    chat.record_cancel_edit_candidate(prompt.clone());
    handle_turn_started(&mut chat, "turn-1");
    chat.on_agent_reasoning_delta("**Thinking**".to_string());
    chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());

    handle_turn_interrupted(&mut chat, "turn-1");

    assert_matches!(rx.try_recv(), Ok(AppEvent::RestoreCancelledTurn(restored)) if restored == prompt);
}

#[tokio::test]
async fn patch_activity_prevents_cancelled_turn_prompt_restore() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    chat.record_cancel_edit_candidate(UserMessage::from("revise this prompt"));
    handle_turn_started(&mut chat, "turn-1");
    chat.on_patch_apply_begin(HashMap::new());
    chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output());

    handle_turn_interrupted(&mut chat, "turn-1");

    while let Ok(event) = rx.try_recv() {
        assert!(!matches!(event, AppEvent::RestoreCancelledTurn(_)));
    }
}

#[tokio::test]
async fn pending_steer_esc_does_not_steal_vim_insert_escape() {
    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);

    chat.bottom_pane.set_task_running(/*running*/ true);
    chat.input_queue
        .pending_steers
        .push_back(pending_steer("queued steer"));
    chat.toggle_vim_mode_and_notify();
    chat.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));

    assert!(chat.should_handle_vim_insert_escape(esc));
    chat.handle_key_event(esc);

    assert!(!chat.should_handle_vim_insert_escape(esc));
    assert_eq!(chat.input_queue.pending_steers.len(), 1);
    assert!(!chat.input_queue.submit_pending_steers_after_interrupt);
    assert!(op_rx.try_recv().is_err());

    chat.handle_key_event(esc);

    match op_rx.try_recv() {
        Ok(Op::Interrupt { .. }) => {}
        other => panic!("expected Op::Interrupt, got {other:?}"),
    }
    assert!(chat.input_queue.submit_pending_steers_after_interrupt);
}

#[tokio::test]
async fn pending_steer_interrupt_uses_remapped_binding() {
    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    let mut keymap = crate::keymap::RuntimeKeymap::defaults();
    keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))];
    chat.chat_keymap = keymap.chat.clone();
    chat.bottom_pane.set_keymap_bindings(&keymap);
    chat.bottom_pane.set_task_running(/*running*/ true);
    chat.input_queue
        .pending_steers
        .push_back(pending_steer("queued steer"));

    chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));

    assert!(!chat.input_queue.submit_pending_steers_after_interrupt);
    assert!(op_rx.try_recv().is_err());

    chat.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE));

    match op_rx.try_recv() {
        Ok(Op::Interrupt { .. }) => {}
        other => panic!("expected Op::Interrupt, got {other:?}"),
    }
    assert!(chat.input_queue.submit_pending_steers_after_interrupt);
}

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

    chat.restore_thread_input_state(Some(ThreadInputState {
        composer: None,
        pending_steers: VecDeque::new(),
        pending_steer_history_records: VecDeque::new(),
        pending_steer_compare_keys: VecDeque::new(),
        rejected_steers_queue: VecDeque::new(),
        rejected_steer_history_records: VecDeque::new(),
        queued_user_messages: VecDeque::new(),
        queued_user_message_history_records: VecDeque::new(),
        user_turn_pending_start: false,
        current_collaboration_mode: chat.current_collaboration_mode.clone(),
        active_collaboration_mask: chat.active_collaboration_mask.clone(),
        task_running: true,
        agent_turn_running: true,
    }));

    assert!(chat.turn_lifecycle.agent_turn_running);
    assert!(chat.turn_lifecycle.sleep_inhibitor.is_turn_running());
    assert!(chat.bottom_pane.is_task_running());

    chat.restore_thread_input_state(/*input_state*/ None);

    assert!(!chat.turn_lifecycle.agent_turn_running);
    assert!(!chat.turn_lifecycle.sleep_inhibitor.is_turn_running());
    assert!(!chat.bottom_pane.is_task_running());
}

#[tokio::test]
async fn alt_up_edits_most_recent_queued_message() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    chat.chat_keymap.edit_queued_message = vec![crate::key_hint::alt(KeyCode::Up)];
    chat.queued_message_edit_hint_binding = Some(crate::key_hint::alt(KeyCode::Up));
    chat.bottom_pane
        .set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);

    // Simulate a running task so messages would normally be queued.
    chat.bottom_pane.set_task_running(/*running*/ true);

    // Seed two queued messages.
    chat.input_queue
        .queued_user_messages
        .push_back(UserMessage::from("first queued".to_string()).into());
    chat.input_queue
        .queued_user_messages
        .push_back(UserMessage::from("second queued".to_string()).into());
    chat.refresh_pending_input_preview();

    // Press Alt+Up to edit the most recent (last) queued message.
    chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));

    // Composer should now contain the last queued message.
    assert_eq!(
        chat.bottom_pane.composer_text(),
        "second queued".to_string()
    );
    // And the queue should now contain only the remaining (older) item.
    assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
    assert_eq!(
        chat.input_queue.queued_user_messages.front().unwrap().text,
        "first queued"
    );
}

#[tokio::test]
async fn unbound_queued_message_edit_does_not_fall_back_to_alt_up() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    chat.chat_keymap.edit_queued_message = Vec::new();
    chat.queued_message_edit_hint_binding = None;
    chat.bottom_pane
        .set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);
    chat.bottom_pane.set_task_running(/*running*/ true);
    chat.input_queue
        .queued_user_messages
        .push_back(UserMessage::from("queued".to_string()).into());
    chat.refresh_pending_input_preview();

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

    assert!(chat.bottom_pane.composer_text().is_empty());
    assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
}

#[tokio::test]
async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() {
    assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo {
        name: TerminalName::AppleTerminal,
        term_program: None,
        version: None,
        term: None,
        multiplexer: None,
    })
    .await;
}

#[tokio::test]
async fn shift_left_edits_most_recent_queued_message_in_warp_terminal() {
    assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo {
        name: TerminalName::WarpTerminal,
        term_program: None,
        version: None,
        term: None,
        multiplexer: None,
    })
    .await;
}

#[tokio::test]
async fn shift_left_edits_most_recent_queued_message_in_vscode_terminal() {
    assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo {
        name: TerminalName::VsCode,
        term_program: None,
        version: None,
        term: None,
        multiplexer: None,
    })
    .await;
}

#[tokio::test]
async fn shift_left_edits_most_recent_queued_message_in_tmux() {
    assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo {
        name: TerminalName::Iterm2,
        term_program: None,
        version: None,
        term: None,
        multiplexer: Some(Multiplexer::Tmux { version: None }),
    })
    .await;
}

#[test]
fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() {
    assert_eq!(
        queued_message_edit_binding_for_terminal(TerminalInfo {
            name: TerminalName::AppleTerminal,
            term_program: None,
            version: None,
            term: None,
            multiplexer: None,
        }),
        crate::key_hint::shift(KeyCode::Left)
    );
    assert_eq!(
        queued_message_edit_binding_for_terminal(TerminalInfo {
            name: TerminalName::WarpTerminal,
            term_program: None,
            version: None,
            term: None,
            multiplexer: None,
        }),
        crate::key_hint::shift(KeyCode::Left)
    );
    assert_eq!(
        queued_message_edit_binding_for_terminal(TerminalInfo {
            name: TerminalName::VsCode,
            term_program: None,
            version: None,
            term: None,
            multiplexer: None,
        }),
        crate::key_hint::shift(KeyCode::Left)
    );
    assert_eq!(
        queued_message_edit_binding_for_terminal(TerminalInfo {
            name: TerminalName::Iterm2,
            term_program: None,
            version: None,
            term: None,
            multiplexer: Some(Multiplexer::Tmux { version: None }),
        }),
        crate::key_hint::shift(KeyCode::Left)
    );
    assert_eq!(
        queued_message_edit_binding_for_terminal(TerminalInfo {
            name: TerminalName::Iterm2,
            term_program: None,
            version: None,
            term: None,
            multiplexer: None,
        }),
        crate::key_hint::alt(KeyCode::Up)
    );
}

/// Pressing Up to recall the most recent history entry and immediately queuing
/// it while a task is running should always enqueue the same text, even when it
/// is queued repeatedly.
#[tokio::test]
async fn enqueueing_history_prompt_multiple_times_is_stable() {
    let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    chat.thread_id = Some(ThreadId::new());

    // Submit an initial prompt to seed history.
    chat.bottom_pane
        .set_composer_text("repeat me".to_string(), Vec::new(), Vec::new());
    chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

    // Simulate an active task so further submissions are queued.
    chat.bottom_pane.set_task_running(/*running*/ true);

    for _ in 0..3 {
        // Recall the prompt from history and ensure it is what we expect.
        chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
        assert_eq!(chat.bottom_pane.composer_text(), "repeat me");

        // Queue the prompt while the task is running.
        chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
    }

    assert_eq!(chat.input_queue.queued_user_messages.len(), 3);
    for message in chat.input_queue.queued_user_messages.iter() {
        assert_eq!(message.text, "repeat me");
    }
}

#[tokio::test]
async fn submit_user_message_ignores_inaccessible_app_mentions_from_bindings() {
    let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    chat.thread_id = Some(ThreadId::new());
    set_chatgpt_auth(&mut chat);
    chat.config
        .features
        .enable(Feature::Apps)
        .expect("test config should allow feature update");

    chat.on_connectors_loaded(
        Ok(ConnectorsSnapshot {
            connectors: vec![AppInfo {
                id: "arabica_uae".to_string(),
                name: "% Arabica UAE".to_string(),
                description: Some("Directory-only app".to_string()),
                logo_url: None,
                logo_url_dark: None,
                distribution_channel: None,
                branding: None,
                app_metadata: None,
                labels: None,
                install_url: Some("https://example.test/arabica".to_string()),
                is_accessible: false,
                is_enabled: true,
                plugin_display_names: Vec::new(),
            }],
        }),
        /*is_final*/ false,
    );

    chat.submit_user_message(UserMessage {
        text: "$arabica-uae".to_string(),
        local_images: Vec::new(),
        remote_image_urls: Vec::new(),
        text_elements: Vec::new(),
        mention_bindings: vec![MentionBinding {
            sigil: '$',
            mention: "arabica-uae".to_string(),
            path: "app://arabica_uae".to_string(),
        }],
    });

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

#[test]
fn user_message_display_from_inputs_matches_flattened_user_message_shape() {
    let local_image = PathBuf::from("/tmp/local.png");
    let rendered = ChatWidget::user_message_display_from_inputs(&[
        UserInput::Text {
            text: "hello ".to_string(),
            text_elements: vec![TextElement::new((0..5).into(), /*placeholder*/ None).into()],
        },
        UserInput::Image {
            url: "https://example.com/remote.png".to_string(),
            detail: None,
        },
        UserInput::LocalImage {
            path: local_image.clone(),
            detail: None,
        },
        UserInput::Skill {
            name: "demo".to_string(),
            path: PathBuf::from("/tmp/skill/SKILL.md"),
        },
        UserInput::Mention {
            name: "repo".to_string(),
            path: "app://repo".to_string(),
        },
        UserInput::Text {
            text: "world".to_string(),
            text_elements: vec![TextElement::new((0..5).into(), Some("planet".to_string())).into()],
        },
    ]);

    assert_eq!(
        rendered,
        ChatWidget::user_message_display_from_parts(
            "hello world".to_string(),
            vec![
                TextElement::new((0..5).into(), Some("hello".to_string())),
                TextElement::new((6..11).into(), Some("planet".to_string())),
            ],
            vec![local_image],
            vec!["https://example.com/remote.png".to_string()],
        )
    );
}

#[test]
fn user_message_display_from_inputs_hides_prompt_context() {
    let raw_message = "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\nAsk $figma";
    let mention_start = raw_message.find("$figma").expect("mention in raw message");
    let rendered = ChatWidget::user_message_display_from_inputs(&[UserInput::Text {
        text: raw_message.to_string(),
        text_elements: vec![
            TextElement::new(
                (mention_start..mention_start + "$figma".len()).into(),
                Some("$figma".to_string()),
            )
            .into(),
        ],
    }]);

    assert_eq!(
        rendered,
        ChatWidget::user_message_display_from_parts(
            "Ask $figma".to_string(),
            vec![TextElement::new((4..10).into(), Some("$figma".to_string()))],
            Vec::new(),
            Vec::new(),
        )
    );
}

#[tokio::test]
async fn committed_user_message_with_hidden_prompt_context_renders_local_images() {
    let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
    let local_image = PathBuf::from("/tmp/context-image.png");
    let raw_message =
        "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\n";

    complete_user_message_for_inputs(
        &mut chat,
        "user-1",
        vec![
            UserInput::Text {
                text: raw_message.to_string(),
                text_elements: Vec::new(),
            },
            UserInput::LocalImage {
                path: local_image.clone(),
                detail: None,
            },
        ],
    );

    let mut user_cell = None;
    while let Ok(event) = rx.try_recv() {
        if let AppEvent::InsertHistoryCell(cell) = event
            && let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>()
        {
            user_cell = Some((cell.message.clone(), cell.local_image_paths.clone()));
            break;
        }
    }

    let (message, local_images) = user_cell.expect("expected user history cell");
    assert_eq!(message, "");
    assert_eq!(local_images, vec![local_image]);
}

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

    // Simulate a running task to enable queuing of user inputs.
    chat.bottom_pane.set_task_running(/*running*/ true);

    // Queue two user messages while the task is running.
    chat.input_queue
        .queued_user_messages
        .push_back(UserMessage::from("first queued".to_string()).into());
    chat.input_queue
        .queued_user_messages
        .push_back(UserMessage::from("second queued".to_string()).into());
    chat.refresh_pending_input_preview();

    // Deliver an interrupted turn notification as if Esc was pressed.
    handle_turn_interrupted(&mut chat, "turn-1");

    // Composer should now contain the queued messages joined by newlines, in order.
    assert_eq!(
        chat.bottom_pane.composer_text(),
        "first queued\nsecond queued"
    );

    // Queue should be cleared and no new user input should have been auto-submitted.
    assert!(chat.input_queue.queued_user_messages.is_empty());
    assert!(
        op_rx.try_recv().is_err(),
        "unexpected outbound op after interrupt"
    );

    // Drain rx to avoid unused warnings.
    let _ = drain_insert_history(&mut rx);
}

#[tokio::test]
async fn interrupt_prepends_queued_messages_before_existing_composer_text() {
    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("current draft".to_string(), Vec::new(), Vec::new());

    chat.input_queue
        .queued_user_messages
        .push_back(UserMessage::from("first queued".to_string()).into());
    chat.input_queue
        .queued_user_messages
        .push_back(UserMessage::from("second queued".to_string()).into());
    chat.refresh_pending_input_preview();

    handle_turn_interrupted(&mut chat, "turn-1");

    assert_eq!(
        chat.bottom_pane.composer_text(),
        "first queued\nsecond queued\ncurrent draft"
    );
    assert!(chat.input_queue.queued_user_messages.is_empty());
    assert!(
        op_rx.try_recv().is_err(),
        "unexpected outbound op after interrupt"
    );

    let _ = drain_insert_history(&mut rx);
}
