//! Paste routing: bracketed paste, clipboard attachment probe, dropped
//! paths, image paste, and the deferred send-after-paste flow.
#[cfg(test)]
use super::{ActivePane, AgentViewLayout, PromptInputMode, render_dropdown_chrome};
use super::{AgentDeferredSend, AgentView};
use crate::actions::ActionRegistry;
use crate::app::actions::Action;
use crate::app::app_view::InputOutcome;
#[cfg(test)]
use crate::key;
#[cfg(test)]
use crate::theme::Theme;
use crate::views::prompt_widget::{PromptEvent, PromptWidget};
#[cfg(test)]
use crossterm::event::{Event, KeyEvent};
impl AgentView {
    /// Insert a plain-text (caption) clipboard paste into the prompt, matching
    /// the bracketed arm's whitespace policy + slash/suggestion refresh. The
    /// image/file-url portion of a paste is handled by the deferred probe.
    fn insert_prompt_plain_text(
        &mut self,
        clipboard_text: Option<&str>,
    ) -> (InputOutcome, crate::app::actions::ClipboardTextInsertion) {
        self.insert_prompt_text(clipboard_text, false)
    }
    pub(super) fn insert_bracketed_prompt_text(
        &mut self,
        text: &str,
    ) -> (InputOutcome, crate::app::actions::ClipboardTextInsertion) {
        self.insert_prompt_text(Some(text), true)
    }
    fn insert_prompt_text(
        &mut self,
        clipboard_text: Option<&str>,
        activate_bash: bool,
    ) -> (InputOutcome, crate::app::actions::ClipboardTextInsertion) {
        use crate::app::actions::ClipboardTextInsertion;
        let Some(text) = clipboard_text else {
            return (InputOutcome::Changed, ClipboardTextInsertion::Empty);
        };
        if text.trim().is_empty() {
            return (InputOutcome::Unchanged, ClipboardTextInsertion::Empty);
        }
        let paste_text = if activate_bash
            && self.prompt_input_mode == super::PromptInputMode::Normal
            && self.prompt.text().is_empty()
        {
            if let Some(command) = text.strip_prefix("! ") {
                self.prompt_input_mode = super::PromptInputMode::Bash;
                command
            } else {
                text
            }
        } else {
            text
        };
        match self.prompt.handle_paste(paste_text) {
            PromptEvent::Edited => {
                self.prompt.refresh_slash(&self.session.models);
                if let Some(eff) = self.notify_suggestion_text_changed() {
                    self.pending_effects.push(eff);
                }
                (InputOutcome::Changed, ClipboardTextInsertion::Inserted)
            }
            PromptEvent::Ignored => (InputOutcome::Changed, ClipboardTextInsertion::Failed),
        }
    }
    fn reject_shared_queue_image_edit(
        &mut self,
        pasted: &crate::prompt_images::PastedImage,
    ) -> bool {
        if !matches!(
            self.prompt_mode,
            crate::app::queue_edit::PromptMode::EditingQueued {
                server_id: Some(_),
                ..
            }
        ) {
            return false;
        }
        crate::prompt_images::cleanup_temp_file(pasted);
        self.show_toast("Images can't be attached when editing a shared queued prompt");
        true
    }
    /// Enqueue attachment probing off-thread so paste-then-send remains ordered.
    pub(super) fn enqueue_clipboard_attachment_probe(
        &mut self,
        source: crate::app::actions::ClipboardPasteSource,
        change_count: Option<u64>,
    ) {
        let images_dir = crate::prompt_images::session_images_dir(
            self.session.session_id.as_ref(),
            &self.session.cwd,
        );
        self.paste_probe_in_flight += 1;
        self.pending_effects
            .push(crate::app::actions::Effect::ProbeClipboardAttachment {
                ctx: crate::app::actions::ClipboardPasteContext {
                    target: crate::app::actions::ClipboardPasteTarget::AgentPrompt {
                        agent_id: self.session.id,
                        images_dir,
                    },
                    source,
                },
                change_count,
            });
    }
    /// Ctrl/Cmd+V paste: a file path in the text resolves synchronously and
    /// wins; else the clipboard raster/file-url probe defers off the event loop
    /// (image wins over the caption, inserted on completion only if no image);
    /// else plain text with no raster inserts synchronously.
    pub(super) fn handle_paste_key_deferred(
        &mut self,
        clipboard_text: crate::app::actions::ClipboardTextRead,
    ) -> InputOutcome {
        let tip_showing = self.ephemeral_tip.current_key()
            == Some(crate::tips::clipboard_focus::CLIPBOARD_IMAGE_TIP_KEY);
        self.ephemeral_tip
            .clear(crate::tips::clipboard_focus::CLIPBOARD_IMAGE_TIP_KEY);
        if let Some(text) = clipboard_text.as_deref()
            && let Some((outcome, _)) = self.try_handle_dropped_paths_paste(text)
        {
            return outcome;
        }
        if let Some(change_count) =
            crate::clipboard::attachment_probe_gate(clipboard_text.as_deref())
        {
            self.enqueue_clipboard_attachment_probe(
                crate::app::actions::ClipboardPasteSource::ClipboardKey {
                    text: clipboard_text,
                    tip_showing,
                },
                change_count,
            );
            return InputOutcome::Changed;
        }
        self.insert_prompt_plain_text(clipboard_text.as_deref()).0
    }
    /// Attach the result of a deferred clipboard attachment probe
    /// ([`Effect::ProbeClipboardAttachment`]). The heavy read/decode/persist
    /// already ran off-thread; this only mutates prompt state on the event loop.
    pub(crate) fn complete_clipboard_attachment_paste(
        &mut self,
        ctx: crate::app::actions::ClipboardPasteContext,
        image: crate::app::actions::ProbedAttachment,
        file_urls: Option<String>,
    ) -> crate::app::actions::ClipboardPasteCompletion {
        use crate::app::actions::{
            ClipboardPasteCompletion, ClipboardPasteFailure, ProbedAttachment,
        };
        self.paste_probe_in_flight = self.paste_probe_in_flight.saturating_sub(1);
        let insert_deferred_text = matches!(
            &image,
            ProbedAttachment::NoRaster
                | ProbedAttachment::ProbeDropped
                | ProbedAttachment::ProbeFailed
        );
        let attachment = match image {
            ProbedAttachment::Image(pasted) => {
                if self.reject_shared_queue_image_edit(&pasted) {
                    return ClipboardPasteCompletion::Failed(
                        ClipboardPasteFailure::AlreadyReported,
                    );
                }
                let preparation = pasted.preview_preparation();
                if let Err(msg) = self.prompt.insert_image(pasted) {
                    self.show_toast_ticks(&msg, 150);
                    ClipboardPasteCompletion::Failed(ClipboardPasteFailure::AlreadyReported)
                } else {
                    if let Some(preparation) = preparation {
                        self.pending_effects.push(
                            crate::app::actions::Effect::PreparePromptImagePreview { preparation },
                        );
                    }
                    ctx.source.tip_showing();
                    self.prompt.refresh_slash(&self.session.models);
                    ClipboardPasteCompletion::Handled
                }
            }
            ProbedAttachment::PersistFailed(_) => {
                self.show_toast("Couldn't save pasted image");
                ClipboardPasteCompletion::Failed(ClipboardPasteFailure::AlreadyReported)
            }
            ProbedAttachment::NoRaster => ClipboardPasteCompletion::FullMiss,
            ProbedAttachment::ProbeDropped => ClipboardPasteCompletion::Dropped,
            ProbedAttachment::ProbeFailed => {
                ClipboardPasteCompletion::Failed(ClipboardPasteFailure::AttachmentRead)
            }
        };
        let file = if attachment == ClipboardPasteCompletion::FullMiss {
            file_urls.as_deref().and_then(|urls| {
                self.try_handle_dropped_paths_paste(urls)
                    .map(|(_, completion)| completion)
            })
        } else {
            None
        };
        let text = if insert_deferred_text {
            ctx.source
                .text_to_insert_on_miss()
                .filter(|text| !text.trim().is_empty())
                .map(|text| self.insert_prompt_plain_text(Some(text)).1)
        } else {
            None
        };
        let completion = crate::app::actions::reduce_clipboard_paste_completion(
            &ctx.source,
            attachment,
            file,
            text,
        );
        if completion == ClipboardPasteCompletion::FullMiss && ctx.source.is_clipboard_key() {
            crate::clipboard::log_paste_key_empty_host_clipboard(ctx.target.surface_str());
        }
        completion
    }
    /// After a deferred paste probe completes, take the kind of any send
    /// stashed while the probe(s) were in flight. Returns `None` while probes
    /// remain in flight or nothing is stashed. The stash is always cleared; the
    /// caller builds the action (via [`Self::build_deferred_send_action`]) only
    /// when it actually reissues, so a dropped reissue keeps the draft intact.
    pub(crate) fn take_deferred_send_after_paste(&mut self) -> Option<AgentDeferredSend> {
        if self.paste_probe_in_flight != 0 {
            return None;
        }
        self.deferred_send.take()
    }
    /// Build the reissue action for a drained stash, re-deriving the payload
    /// from the now-updated prompt so the freshly attached image chip (and its
    /// aligned range) travels with it. Call only when actually reissuing — the
    /// interject variant consumes the draft (drain images + clear) exactly like
    /// the `InterjectPrompt` arm it was stashed from.
    pub(crate) fn build_deferred_send_action(&mut self, kind: AgentDeferredSend) -> Option<Action> {
        match kind {
            AgentDeferredSend::SendPrompt => {
                let text = self.prompt.text().to_string();
                (!text.trim().is_empty()).then_some(Action::SendPrompt(text))
            }
            AgentDeferredSend::Interject => {
                let text = self.prompt.text().trim().to_string();
                if !ActionRegistry::interjection_possible(
                    self.session.state.is_turn_running(),
                    !text.is_empty(),
                ) {
                    return None;
                }
                let images = self.prompt.drain_images();
                self.prompt.set_text("");
                Some(Action::SendPromptNow { text, images })
            }
        }
    }
    /// Consume wrap host-image magic paste (`Some` = handled, never as text).
    pub(super) fn try_handle_wrap_host_image_paste(&mut self, text: &str) -> Option<InputOutcome> {
        let wrap = crate::wrap_clipboard_image::try_decode_wrap_host_image_paste(text)?;
        Some(match wrap {
            crate::wrap_clipboard_image::WrapImagePaste::Image(data) => {
                let pasted = crate::prompt_images::from_clipboard_data(&data);
                let _ = self.handle_image_paste_from_data(pasted);
                self.prompt.refresh_slash(&self.session.models);
                InputOutcome::Changed
            }
            crate::wrap_clipboard_image::WrapImagePaste::NoImage => InputOutcome::Unchanged,
        })
    }
    /// Parse a paste payload as one or more drop-style file paths and
    /// route each entry: image paths become `[Image #N]` chips, non-image
    /// paths get inserted as decoded absolute path text.
    ///
    /// Route a popup pane's `Event::Paste(text)` through the drop
    /// classifier and fall back to a plain text paste into the shared
    /// prompt buffer. Used by the plan-feedback, permission-followup,
    /// plan-approval, and question-view paste arms — all of which share
    /// the same prompt widget as the main Prompt pane and need identical
    /// classifier semantics.
    pub(super) fn route_popup_paste(&mut self, text: &str) -> InputOutcome {
        if let Some((outcome, _)) = self.try_handle_dropped_paths_paste(text) {
            return outcome;
        }
        let _ = self.prompt.handle_paste(text);
        InputOutcome::Changed
    }
    /// Returns redraw and completion outcomes only when at least one path resolves.
    ///
    /// This is the canonical drag-and-drop / Finder-paste classifier on the
    /// main Prompt pane. It must run BEFORE any clipboard image probe so the
    /// Finder icon attached to a non-image file path doesn't get rendered
    /// as a chip. Other `Event::Paste` arms (permission followup, question
    /// view, plan feedback, plan approval) route through here too.
    ///
    /// `refresh_slash` fires exactly once at the end of the loop when any
    /// entry was inserted. `notify_suggestion_text_changed` fires only when
    /// at least one non-image path was inserted — `[Image #N]` placeholder
    /// text doesn't influence @-mention / file-search completions.
    ///
    /// Entries are processed in **source-token order**:
    /// `"file://{png} file://{txt}"` → `[Image #N] {canon_txt} `;
    /// `"file://{txt} file://{png}"` → `{canon_txt} [Image #N] `.
    ///
    /// **Size guard**: payloads ≥ `DROP_CLASSIFIER_MAX_BYTES`
    /// short-circuit to `None`. The early-return lives inside this
    /// function (not at each call site) so every paste arm — the
    /// main Prompt bracketed-paste arm, the four popup `Event::Paste`
    /// arms (plan-feedback, permission-followup, plan-approval,
    /// question-view), and the Cmd+V `handle_paste_key_deferred` path
    /// (clipboard-text, plus deferred file-urls on completion) — gets the
    /// guard uniformly. Real drag-and-drop payloads (one or more
    /// `file://` URLs) are at most a few KB; anything ≥ 10 MB is a
    /// log/code paste and not worth iterating line-by-line.
    pub(super) fn try_handle_dropped_paths_paste(
        &mut self,
        text: &str,
    ) -> Option<(InputOutcome, crate::app::actions::ClipboardPasteCompletion)> {
        if crate::terminal::terminal_context().is_ssh {
            return None;
        }
        /// Upper bound on the size of a paste payload the drop
        /// classifier will scan. 10 MB matches `MAX_SEND_BYTES` for
        /// individual image attachments — well above any realistic
        /// drop, well below any log/code paste worth iterating.
        const DROP_CLASSIFIER_MAX_BYTES: usize = 10 * 1024 * 1024;
        if text.len() >= DROP_CLASSIFIER_MAX_BYTES {
            return None;
        }
        let dropped = crate::prompt_images::try_read_dropped_paths(text);
        if dropped.is_empty() {
            return None;
        }
        let mut group_open = false;
        let mut inserted_image = false;
        let mut inserted_non_image = false;
        let mut image_cap_reached = false;
        for entry in dropped {
            match entry {
                crate::prompt_images::DroppedPath::Image(img) => {
                    if image_cap_reached {
                        continue;
                    }
                    if self.prompt.images.len() >= PromptWidget::IMAGE_CAP {
                        image_cap_reached = true;
                        self.show_toast(&PromptWidget::cap_reached_toast());
                        continue;
                    }
                    if !group_open {
                        self.prompt.textarea.begin_undo_group();
                        group_open = true;
                    }
                    if self.handle_image_paste_from_data(img) {
                        inserted_image = true;
                    }
                }
                crate::prompt_images::DroppedPath::NonImage(path) => {
                    let to_insert = format!("{} ", path.display());
                    if !group_open {
                        self.prompt.textarea.begin_undo_group();
                        group_open = true;
                    }
                    if matches!(self.prompt.handle_paste(&to_insert), PromptEvent::Edited) {
                        inserted_non_image = true;
                    }
                }
            }
        }
        if group_open {
            self.prompt.textarea.end_undo_group();
        }
        if inserted_image || inserted_non_image {
            self.prompt.refresh_slash(&self.session.models);
        }
        if inserted_non_image && let Some(eff) = self.notify_suggestion_text_changed() {
            self.pending_effects.push(eff);
        }
        let completion = if inserted_image || inserted_non_image {
            crate::app::actions::ClipboardPasteCompletion::Handled
        } else {
            crate::app::actions::ClipboardPasteCompletion::Failed(
                crate::app::actions::ClipboardPasteFailure::AlreadyReported,
            )
        };
        Some((InputOutcome::Changed, completion))
    }
    /// Persist a `PastedImage` to the session directory and insert it as an
    /// `[Image #N]` chip in the prompt.
    ///
    /// **Does NOT call `refresh_slash`.** Callers are expected to do
    /// that once per logical paste event (at the loop boundary for
    /// the drag-and-drop classifier, immediately after-return for
    /// single-image callers). Centralising the refresh at the caller
    /// boundary avoids the (N+1)-call multiplicity that would otherwise
    /// arise on a mixed N-image-plus-1-non-image drop.
    ///
    /// Returns `true` when the buffer was actually mutated (chip
    /// inserted), `false` when persistence or the cap rejected the
    /// insert. Callers that wrap an undo group around a batch of
    /// inserts use this to defer opening the group until at least one
    /// mutation lands (avoids an empty undo step on Ctrl-Z).
    fn handle_image_paste_from_data(
        &mut self,
        mut pasted: crate::prompt_images::PastedImage,
    ) -> bool {
        if self.reject_shared_queue_image_edit(&pasted) {
            return false;
        }
        let preparation = pasted.preview_preparation();
        if let Some(images_dir) = crate::prompt_images::session_images_dir(
            self.session.session_id.as_ref(),
            &self.session.cwd,
        ) && let Err(e) = crate::prompt_images::persist_to_session(&mut pasted, &images_dir)
        {
            tracing::warn!("failed to persist pasted image: {e}");
            self.show_toast("Couldn't save pasted image");
            return false;
        }
        if let Err(msg) = self.prompt.insert_image(pasted) {
            self.show_toast_ticks(&msg, 150);
            return false;
        }
        if let Some(preparation) = preparation {
            self.pending_effects
                .push(crate::app::actions::Effect::PreparePromptImagePreview { preparation });
        }
        true
    }
}
#[cfg(test)]
pub(super) mod paste_key_tests {
    use super::*;
    use crate::acp::model_state::ModelState;
    use crate::app::agent::{AgentId, AgentSession, AgentState};
    use crate::app::app_view::InputOutcome;
    use crate::clipboard::ImageData;
    use crate::scrollback::state::ScrollbackState;
    use crate::views::prompt_widget::KIND_PASTE;
    fn make_agent() -> AgentView {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        AgentView::new(
            AgentSession {
                id: AgentId(0),
                acp_tx: tx,
                session_id: None,
                models: ModelState::default(),
                state: AgentState::Idle,
                tracker: crate::acp::tracker::AcpUpdateTracker::new(),
                cwd: std::path::PathBuf::from("/tmp"),
                is_worktree: false,
                forked_from: None,
                pending_prompts: std::collections::VecDeque::new(),
                next_queue_id: 0,
                yolo_mode: false,
                auto_mode: false,
                prompt_history: Vec::new(),
                prompt_history_loading: false,
                loading_replay: false,
                restore_degree: None,
                rate_limited: false,
                model_incompatible: false,
                available_commands: Vec::new(),
                available_commands_generation: 0,
                available_tools: None,
                model_switch_pending: false,
                user_model_preference: None,
                deferred_model_switch: None,
                bg_tasks: std::collections::BTreeMap::new(),
                bg_tool_call_to_task: std::collections::HashMap::new(),
                scheduled_tasks: std::collections::HashMap::new(),
                in_flight_prompt: None,
                current_prompt_id: None,
                created_via_new: false,
            },
            ScrollbackState::new(),
        )
    }
    /// Minimal PNG header (not a valid image, but enough for `from_clipboard_data`
    /// which only copies bytes and MIME type without decoding).
    fn test_image_data() -> ImageData {
        ImageData {
            data: vec![
                0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0, 0, 0, 0, 0,
            ],
            mime_type: "image/png".to_string(),
        }
    }
    #[test]
    fn wrap_host_image_none_paste_not_inserted_as_text() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let registry = ActionRegistry::defaults();
        let outcome = agent.handle_input(
            &Event::Paste(crate::wrap_clipboard_image::MAGIC_NONE.to_string()),
            &registry,
        );
        assert!(matches!(outcome, InputOutcome::Unchanged));
        assert!(agent.prompt.text().is_empty());
    }
    #[test]
    fn wrap_host_image_malformed_paste_not_inserted_as_text() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let registry = ActionRegistry::defaults();
        let outcome = agent.handle_input(
            &Event::Paste("KIGI_WRAP_IMG\nimage/png\n!!!".to_string()),
            &registry,
        );
        assert!(matches!(outcome, InputOutcome::Unchanged));
        assert!(agent.prompt.text().is_empty());
    }
    #[test]
    fn paste_key_text_inserts_into_prompt() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let outcome = paste_cmd_v(&mut agent, Some("hello world"));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), "hello world");
    }
    #[test]
    fn paste_key_multiline_text_creates_element() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let text = "line1\nline2\nline3\nline4";
        let outcome = paste_cmd_v(&mut agent, Some(text));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), text);
        assert_eq!(agent.prompt.textarea().elements().len(), 1);
        assert_eq!(agent.prompt.textarea().elements()[0].kind, KIND_PASTE);
    }
    #[test]
    fn paste_key_image_preferred_over_text() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        paste_cmd_v_image(&mut agent, Some("text content"));
        assert_eq!(agent.prompt.images.len(), 1);
        assert!(agent.prompt.text().contains("[Image #1]"));
        assert!(!agent.prompt.text().contains("text content"));
    }
    #[test]
    fn paste_key_image_when_no_text() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        paste_cmd_v_image(&mut agent, None);
        assert_eq!(agent.prompt.images.len(), 1);
        assert!(agent.prompt.text().contains("[Image #1]"));
    }
    #[test]
    fn paste_key_image_when_text_is_empty_string() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        paste_cmd_v_image(&mut agent, Some(""));
        assert_eq!(agent.prompt.images.len(), 1);
    }
    #[test]
    fn paste_key_image_when_text_is_whitespace() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        paste_cmd_v_image(&mut agent, Some("   \n  "));
        assert_eq!(agent.prompt.images.len(), 1);
    }
    #[test]
    fn paste_key_empty_clipboard_consumes_key() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let outcome = paste_cmd_v(&mut agent, None);
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(agent.prompt.text().is_empty());
        assert!(agent.prompt.images.is_empty());
    }
    /// Whitespace-only Cmd+V inserts no text. Trimmed-empty routes to the
    /// FileUrlsThenImage probe (to catch an image-only pasteboard), so it defers
    /// off the event loop rather than inserting spaces; the completion drops the
    /// whitespace caption (a no-image miss with blank text inserts nothing).
    #[test]
    fn paste_key_whitespace_only_text_with_no_image_or_urls_is_noop() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let _ = paste_cmd_v(&mut agent, Some("   "));
        assert!(agent.prompt.text().is_empty());
        let _ = paste_cmd_v(&mut agent, Some("\t\n  \t"));
        assert!(agent.prompt.text().is_empty());
    }
    #[test]
    fn paste_key_appends_to_existing_text() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        agent.prompt.textarea.insert_str("existing ");
        let outcome = paste_cmd_v(&mut agent, Some("pasted"));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), "existing pasted");
    }
    #[test]
    fn paste_key_image_with_existing_text_in_prompt() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        agent.prompt.textarea.insert_str("describe: ");
        paste_cmd_v_image(&mut agent, None);
        assert_eq!(agent.prompt.images.len(), 1);
        assert!(agent.prompt.text().contains("describe: "));
        assert!(agent.prompt.text().contains("[Image #1]"));
    }
    /// A single-newline paste is whitespace-only and inserts no text (trimmed
    /// empty → deferred probe, whitespace caption dropped on the miss).
    #[test]
    fn paste_key_single_newline_text_is_noop() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let _ = paste_cmd_v(&mut agent, Some("\n"));
        assert!(agent.prompt.text().is_empty());
    }
    #[test]
    fn paste_key_cr_normalized_to_lf() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let outcome = paste_cmd_v(&mut agent, Some("a\rb\rc"));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.text(), "a\nb\nc");
    }
    #[test]
    fn paste_key_tabs_expanded_to_spaces() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let outcome = paste_cmd_v(&mut agent, Some("if true:\n\tpass"));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(!agent.prompt.text().contains('\t'));
        assert_eq!(agent.prompt.text(), "if true:\n    pass");
    }
    /// Empty-string clipboard text is whitespace-only: no text is inserted
    /// (trimmed-empty → deferred probe, blank caption dropped on the miss).
    #[test]
    fn paste_key_empty_string_text_no_image_is_noop() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let _ = paste_cmd_v(&mut agent, Some(""));
        assert!(agent.prompt.text().is_empty());
    }
    #[test]
    fn paste_key_image_path_detected_as_image() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let dir = tempfile::tempdir().unwrap();
        let png_path = dir.path().join("test.png");
        let png = make_test_png(10, 10);
        std::fs::write(&png_path, &png).unwrap();
        let path_str = png_path.to_string_lossy().to_string();
        let outcome = paste_cmd_v(&mut agent, Some(&path_str));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.images.len(), 1);
        assert!(agent.prompt.text().contains("[Image #"));
        assert!(agent.prompt.images[0].preview.is_pending());
        assert!(agent.pending_effects.iter().any(|effect| matches!(
            effect,
            crate::app::actions::Effect::PreparePromptImagePreview { .. }
        )));
    }
    #[test]
    fn paste_key_tiny_image_path_cannot_insert_or_send_immediately() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("tiny.png");
        std::fs::write(&path, make_test_png(1, 1)).unwrap();
        let outcome = paste_cmd_v(&mut agent, Some(&path.display().to_string()));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(agent.prompt.images.is_empty());
        assert!(!agent.prompt.text().contains("[Image #"));
        assert!(!agent.pending_effects.iter().any(|effect| matches!(
            effect,
            crate::app::actions::Effect::PreparePromptImagePreview { .. }
        )));
        let enter = agent.handle_prompt_key_for_test(&KeyEvent::new(
            crossterm::event::KeyCode::Enter,
            crossterm::event::KeyModifiers::NONE,
        ));
        assert!(!matches!(
            enter,
            InputOutcome::Action(crate::app::actions::Action::SendPrompt(_))
        ));
    }
    #[test]
    fn paste_key_non_image_path_pasted_as_text() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let outcome = paste_cmd_v(&mut agent, Some("/tmp/not-an-image.txt"));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(agent.prompt.images.is_empty());
        assert_eq!(agent.prompt.text(), "/tmp/not-an-image.txt");
    }
    #[test]
    fn paste_key_non_image_file_url_with_clipboard_icon_uses_path_not_icon() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let dir = tempfile::tempdir().unwrap();
        let txt = dir.path().join("notes.txt");
        std::fs::write(&txt, b"hello").unwrap();
        let url = format!("file://{}", txt.display());
        let outcome = paste_cmd_v(&mut agent, Some(&url));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(
            agent.prompt.images.is_empty(),
            "must NOT insert image chip for non-image file; got images: {:?}",
            agent.prompt.images.len()
        );
        assert!(
            !agent.prompt.text().contains("[Image #"),
            "no chip placeholder expected for non-image drop; got {:?}",
            agent.prompt.text()
        );
        let canon_txt = dunce::canonicalize(&txt).unwrap();
        let want_with_trailing_space = format!("{} ", canon_txt.display());
        assert!(
            agent.prompt.text().contains(&want_with_trailing_space),
            "decoded path + trailing space must be inserted; got {:?}, want substring {:?}",
            agent.prompt.text(),
            want_with_trailing_space
        );
    }
    /// Bug A regression: when pbpaste returns `None` (the
    /// `public.utf8-plain-text` representation is absent on the macOS clipboard)
    /// but the deferred probe recovers `public.file-url`, the completion routes
    /// the non-image file to decoded path text (not an `[Image #N]` chip). The
    /// probe's `FileUrlsThenImage` route suppresses the Finder file-icon raster
    /// off-thread, so the completion sees no image.
    #[test]
    fn paste_key_file_urls_probe_recovers_when_text_is_none() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let dir = tempfile::tempdir().unwrap();
        let txt = dir.path().join("only_furl.txt");
        std::fs::write(&txt, b"hi").unwrap();
        let url = format!("file://{}", txt.display());
        let ctx = agent_completion_ctx(&agent, None);
        let completion = agent.complete_clipboard_attachment_paste(
            ctx,
            crate::app::actions::ProbedAttachment::NoRaster,
            Some(url),
        );
        assert_eq!(
            completion,
            crate::app::actions::ClipboardPasteCompletion::Handled
        );
        assert!(
            agent.prompt.images.is_empty(),
            "file-url path must beat the icon image; got images: {:?}",
            agent.prompt.images.len()
        );
        assert!(
            !agent.prompt.text().contains("[Image #"),
            "no chip placeholder expected; got {:?}",
            agent.prompt.text()
        );
        let canon_txt = dunce::canonicalize(&txt).unwrap();
        let want_with_trailing_space = format!("{} ", canon_txt.display());
        assert!(
            agent.prompt.text().contains(&want_with_trailing_space),
            "decoded path + trailing space must be inserted; got {:?}, want substring {:?}",
            agent.prompt.text(),
            want_with_trailing_space
        );
    }
    /// Bug A regression (sibling of the `None`-text case): macOS `pbpaste`
    /// returns `Some("")` rather than `None` in some configurations. The
    /// deferred file-url recovery must still route the path on completion.
    #[test]
    fn paste_key_file_urls_probe_recovers_when_text_is_empty_string() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let dir = tempfile::tempdir().unwrap();
        let txt = dir.path().join("empty_text_furl.txt");
        std::fs::write(&txt, b"hi").unwrap();
        let url = format!("file://{}", txt.display());
        let ctx = agent_completion_ctx(&agent, Some(""));
        agent.complete_clipboard_attachment_paste(
            ctx,
            crate::app::actions::ProbedAttachment::NoRaster,
            Some(url),
        );
        assert!(
            agent.prompt.images.is_empty(),
            "file-url path must beat the icon image even when text is \"\"",
        );
        let canon_txt = dunce::canonicalize(&txt).unwrap();
        let want_with_trailing_space = format!("{} ", canon_txt.display());
        assert!(
            agent.prompt.text().contains(&want_with_trailing_space),
            "decoded path + trailing space must be inserted; got {:?}",
            agent.prompt.text(),
        );
    }
    /// Bug A multi-file integration: the macOS pasteboard's `public.file-url`
    /// type carries N newline-joined POSIX paths. The completion must (a) route
    /// the PNG entry to a chip and (b) route the non-image entry to decoded path
    /// text (the Finder file-icon raster is suppressed by the off-thread probe).
    #[test]
    fn paste_key_file_urls_probe_handles_multi_file_payload() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let dir = tempfile::tempdir().unwrap();
        let png = dir.path().join("snap.png");
        let txt = dir.path().join("notes.txt");
        std::fs::write(&png, make_test_png(8, 8)).unwrap();
        std::fs::write(&txt, b"x").unwrap();
        let furl_payload = format!("{}\n{}", png.display(), txt.display());
        let ctx = agent_completion_ctx(&agent, None);
        agent.complete_clipboard_attachment_paste(
            ctx,
            crate::app::actions::ProbedAttachment::NoRaster,
            Some(furl_payload),
        );
        assert_eq!(
            agent.prompt.images.len(),
            1,
            "exactly one chip for the real PNG — not the Finder icon",
        );
        assert!(
            agent.prompt.text().contains("[Image #1]"),
            "PNG chip placeholder must be present; prompt = {:?}",
            agent.prompt.text(),
        );
        let canon_txt = dunce::canonicalize(&txt).unwrap();
        let want_with_trailing_space = format!("{} ", canon_txt.display());
        assert!(
            agent.prompt.text().contains(&want_with_trailing_space),
            "non-image path + trailing space must be inserted; prompt = {:?}",
            agent.prompt.text(),
        );
    }
    /// Bug A regression: when the pbpaste text already classifies as drop paths,
    /// the text-path resolver wins synchronously and NO probe is deferred (so the
    /// off-thread file-url recovery never runs and can't insert a rival path).
    #[test]
    fn paste_key_file_urls_probe_not_double_inserted_when_text_classifies() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let dir = tempfile::tempdir().unwrap();
        let primary = dir.path().join("notes.txt");
        std::fs::write(&primary, b"primary").unwrap();
        let primary_url = format!("file://{}", primary.display());
        let outcome = paste_cmd_v(&mut agent, Some(&primary_url));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(
            agent.prompt.images.is_empty(),
            "image must lose to classifying text; got images: {}",
            agent.prompt.images.len(),
        );
        assert!(
            deferred_probe_target(&agent).is_none(),
            "a classifying text paste must not also defer a probe"
        );
        let canon_primary = dunce::canonicalize(&primary).unwrap();
        let want_primary = format!("{} ", canon_primary.display());
        assert!(
            agent.prompt.text().contains(&want_primary),
            "primary text path must be inserted; prompt = {:?}",
            agent.prompt.text(),
        );
    }
    #[test]
    fn paste_key_non_image_file_url_percent_encoded_space_round_trips() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let dir = tempfile::tempdir().unwrap();
        let sub = dir.path().join("My Documents");
        std::fs::create_dir_all(&sub).unwrap();
        let txt = sub.join("readme report.md");
        std::fs::write(&txt, b"x").unwrap();
        let encoded = txt.display().to_string().replace(' ', "%20");
        let url = format!("file://{}", encoded);
        let outcome = paste_cmd_v(&mut agent, Some(&url));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(agent.prompt.images.is_empty());
        assert!(
            !agent.prompt.text().contains("[Image #"),
            "no chip placeholder expected for non-image drop; got {:?}",
            agent.prompt.text()
        );
        let canon_txt = dunce::canonicalize(&txt).unwrap();
        let want_with_trailing_space = format!("{} ", canon_txt.display());
        assert!(
            agent.prompt.text().contains(&want_with_trailing_space),
            "percent-encoded path + trailing space must round-trip; got {:?}, want substring {:?}",
            agent.prompt.text(),
            want_with_trailing_space
        );
        assert!(
            !agent.prompt.text().contains("%20"),
            "%20 must be decoded; got {:?}",
            agent.prompt.text()
        );
    }
    #[test]
    fn paste_key_multi_file_drop_image_plus_non_image_handles_both() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let dir = tempfile::tempdir().unwrap();
        let png = dir.path().join("snap.png");
        let txt = dir.path().join("notes.txt");
        let png_bytes = make_test_png(8, 8);
        std::fs::write(&png, &png_bytes).unwrap();
        std::fs::write(&txt, b"hi").unwrap();
        let pasted = format!("file://{}\nfile://{}", png.display(), txt.display());
        let outcome = paste_cmd_v(&mut agent, Some(&pasted));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.images.len(), 1);
        assert!(agent.prompt.text().contains("[Image #1]"));
        assert!(
            !agent.prompt.text().contains("[Image #2]"),
            "non-image must not also produce a chip; prompt = {:?}",
            agent.prompt.text()
        );
        let canon_txt = dunce::canonicalize(&txt).unwrap();
        let want_with_trailing_space = format!("{} ", canon_txt.display());
        assert!(
            agent.prompt.text().contains(&want_with_trailing_space),
            "non-image path + trailing space must be inserted; prompt = {:?}, want substring = {:?}",
            agent.prompt.text(),
            want_with_trailing_space
        );
        let prompt_text = agent.prompt.text();
        let chip_idx = prompt_text.find("[Image #1]").unwrap();
        let path_idx = prompt_text.find(&want_with_trailing_space).unwrap();
        assert!(
            chip_idx < path_idx,
            "chip must precede path text in source order; prompt = {prompt_text:?}"
        );
    }
    #[test]
    fn paste_key_image_path_with_trailing_newline_still_attaches() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let dir = tempfile::tempdir().unwrap();
        let png = dir.path().join("trail.png");
        let png_bytes = make_test_png(8, 8);
        std::fs::write(&png, &png_bytes).unwrap();
        let pasted = format!("file://{}\n", png.display());
        let outcome = paste_cmd_v(&mut agent, Some(&pasted));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.images.len(), 1);
        assert!(
            agent.prompt.text().contains("[Image #1]"),
            "expected `[Image #1]` chip text; got {:?}",
            agent.prompt.text()
        );
        assert!(
            !agent.prompt.text().contains("file://"),
            "no `file://` fragment should leak into the prompt; got {:?}",
            agent.prompt.text()
        );
    }
    /// Mirrors the strongest Cmd+V drop-path test, but exercises the
    /// `Event::Paste` (bracketed-paste) branch — the actual path a
    /// drag-from-Finder takes through the dispatcher. A future refactor
    /// that re-orders the clipboard-image probe ahead of the path
    /// classifier on this branch would silently regress the headline
    /// bug; this test fails fast in that scenario.
    #[test]
    fn event_paste_non_image_file_url_inserts_decoded_path_not_chip() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let dir = tempfile::tempdir().unwrap();
        let txt = dir.path().join("event_paste.txt");
        std::fs::write(&txt, b"hello").unwrap();
        let url = format!("file://{}", txt.display());
        let registry = ActionRegistry::defaults();
        let outcome = agent.handle_input(&Event::Paste(url), &registry);
        assert!(
            matches!(outcome, InputOutcome::Changed),
            "Event::Paste path must claim the drop with `Changed`; got {outcome:?}"
        );
        assert!(
            agent.prompt.images.is_empty(),
            "non-image file:// drop must not create an image chip"
        );
        assert!(
            !agent.prompt.text().contains("[Image #"),
            "no chip placeholder expected; got {:?}",
            agent.prompt.text()
        );
        let canon_txt = dunce::canonicalize(&txt).unwrap();
        let want_with_trailing_space = format!("{} ", canon_txt.display());
        assert!(
            agent.prompt.text().contains(&want_with_trailing_space),
            "decoded path + trailing space must be inserted; got {:?}, want substring {:?}",
            agent.prompt.text(),
            want_with_trailing_space
        );
    }
    /// A multi-image drop that pushes us past the cap must NOT block
    /// subsequent non-image entries — the trailing path text must still
    /// be inserted. We assert the cap toast is the live (last-written)
    /// toast at the end of the drop; the dedup latch itself is enforced
    /// statically by the `image_cap_reached` branch in
    /// `try_handle_dropped_paths_paste`.
    #[test]
    fn paste_key_cap_reached_does_not_block_non_image_insert() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let cap = crate::views::prompt_widget::PromptWidget::IMAGE_CAP;
        for _ in 0..(cap - 1) {
            agent.prompt.insert_image(test_image_paste()).unwrap();
        }
        assert_eq!(agent.prompt.images.len(), cap - 1);
        let dir = tempfile::tempdir().unwrap();
        let mk_png = |name: &str| {
            let p = dir.path().join(name);
            std::fs::write(&p, make_test_png(8, 8)).unwrap();
            p
        };
        let a = mk_png("a.png");
        let b = mk_png("b.png");
        let c = mk_png("c.png");
        let txt = dir.path().join("note.txt");
        std::fs::write(&txt, b"x").unwrap();
        let pasted = format!(
            "file://{}\nfile://{}\nfile://{}\nfile://{}",
            a.display(),
            b.display(),
            c.display(),
            txt.display(),
        );
        let outcome = paste_cmd_v(&mut agent, Some(&pasted));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.prompt.images.len(), cap);
        let canon_txt = dunce::canonicalize(&txt).unwrap();
        let want = format!("{} ", canon_txt.display());
        assert!(
            agent.prompt.text().contains(&want),
            "non-image path must still be inserted past the image cap; prompt = {:?}",
            agent.prompt.text()
        );
        let toast_msg = agent
            .toast
            .as_ref()
            .map(|(msg, _ticks)| msg.clone())
            .unwrap_or_default();
        assert!(
            toast_msg.contains("Image limit reached"),
            "expected cap toast to be the last toast shown; got {toast_msg:?}"
        );
    }
    /// Drive `agent` through the canonical drop-classifier assertions
    /// for one `Event::Paste` arm. The `setup` closure puts the agent
    /// into whatever state the dispatcher needs to route paste through
    /// the target arm (focus, queue, viewer, etc.). All four arms
    /// share these assertions; using a single helper prevents the
    /// four bodies from drifting against each other.
    fn assert_event_paste_arm_decodes_non_image(
        arm_name: &str,
        setup: impl FnOnce(&mut AgentView),
    ) {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        setup(&mut agent);
        let dir = tempfile::tempdir().unwrap();
        let txt = dir.path().join(format!("{arm_name}_paste.txt"));
        std::fs::write(&txt, b"hello").unwrap();
        let url = format!("file://{}", txt.display());
        let registry = ActionRegistry::defaults();
        let outcome = agent.handle_input(&Event::Paste(url), &registry);
        assert!(
            matches!(outcome, InputOutcome::Changed),
            "[{arm_name}] outcome must be Changed; got {outcome:?}"
        );
        assert!(
            agent.prompt.images.is_empty(),
            "[{arm_name}] non-image drop must not create an image chip"
        );
        assert!(
            !agent.prompt.text().contains("[Image #"),
            "[{arm_name}] no chip placeholder expected; got {:?}",
            agent.prompt.text()
        );
        assert!(
            !agent.prompt.text().contains("file://"),
            "[{arm_name}] no `file://` fragment may leak into the prompt; got {:?}",
            agent.prompt.text()
        );
        let canon_txt = dunce::canonicalize(&txt).unwrap();
        let want = format!("{} ", canon_txt.display());
        assert!(
            agent.prompt.text().contains(&want),
            "[{arm_name}] decoded path + trailing space must appear; got {:?}",
            agent.prompt.text()
        );
    }
    /// The plan-feedback / casual-commenting `Event::Paste` arm routes
    /// through the canonical drop classifier. Re-introducing a raw
    /// `self.prompt.handle_paste(text)` here would skip path decoding
    /// and fail this test.
    #[test]
    fn event_paste_plan_feedback_non_image_file_url_decoded_into_prompt() {
        assert_event_paste_arm_decodes_non_image("plan_feedback", |agent| {
            agent.enter_casual_commenting_for_test();
            assert!(
                agent.is_casual_commenting(),
                "fixture must satisfy is_casual_commenting() to reach the plan-feedback paste arm"
            );
        });
    }
    /// Permission-followup `Event::Paste` arm routes through the classifier.
    #[test]
    fn event_paste_permission_followup_non_image_file_url_decoded_into_prompt() {
        assert_event_paste_arm_decodes_non_image("permission_followup", |agent| {
            agent
                .permission_queue
                .push_back(make_followup_permission_state());
        });
    }
    /// Plan-approval-view `Event::Paste` arm routes through the classifier.
    #[test]
    fn event_paste_plan_approval_non_image_file_url_decoded_into_prompt() {
        assert_event_paste_arm_decodes_non_image("plan_approval", |agent| {
            agent.plan_approval_view = Some(make_plan_approval_view_state());
            agent.line_viewer = None;
        });
    }
    /// Question-view `Event::Paste` arm routes through the classifier when
    /// the question view is in `InputMode` focus.
    #[test]
    fn event_paste_question_view_input_mode_non_image_file_url_decoded_into_prompt() {
        assert_event_paste_arm_decodes_non_image("question_view", |agent| {
            agent.question_view = Some(make_question_view_state_in_input_mode());
        });
    }
    /// Build a `PermissionViewState` already in FollowupInput focus —
    /// enough for the dispatcher's permission-followup paste arm.
    pub(in crate::app::agent_view) fn make_followup_permission_state()
    -> crate::views::permission_view::PermissionViewState {
        let (response_tx, _rx) = tokio::sync::oneshot::channel();
        let request = agent_client_protocol::RequestPermissionRequest::new(
            agent_client_protocol::SessionId::new(std::sync::Arc::from("test")),
            agent_client_protocol::ToolCallUpdate::new(
                agent_client_protocol::ToolCallId::new(std::sync::Arc::from("call-1")),
                agent_client_protocol::ToolCallUpdateFields::default(),
            ),
            vec![],
        );
        let perm = kigi_acp_lib::AcpArgs {
            request,
            response_tx,
        };
        crate::views::permission_view::PermissionViewState {
            request: perm,
            id: 0,
            focus: crate::views::permission_view::PermissionFocus::FollowupInput,
            options: vec![],
            active_idx: 0,
            bash_highlights: None,
            bash_selection_count: 0,
            bash_command_raw: None,
            mcp_scope: None,
            title: String::new(),
            description: vec![],
            args_expanded: false,
            desc_scroll: 0,
            subagent_label: None,
            options_area_height: 0,
            options_scroll_offset: 0,
        }
    }
    /// Build a minimal `PlanApprovalViewState` — enough for the
    /// dispatcher's plan-approval-view paste arm.
    pub(in crate::app::agent_view) fn make_plan_approval_view_state()
    -> crate::views::plan_approval_view::PlanApprovalViewState {
        let (tx, _rx) = tokio::sync::oneshot::channel();
        let request = crate::views::plan_approval_view::ExitPlanModeExtRequest {
            session_id: "test-session".into(),
            tool_call_id: "call-1".into(),
            plan_content: Some("# Plan\n\n## Step 1\nDo something".into()),
        };
        crate::views::plan_approval_view::PlanApprovalViewState::new(
            request,
            crate::views::prompt_widget::StashedPrompt {
                text: String::new(),
                cursor: 0,
                images: Vec::new(),
                chip_elements: Vec::new(),
                image_counter: 0,
                image_undo_stash: Vec::new(),
            },
            tx,
        )
    }
    /// Build a `QuestionViewState` already in `InputMode` focus.
    pub(in crate::app::agent_view) fn make_question_view_state_in_input_mode()
    -> crate::views::question_view::QuestionViewState {
        let question = kigi_tools::implementations::kigi::ask_user_question::Question {
            question: "Pick one?".to_string(),
            options: vec![
                kigi_tools::implementations::kigi::ask_user_question::QuestionOption {
                    label: "A".to_string(),
                    description: "Option A".to_string(),
                    preview: None,
                    id: None,
                },
            ],
            multi_select: Some(false),
            id: None,
        };
        let mut state = crate::views::question_view::QuestionViewState::new(
            "tc-1".into(),
            vec![question],
            crate::views::prompt_widget::StashedPrompt {
                text: String::new(),
                cursor: 0,
                images: Vec::new(),
                chip_elements: Vec::new(),
                image_counter: 0,
                image_undo_stash: Vec::new(),
            },
        );
        state.focus = crate::views::question_view::QuestionFocus::InputMode;
        state
    }
    /// Helper: build a minimal `PastedImage` for in-memory cap testing.
    /// Uses a real PNG byte payload so `insert_image` accepts it.
    fn test_image_paste() -> crate::prompt_images::PastedImage {
        let bytes = make_test_png(8, 8);
        crate::prompt_images::from_clipboard_data(&crate::clipboard::ImageData {
            data: bytes,
            mime_type: "image/png".to_string(),
        })
    }
    /// Generate a valid minimal PNG of the given dimensions.
    fn make_test_png(width: u32, height: u32) -> Vec<u8> {
        use image::{ImageBuffer, Rgba};
        let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
            ImageBuffer::from_pixel(width, height, Rgba([128, 64, 32, 255]));
        let mut buf = Vec::new();
        img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
            .unwrap();
        buf
    }
    #[test]
    fn regression_question_modal_fullscreen_overcommit() {
        use crate::appearance::{LayoutConfig, ScrollbarConfig};
        use ratatui::layout::Rect;
        let area = Rect::new(0, 0, 80, 25);
        let reserved: u16 = 1 + 5 + 1 + 3;
        let unclamped: u16 = area.height + 3 + 5;
        assert!(unclamped > area.height);
        let clamped = unclamped.min(area.height.saturating_sub(reserved));
        assert!(clamped + reserved <= area.height);
        let layout_cfg = LayoutConfig::default();
        let scrollbar_cfg = ScrollbarConfig::default();
        let layout = AgentViewLayout::compute(
            area,
            &layout_cfg,
            &scrollbar_cfg,
            0,
            clamped,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            0,
            1,
            false,
        );
        assert!(layout.prompt.y + layout.prompt.height <= area.height);
    }
    /// Wiping a substantial main-prompt draft routes `Action::ShowUndoTip`:
    /// the end-to-end happy path the whole feature exists for.
    #[test]
    fn main_prompt_substantial_wipe_routes_show_undo_tip() {
        let mut agent = make_agent();
        agent.prompt.set_contextual_hints(true, true);
        for _ in 0..25 {
            let _ = agent.handle_prompt_key_for_test(&key!('x').to_key_event());
        }
        let outcome = agent.handle_prompt_key_for_test(&key!('c', CONTROL).to_key_event());
        assert!(
            matches!(outcome, InputOutcome::Action(Action::ShowUndoTip)),
            "substantial main-prompt wipe must route show, got {outcome:?}"
        );
    }
    /// Ctrl+Z while the undo tip is on screen is an acceptance: it restores the
    /// wiped draft and retires the hint (the guarded branch that also emits the
    /// `accepted` telemetry; the emit itself has no in-process capture sink).
    #[test]
    fn ctrl_z_accepts_and_retires_undo_tip() {
        let mut agent = make_agent();
        agent.prompt.set_contextual_hints(true, true);
        for _ in 0..25 {
            let _ = agent.handle_prompt_key_for_test(&key!('x').to_key_event());
        }
        let _ = agent.handle_prompt_key_for_test(&key!('c', CONTROL).to_key_event());
        assert!(agent.prompt.text().is_empty(), "ctrl+c wiped the draft");
        let _ = agent.ephemeral_tip.show(
            crate::tips::clear_detector::undo_tip(),
            &mut std::collections::HashMap::new(),
        );
        let _ = agent.handle_prompt_key_for_test(&key!('z', CONTROL).to_key_event());
        assert!(
            !agent.prompt.text().is_empty(),
            "ctrl+z restored the wiped draft"
        );
        assert!(
            !agent.ephemeral_tip.is_active(),
            "accepting ctrl+z retires the undo tip"
        );
    }
    /// Ctrl+Z attributes ONLY the undo tip: with a different tip on screen the
    /// undo-accept guard is false, so that tip is left untouched (no acceptance
    /// is misattributed to it).
    #[test]
    fn ctrl_z_leaves_a_non_undo_tip_untouched() {
        let mut agent = make_agent();
        agent.prompt.set_contextual_hints(true, true);
        for _ in 0..25 {
            let _ = agent.handle_prompt_key_for_test(&key!('x').to_key_event());
        }
        let _ = agent.handle_prompt_key_for_test(&key!('c', CONTROL).to_key_event());
        let _ = agent.ephemeral_tip.show(
            crate::tips::clipboard_focus::clipboard_image_tip(),
            &mut std::collections::HashMap::new(),
        );
        let _ = agent.handle_prompt_key_for_test(&key!('z', CONTROL).to_key_event());
        assert_eq!(
            agent.ephemeral_tip.current_key(),
            Some(crate::tips::clipboard_focus::CLIPBOARD_IMAGE_TIP_KEY),
            "ctrl+z must not retire a tip that is not the undo tip"
        );
    }
    /// Type a draft across into a planning keyword so the prompt's one-shot
    /// plan-nudge fire is armed (mirrors a real keypress without the
    /// auto-managed input-mode reset a full route would apply).
    fn arm_plan_nudge(agent: &mut AgentView) {
        agent.prompt.set_contextual_hints(true, true);
        for ch in "plan".chars() {
            let _ = agent.prompt.handle_key(&crossterm::event::KeyEvent::new(
                crossterm::event::KeyCode::Char(ch),
                crossterm::event::KeyModifiers::NONE,
            ));
        }
    }
    /// Typing a planning keyword into an idle, normal-mode prompt that is not
    /// already in plan mode routes `Action::ShowPlanNudge`.
    #[test]
    fn typed_planning_keyword_routes_show_plan_nudge() {
        let mut agent = make_agent();
        arm_plan_nudge(&mut agent);
        assert!(
            matches!(agent.take_prompt_tip_signal(), Some(Action::ShowPlanNudge)),
            "idle normal-mode planning keyword must route the plan nudge"
        );
    }
    /// Plan-nudge gates: already in plan mode (optimistic read), a busy turn,
    /// or a special (bash/feedback/remember) input mode each suppress it.
    #[test]
    fn plan_nudge_suppressed_by_state_gates() {
        let mut agent = make_agent();
        agent.plan_mode_pending = Some(true);
        arm_plan_nudge(&mut agent);
        assert!(
            agent.take_prompt_tip_signal().is_none(),
            "plan mode must suppress the nudge"
        );
        let mut agent = make_agent();
        agent.session.state = AgentState::TurnRunning;
        arm_plan_nudge(&mut agent);
        assert!(
            agent.take_prompt_tip_signal().is_none(),
            "a busy turn must suppress the nudge"
        );
        let mut agent = make_agent();
        agent.prompt_input_mode = PromptInputMode::Bash;
        arm_plan_nudge(&mut agent);
        assert!(
            agent.take_prompt_tip_signal().is_none(),
            "a non-Normal input mode must suppress the nudge"
        );
    }
    /// A paste retires the clipboard-image hint that advertised it.
    #[test]
    fn paste_clears_clipboard_image_tip() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let _ = agent.ephemeral_tip.show(
            crate::tips::clipboard_focus::clipboard_image_tip(),
            &mut std::collections::HashMap::new(),
        );
        assert!(agent.ephemeral_tip.is_active());
        let _ = paste_cmd_v(&mut agent, Some("hello"));
        assert!(
            !agent.ephemeral_tip.is_active(),
            "paste must clear the clipboard-image tip"
        );
    }
    /// A clipboard-IMAGE paste while the hint is on screen runs the acceptance
    /// branch (the guarded `contextual_tip` emit): the image attaches and the
    /// hint retires. The emission has no in-process sink, so this pins the
    /// guarded branch's observable behavior; `current_key()` is unit-tested in
    /// `tips::ephemeral` and the mapping in the telemetry crate.
    #[test]
    fn image_paste_accepts_clipboard_tip_and_attaches() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let _ = agent.ephemeral_tip.show(
            crate::tips::clipboard_focus::clipboard_image_tip(),
            &mut std::collections::HashMap::new(),
        );
        assert_eq!(
            agent.ephemeral_tip.current_key(),
            Some(crate::tips::clipboard_focus::CLIPBOARD_IMAGE_TIP_KEY)
        );
        paste_cmd_v_image(&mut agent, None);
        assert_eq!(agent.prompt.images.len(), 1, "image attached");
        assert!(
            !agent.ephemeral_tip.is_active(),
            "the image paste retired the clipboard-image hint"
        );
    }
    /// A bracketed `Event::Paste` (not the Cmd+V chord) also retires the
    /// clipboard-image hint (regression guard). A 5-line, no-`://` payload
    /// skips the macOS attachment probe, so the test never reads the real
    /// pasteboard; the clear runs at the top of the prompt paste arm regardless.
    #[test]
    fn bracketed_paste_clears_clipboard_image_tip() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let _ = agent.ephemeral_tip.show(
            crate::tips::clipboard_focus::clipboard_image_tip(),
            &mut std::collections::HashMap::new(),
        );
        assert!(agent.ephemeral_tip.is_active());
        let registry = ActionRegistry::defaults();
        let _ = agent.handle_input(&Event::Paste("a\nb\nc\nd\ne".to_string()), &registry);
        assert!(
            !agent.ephemeral_tip.is_active(),
            "bracketed paste must clear the clipboard-image tip"
        );
    }
    /// `show_ephemeral_tip` refuses shows that cannot paint, so no seen count,
    /// TTL, or telemetry burns invisibly: an unknown/short terminal, or any
    /// occluding view. One case per occluder predicate term — a per-term typo
    /// (wrong field, duplicate, omission) fails exactly one assertion — closed
    /// by a non-vacuous success that shows and counts.
    #[test]
    fn ephemeral_tip_show_refused_while_unrenderable() {
        use std::collections::HashMap;
        let mut agent = make_agent();
        let mut counts: HashMap<&'static str, u32> = HashMap::new();
        let gated = || {
            crate::tips::EphemeralTip::new("t", ratatui::text::Line::from("hint"))
                .with_session_seen_cap("t_seen", 1)
        };
        let assert_refused =
            |agent: &mut AgentView, counts: &mut HashMap<&'static str, u32>, why: &str| {
                assert!(!agent.show_ephemeral_tip(gated(), counts), "{why}");
                assert!(!agent.ephemeral_tip.is_active(), "{why}: tip must not show");
                assert!(counts.is_empty(), "{why}: must not burn a count");
            };
        assert_refused(&mut agent, &mut counts, "unknown size");
        agent.last_terminal_size = (80, 16);
        assert_refused(&mut agent, &mut counts, "short terminal");
        agent.last_terminal_size = (80, 30);
        agent.active_subagent = Some("child".into());
        assert_refused(&mut agent, &mut counts, "subagent takeover");
        agent.active_subagent = None;
        agent.line_viewer =
            crate::views::file_search::line_viewer::LineViewerState::open_markdown_content(
                "x.md",
                "body".to_string(),
                None,
            );
        assert!(agent.line_viewer.is_some(), "line viewer fixture must open");
        assert_refused(&mut agent, &mut counts, "line viewer");
        agent.line_viewer = None;
        agent.image_viewer = Some(
            crate::prompt_images::ImageViewerState::open_from_path_deferred(std::path::Path::new(
                "x.png",
            )),
        );
        assert_refused(&mut agent, &mut counts, "image viewer");
        agent.image_viewer = None;
        agent.video_viewer = Some(crate::prompt_images::VideoViewerState::test_stub());
        assert_refused(&mut agent, &mut counts, "video viewer");
        agent.video_viewer = None;
        agent.block_viewer = Some(crate::views::block_viewer::BlockViewerPane::for_plain_text(
            "t", "content",
        ));
        assert_refused(&mut agent, &mut counts, "block viewer");
        agent.block_viewer = None;
        agent.gboom = Some(crate::gboom::GboomState::new());
        assert_refused(&mut agent, &mut counts, "gboom");
        agent.gboom = None;
        agent.extensions_modal = Some(crate::views::extensions_modal::ExtensionsModalState::new(
            crate::views::extensions_modal::ExtensionsTab::Hooks,
        ));
        assert_refused(&mut agent, &mut counts, "extensions modal");
        agent.extensions_modal = None;
        agent.agents_modal = Some(crate::views::agents_modal::AgentsModalState::new(
            std::path::Path::new("/nonexistent"),
            &HashMap::new(),
            &crate::app::bundle::BundleState::default(),
            None,
            None,
        ));
        assert_refused(&mut agent, &mut counts, "agents modal");
        agent.agents_modal = None;
        agent.show_goal_detail = true;
        agent.goal_state = Some(crate::app::agent::GoalDisplayState::test_stub());
        assert_refused(&mut agent, &mut counts, "goal detail overlay");
        agent.show_goal_detail = false;
        agent.goal_state = None;
        agent.prompt.suggestions.dropdown.open = true;
        assert_refused(&mut agent, &mut counts, "prompt dropdown");
        agent.prompt.suggestions.dropdown.open = false;
        assert!(
            agent.show_ephemeral_tip(gated(), &mut counts),
            "renderable show must take the slot"
        );
        assert!(agent.ephemeral_tip.is_active());
        assert_eq!(counts.get("t_seen"), Some(&1));
    }
    /// A resize event must close the show gate until the next draw
    /// re-measures — in EITHER direction. The recorded height describes a
    /// possibly chrome-shrunk paint rect (dashboard overlay header/popup,
    /// dev tracing split), so even a grown terminal does not prove the
    /// banner row can paint; acting on any extrapolated height could burn
    /// a seen count on a tip that never shows.
    #[test]
    fn ephemeral_tip_show_gate_refuses_between_resize_and_redraw() {
        let mut agent = make_agent();
        let mut counts = std::collections::HashMap::new();
        let gated = || {
            crate::tips::EphemeralTip::new("t", ratatui::text::Line::from("hint"))
                .with_session_seen_cap("t_seen", 1)
        };
        agent.note_terminal_size((80, 30));
        agent.note_terminal_resize();
        assert!(!agent.show_ephemeral_tip(gated(), &mut counts));
        assert!(!agent.ephemeral_tip.is_active());
        assert!(counts.is_empty(), "stale-size show must not burn a count");
        assert_eq!(
            agent.last_terminal_size,
            (80, 30),
            "the event must not overwrite the draw-measured rect size"
        );
        agent.note_terminal_size((80, 30));
        assert!(agent.show_ephemeral_tip(gated(), &mut counts));
        assert!(agent.ephemeral_tip.is_active());
        assert_eq!(counts.get("t_seen"), Some(&1));
    }
    /// `note_terminal_size` keeps the draw-path semantics it replaced:
    /// Kitty IDs are invalidated only on an actual size change, the
    /// `(0, 0)` pre-first-draw state never counts as a resize, and every
    /// re-measure clears the resize-event staleness flag.
    #[test]
    fn note_terminal_size_invalidates_kitty_ids_only_on_change() {
        let mut agent = make_agent();
        let path = std::path::PathBuf::from("/img.png");
        agent.inline_media_ids.insert(path, 2);
        agent.note_terminal_size((80, 30));
        assert_eq!(agent.last_terminal_size, (80, 30));
        assert!(!agent.inline_media_ids.is_empty());
        agent.note_terminal_size((80, 30));
        assert!(!agent.inline_media_ids.is_empty());
        agent.note_terminal_resize();
        assert!(agent.terminal_size_stale);
        agent.note_terminal_size((80, 10));
        assert_eq!(agent.last_terminal_size, (80, 10));
        assert!(agent.inline_media_ids.is_empty());
        assert!(!agent.terminal_size_stale, "draw re-measure ends staleness");
    }
    /// `[Copy source]` copies the diagram source with NO render dispatched
    /// (needs no PNG), in every build; a click outside every hit-rect falls
    /// through. Lazy `[Open]`/`[Copy path]` routing is covered (engine-gated) by
    /// `mermaid_open_click_routes_to_lazy_render`.
    #[test]
    fn mermaid_copy_source_click_copies_without_render() {
        use crate::scrollback::blocks::mermaid_content::AffordanceKind;
        use ratatui::layout::Rect;
        let mut agent = make_agent();
        agent.inline_media_hits.mermaid_sources = vec!["flowchart TD\nA-->B\n".to_string()];
        agent.inline_media_hits.mermaid_buttons =
            vec![(Rect::new(35, 0, 13, 1), AffordanceKind::CopySource, 0)];
        let out = agent.handle_inline_media_click(36, 0);
        assert!(matches!(out, Some(InputOutcome::Changed)));
        let toast = agent
            .toast
            .as_ref()
            .map(|(m, _)| m.clone())
            .unwrap_or_default();
        assert!(
            toast.starts_with("Copied") || toast.starts_with("Copy failed"),
            "copy-source emits a clipboard toast, got {toast:?}",
        );
        assert!(
            !agent.mermaid_needs_tick(),
            "copy source dispatches no render"
        );
        agent.toast = None;
        assert!(agent.handle_inline_media_click(60, 5).is_none());
    }
    /// `[Open]`/`[Copy path]` route to the lazy render path: with no session dir
    /// (so nowhere to cache a PNG) the request reports "not ready" — proving the
    /// click reached `request_mermaid_render` rather than an eager/open path.
    #[test]
    fn mermaid_open_click_routes_to_lazy_render() {
        use crate::scrollback::blocks::mermaid_content::AffordanceKind;
        use ratatui::layout::Rect;
        for kind in [AffordanceKind::Open, AffordanceKind::CopyPath] {
            let mut agent = make_agent();
            agent.inline_media_hits.mermaid_sources = vec!["flowchart TD\nA-->B\n".to_string()];
            agent.inline_media_hits.mermaid_buttons = vec![(Rect::new(12, 0, 6, 1), kind, 0)];
            let out = agent.handle_inline_media_click(13, 0);
            assert!(matches!(out, Some(InputOutcome::Changed)));
            let toast = agent
                .toast
                .as_ref()
                .map(|(m, _)| m.clone())
                .unwrap_or_default();
            assert!(
                toast.contains("not ready"),
                "{kind:?} routes to the lazy render path (no session dir): {toast:?}",
            );
        }
    }
    /// The real painter (`paint_diagram_affordances`) lays the row from the
    /// single layout source of truth: a leading dim `◇ mermaid` label then the
    /// three always-clickable buttons (each registering a hit-rect carrying the
    /// source), with the buttons shifted right past the label.
    #[test]
    fn paints_affordance_row_with_label_and_registers_all_buttons() {
        use crate::scrollback::blocks::mermaid_content::{AffordanceKind, affordance_row};
        use crate::scrollback::render::DiagramAffordancePlacement;
        use ratatui::buffer::Buffer;
        use ratatui::layout::Rect;
        let theme = Theme::current();
        let source = "flowchart TD\nA-->B\n".to_string();
        let layout = affordance_row(false);
        let cols = layout.buttons.map(|b| b.col);
        let mut agent = make_agent();
        let rect = Rect::new(0, 0, 80, 1);
        let mut buf = Buffer::empty(rect);
        agent.paint_diagram_affordances(
            &mut buf,
            vec![DiagramAffordancePlacement {
                screen_rect: rect,
                source: source.clone(),
            }],
            &theme,
        );
        let span = |col: u16, w: u16| -> String {
            (col..col + w)
                .map(|x| buf.cell((x, 0)).unwrap().symbol().to_string())
                .collect()
        };
        assert_eq!(span(0, 9), "\u{25c7} mermaid");
        assert_eq!(
            buf.cell((0, 0)).unwrap().fg,
            theme.gray_dim,
            "the ◇ mermaid label is dim",
        );
        assert_eq!(span(cols[0], 12), "[Open Image]");
        assert_eq!(span(cols[1], 17), "[Copy Image Path]");
        assert_eq!(span(cols[2], 13), "[Copy Source]");
        let buttons = &agent.inline_media_hits.mermaid_buttons;
        assert_eq!(buttons.len(), 3);
        assert_eq!(
            buttons.iter().map(|&(_, k, _)| k).collect::<Vec<_>>(),
            vec![
                AffordanceKind::Open,
                AffordanceKind::CopyPath,
                AffordanceKind::CopySource
            ],
        );
        for (i, &(r, _, idx)) in buttons.iter().enumerate() {
            assert_eq!(r.x, cols[i], "hit-rect aligns with painted column");
            assert_eq!(idx, 0, "all buttons index the one source");
        }
        assert_eq!(agent.inline_media_hits.mermaid_sources, vec![source]);
    }
    /// The hovered button is highlighted (BOLD|UNDERLINED, `text_primary`); every
    /// other button stays at the idle `gray` (brighter than the dim `gray_dim`
    /// label so it stays discoverable), with no bold/underline. With the cursor
    /// off the row, all buttons are idle.
    #[test]
    fn paints_affordance_row_highlights_only_the_hovered_button() {
        use crate::scrollback::blocks::mermaid_content::affordance_row;
        use crate::scrollback::render::DiagramAffordancePlacement;
        use ratatui::buffer::Buffer;
        use ratatui::layout::Rect;
        use ratatui::style::Modifier;
        let theme = Theme::current();
        let cols = affordance_row(false).buttons.map(|b| b.col);
        let placement = |rect: Rect| DiagramAffordancePlacement {
            screen_rect: rect,
            source: "A-->B\n".to_string(),
        };
        let rect = Rect::new(0, 0, 80, 1);
        let modifier = |buf: &Buffer, x: u16| buf.cell((x, 0)).unwrap().modifier;
        let highlighted = |buf: &Buffer, x: u16| {
            modifier(buf, x).contains(Modifier::BOLD | Modifier::UNDERLINED)
                && buf.cell((x, 0)).unwrap().fg == theme.text_primary
        };
        let idle = |buf: &Buffer, x: u16| {
            !modifier(buf, x).intersects(Modifier::BOLD | Modifier::UNDERLINED)
                && buf.cell((x, 0)).unwrap().fg == theme.gray
        };
        let mut agent = make_agent();
        agent.last_mouse_pos = (cols[1] + 1, 0);
        let mut buf = Buffer::empty(rect);
        agent.paint_diagram_affordances(&mut buf, vec![placement(rect)], &theme);
        assert!(
            highlighted(&buf, cols[1]),
            "hovered [Copy Image Path] is BOLD|UNDERLINED + text_primary",
        );
        assert!(
            idle(&buf, cols[0]),
            "non-hovered [Open Image] stays idle gray"
        );
        assert!(
            idle(&buf, cols[2]),
            "non-hovered [Copy Source] stays idle gray",
        );
        assert_eq!(buf.cell((0, 0)).unwrap().fg, theme.gray_dim);
        assert!(
            !modifier(&buf, 0).contains(Modifier::BOLD),
            "label not bold"
        );
        let mut agent = make_agent();
        agent.last_mouse_pos = (79, 0);
        let mut buf = Buffer::empty(rect);
        agent.paint_diagram_affordances(&mut buf, vec![placement(rect)], &theme);
        for col in cols {
            assert!(idle(&buf, col), "no hover ⇒ all buttons idle gray");
        }
    }
    /// Narrow rows clip whole segments rather than spilling past the row width:
    /// a row wide enough for the label + `[Open]` paints just those and registers
    /// only `[Open]`'s hit-rect; the clipped buttons register none.
    #[test]
    fn paints_affordance_row_clips_segments_to_row_width() {
        use crate::scrollback::render::DiagramAffordancePlacement;
        use ratatui::buffer::Buffer;
        use ratatui::layout::Rect;
        let theme = Theme::current();
        let mut agent = make_agent();
        let rect = Rect::new(0, 0, 24, 1);
        let mut buf = Buffer::empty(Rect::new(0, 0, 80, 1));
        agent.paint_diagram_affordances(
            &mut buf,
            vec![DiagramAffordancePlacement {
                screen_rect: rect,
                source: "A-->B\n".to_string(),
            }],
            &theme,
        );
        let row: String = (0..80)
            .filter_map(|x| buf.cell((x, 0)).map(|c| c.symbol()))
            .collect();
        assert!(
            row.contains("[Open Image]"),
            "[Open Image] fits and is painted: {row:?}"
        );
        assert!(
            !row.contains("[Copy"),
            "clipped segments are not painted: {row:?}"
        );
        assert_eq!(
            agent.inline_media_hits.mermaid_buttons.len(),
            1,
            "only the [Open Image] hit-rect is registered",
        );
    }
    /// `render_dropdown_chrome` anchors the items band above the prompt by
    /// default (full TUI) and below it when `below = true` (minimal mode).
    #[test]
    fn dropdown_chrome_anchors_above_or_below_the_prompt() {
        use crate::appearance::LayoutConfig;
        use ratatui::buffer::Buffer;
        use ratatui::layout::Rect;
        let theme = Theme::current();
        let layout_cfg = LayoutConfig::default();
        let area = Rect::new(0, 0, 80, 25);
        let prompt = Rect::new(0, 10, 80, 2);
        let item_rows = 3u16;
        let mut buf = Buffer::empty(area);
        let above = render_dropdown_chrome(
            &mut buf,
            3,
            item_rows,
            None,
            prompt,
            area,
            &layout_cfg,
            false,
            false,
            &theme,
        )
        .expect("above chrome fits");
        assert!(
            above.items.y < prompt.y,
            "above: items.y {} should be above prompt.y {}",
            above.items.y,
            prompt.y
        );
        let mut buf = Buffer::empty(area);
        let below = render_dropdown_chrome(
            &mut buf,
            3,
            item_rows,
            None,
            prompt,
            area,
            &layout_cfg,
            false,
            true,
            &theme,
        )
        .expect("below chrome fits");
        assert!(
            below.items.y >= prompt.y + prompt.height,
            "below: items.y {} should be at/after prompt bottom {}",
            below.items.y,
            prompt.y + prompt.height
        );
        assert_eq!(below.items.height, item_rows);
    }
    /// Minimal ("embedded") dropdown chrome is flush-left (W-38): no outer
    /// horizontal padding around the panel and no content inset for the item
    /// rows, so the dropdown's `❯` marker sits at column 0 under the prompt's.
    /// The full TUI keeps the layout hpad + 1-col item inset in its boxed panel.
    #[test]
    #[serial_test::serial]
    fn dropdown_chrome_embedded_is_flush_left() {
        use crate::appearance::LayoutConfig;
        use ratatui::buffer::Buffer;
        use ratatui::layout::Rect;
        struct EmbedReset;
        impl Drop for EmbedReset {
            fn drop(&mut self) {
                crate::views::modal_window::set_embedded(false);
            }
        }
        let theme = Theme::current();
        let layout_cfg = LayoutConfig::default();
        let area = Rect::new(0, 0, 80, 25);
        let prompt = Rect::new(0, 10, 80, 2);
        let mut buf = Buffer::empty(area);
        let full = render_dropdown_chrome(
            &mut buf,
            3,
            3,
            None,
            prompt,
            area,
            &layout_cfg,
            false,
            false,
            &theme,
        )
        .expect("full chrome fits");
        assert_eq!(full.panel.x, area.x + layout_cfg.eff_hpad_left(false));
        assert_eq!(full.items.x, prompt.x + 1 + layout_cfg.eff_hpad_left(false));
        let _reset = EmbedReset;
        crate::views::modal_window::set_embedded(true);
        let mut buf = Buffer::empty(area);
        let embedded = render_dropdown_chrome(
            &mut buf,
            3,
            3,
            None,
            prompt,
            area,
            &layout_cfg,
            false,
            true,
            &theme,
        )
        .expect("embedded chrome fits");
        assert_eq!(embedded.panel.x, area.x, "no outer hpad in minimal");
        assert_eq!(embedded.panel.width, area.width, "panel spans full width");
        assert_eq!(
            embedded.items.x, prompt.x,
            "items flush with the prompt's left edge"
        );
        assert_eq!(embedded.items.width, prompt.width);
    }
    /// The tool-media inline-image path (`build_inline_media_escapes`, still live
    /// for tool calls) transmits the bytes (`a=t`) before placing them (`a=p`) on
    /// the first paint, then places only (no re-transmit) on a later frame for
    /// the same path — a place-without-transmit would render blank.
    #[test]
    fn tool_media_first_frame_transmits_then_places_only() {
        use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
        let _g = set_protocol_for_test(GraphicsProtocol::Kitty);
        let mut agent = make_agent();
        let path = std::path::PathBuf::from("/tmp/tool-media.png");
        agent
            .inline_media_cache
            .insert(path.clone(), make_test_png(40, 20));
        let placement = crate::scrollback::render::InlineMediaPlacement {
            info: crate::prompt_images::InlineMediaInfo {
                path: path.clone(),
                width: 40,
                height: 20,
                is_video: false,
                alt_text: String::new(),
            },
            screen_rect: ratatui::layout::Rect::new(0, 0, 20, 6),
            full_rows: 6,
            top_crop_rows: 0,
            filepath_screen_rect: None,
            open_button_screen_rect: None,
            has_button_row: true,
        };
        let first = agent
            .build_inline_media_escapes(&placement)
            .expect("first frame emits escapes");
        assert!(
            first.contains("\x1b_G"),
            "first frame is a Kitty graphics escape"
        );
        assert!(
            first.contains("a=t"),
            "first frame transmits the bytes (a=t), not place-only: {first:?}",
        );
        assert!(
            first.contains("a=p"),
            "first frame also places the image (a=p)"
        );
        let second = agent
            .build_inline_media_escapes(&placement)
            .expect("second frame emits escapes");
        assert!(second.contains("a=p"), "second frame places the image");
        assert!(
            !second.contains("a=t"),
            "second frame must not re-transmit the bytes: {second:?}",
        );
    }
    /// Draining an agent with live inline-media placements returns delete
    /// escapes for every placed id — including ids placed by subagent
    /// fullscreen views — and resets the tracking state so the next draw
    /// re-transmits (used when another view takes over the frame and the
    /// agent's per-frame clears stop running).
    #[test]
    fn take_inline_media_clear_escapes_drains_placements() {
        let mut agent = make_agent();
        agent
            .inline_media_ids
            .insert(std::path::PathBuf::from("/tmp/a.png"), 2);
        agent
            .inline_media_ids
            .insert(std::path::PathBuf::from("/tmp/b.png"), 3);
        agent.last_placed_ids = [2, 3].into_iter().collect();
        agent.inline_media_active = true;
        let mut child = make_agent();
        child
            .inline_media_ids
            .insert(std::path::PathBuf::from("/tmp/c.png"), 4);
        child.inline_media_active = true;
        agent
            .subagent_views
            .insert("child-sid".into(), Box::new(child));
        let esc = agent
            .take_inline_media_clear_escapes()
            .expect("drains placed media");
        assert!(
            esc.contains(&crate::terminal::image::clear_kitty_image(2)),
            "deletes id 2: {esc:?}"
        );
        assert!(
            esc.contains(&crate::terminal::image::clear_kitty_image(3)),
            "deletes id 3: {esc:?}"
        );
        assert!(
            esc.contains(&crate::terminal::image::clear_kitty_image(4)),
            "deletes the subagent view's id 4: {esc:?}"
        );
        assert!(!agent.inline_media_active);
        assert!(agent.inline_media_ids.is_empty());
        assert!(agent.last_placed_ids.is_empty());
        assert!(agent.inline_video.is_none());
        assert!(agent.take_inline_media_clear_escapes().is_none());
    }
    /// An agent with no placements has nothing to clear.
    #[test]
    fn take_inline_media_clear_escapes_none_when_no_placements() {
        let mut agent = make_agent();
        assert!(agent.take_inline_media_clear_escapes().is_none());
    }
    /// The own-only drain deletes this view's placements but leaves
    /// `subagent_views` untouched, so the fullscreen takeover doesn't force
    /// the active child into a pointless re-transmit.
    #[test]
    fn take_own_inline_media_clear_escapes_leaves_children() {
        let mut agent = make_agent();
        agent
            .inline_media_ids
            .insert(std::path::PathBuf::from("/tmp/a.png"), 2);
        agent.last_placed_ids = [2].into_iter().collect();
        agent.inline_media_active = true;
        let mut child = make_agent();
        child
            .inline_media_ids
            .insert(std::path::PathBuf::from("/tmp/c.png"), 4);
        child.inline_media_active = true;
        agent
            .subagent_views
            .insert("child-sid".into(), Box::new(child));
        let esc = agent
            .take_own_inline_media_clear_escapes()
            .expect("drains own placed media");
        assert!(
            esc.contains(&crate::terminal::image::clear_kitty_image(2)),
            "deletes own id 2: {esc:?}"
        );
        assert!(
            !esc.contains(&crate::terminal::image::clear_kitty_image(4)),
            "must not delete the child's id 4: {esc:?}"
        );
        assert!(!agent.inline_media_active);
        assert!(agent.inline_media_ids.is_empty());
        assert!(agent.last_placed_ids.is_empty());
        let child = agent.subagent_views.get("child-sid").unwrap();
        assert!(child.inline_media_active);
        assert_eq!(child.inline_media_ids.len(), 1);
    }
    /// Draw one 80x30 frame — shared fixture for the subagent-takeover
    /// inline-media regression tests below.
    fn draw_media_frame(agent: &mut AgentView) {
        let registry = ActionRegistry::defaults();
        let area = ratatui::layout::Rect::new(0, 0, 80, 30);
        let mut buf = ratatui::buffer::Buffer::empty(area);
        let mut scratch = crate::scrollback::render::ScratchBuffer::new();
        let bundle = crate::app::bundle::BundleState::default();
        agent.draw(
            area,
            &mut buf,
            &registry,
            &mut scratch,
            None,
            false,
            0,
            None,
            &bundle,
            false,
            &mut Vec::new(),
        );
    }
    /// The scrolled-off/overlay branch of `AgentView::draw` (render.rs) must
    /// stop live inline playback through `stop_inline_playback` — dropping
    /// the frame set and REQUESTING a post-draw purge, never purging
    /// synchronously mid-frame. Pins the render.rs wiring itself (the
    /// helper alone is covered in media.rs). Serialized: the deferred flag
    /// is process-wide.
    #[test]
    #[serial_test::serial(MEMORY_RELEASE_DEFER)]
    fn scrolled_off_video_stop_requests_post_draw_release() {
        use crate::memory_release::test_support;
        test_support::install_counting_hook();
        crate::memory_release::run_deferred_release();
        let mut agent = make_agent();
        agent.inline_media_active = true;
        agent.inline_video = Some(crate::app::agent_view::InlineVideoState {
            path: std::path::PathBuf::from("/tmp/clip.mp4"),
            frames: vec![Vec::new()],
            current_frame: 0,
            last_frame_time: std::time::Instant::now(),
            fps: 1.0,
            finished: false,
        });
        let before = test_support::calls();
        draw_media_frame(&mut agent);
        assert!(
            agent.inline_video.is_none(),
            "scroll-off branch must stop playback"
        );
        assert_eq!(
            test_support::calls(),
            before,
            "no synchronous purge inside AgentView::draw"
        );
        crate::memory_release::run_deferred_release();
        assert_eq!(
            test_support::calls(),
            before + 1,
            "the post-draw drain must purge the dropped frame set"
        );
    }
    /// Entering the fullscreen subagent view must delete the parent's Kitty
    /// placements: the takeover early-returns before every normal per-frame
    /// clear path, and Kitty images survive cell overdraw, so without the
    /// takeover-time drain the parent's image bleeds through the child view.
    #[test]
    fn subagent_fullscreen_draw_clears_parent_inline_media() {
        let mut agent = make_agent();
        agent
            .inline_media_ids
            .insert(std::path::PathBuf::from("/tmp/a.png"), 2);
        agent.last_placed_ids = [2].into_iter().collect();
        agent.inline_media_active = true;
        agent
            .subagent_views
            .insert("child-sid".into(), Box::new(make_agent()));
        agent.active_subagent = Some("child-sid".into());
        draw_media_frame(&mut agent);
        assert!(
            !agent.inline_media_active,
            "takeover frame must drain the parent's inline media"
        );
        assert!(agent.inline_media_ids.is_empty());
        assert!(agent.last_placed_ids.is_empty());
    }
    /// Symmetric regression: after the fullscreen subagent view closes, the
    /// child's per-frame clears stop running, so the parent's next normal draw
    /// must delete whatever the child placed while fullscreen.
    #[test]
    fn draw_after_subagent_close_clears_child_inline_media() {
        let mut agent = make_agent();
        let mut child = make_agent();
        child
            .inline_media_ids
            .insert(std::path::PathBuf::from("/tmp/c.png"), 4);
        child.inline_media_active = true;
        agent
            .subagent_views
            .insert("child-sid".into(), Box::new(child));
        assert!(agent.active_subagent.is_none(), "subagent view is closed");
        draw_media_frame(&mut agent);
        let child = agent.subagent_views.get("child-sid").unwrap();
        assert!(
            !child.inline_media_active,
            "normal draw must drain a closed subagent view's inline media"
        );
        assert!(child.inline_media_ids.is_empty());
    }
    fn ctrl_v_key() -> KeyEvent {
        key!('v', CONTROL).to_key_event()
    }
    /// Target of the enqueued deferred-probe effect, if any.
    fn deferred_probe_target(
        agent: &AgentView,
    ) -> Option<crate::app::actions::ClipboardPasteTarget> {
        deferred_probe_ctx(agent).map(|ctx| ctx.target)
    }
    /// The `ClipboardPasteContext` of the enqueued deferred-probe effect, if any.
    fn deferred_probe_ctx(agent: &AgentView) -> Option<crate::app::actions::ClipboardPasteContext> {
        agent.pending_effects.iter().find_map(|e| match e {
            crate::app::actions::Effect::ProbeClipboardAttachment { ctx, .. } => Some(ctx.clone()),
            _ => None,
        })
    }
    /// Drive a real Cmd+V through the shipped entry point with the given pbpaste
    /// text and an available, raster-free snapshot (the native snapshot gate skips
    /// the deferred image probe), so a text or file-path paste resolves synchronously.
    fn paste_cmd_v(agent: &mut AgentView, clipboard_text: Option<&str>) -> InputOutcome {
        crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
            text: clipboard_text.map(str::to_owned),
            snapshot_supported: Some(true),
            snapshot: Some((Some(1), false)),
            ..Default::default()
        });
        let outcome = agent.handle_prompt_key_for_test(&ctrl_v_key());
        crate::clipboard::clear_clipboard_probe_hook();
        outcome
    }
    /// A `ClipboardPasteContext` matching what a real agent Cmd+V enqueues, for
    /// driving `complete_clipboard_attachment_paste` directly in completion tests.
    fn agent_completion_ctx(
        agent: &AgentView,
        clipboard_text: Option<&str>,
    ) -> crate::app::actions::ClipboardPasteContext {
        crate::app::actions::ClipboardPasteContext {
            target: crate::app::actions::ClipboardPasteTarget::AgentPrompt {
                agent_id: agent.session.id,
                images_dir: None,
            },
            source: crate::app::actions::ClipboardPasteSource::ClipboardKey {
                text: crate::app::actions::ClipboardTextRead::Success(
                    clipboard_text.map(str::to_owned),
                ),
                tip_showing: false,
            },
        }
    }
    /// Drive a real Cmd+V that finds a raster (defers), then complete the probe
    /// with a decoded image — the full shipped image-paste path through the
    /// deferred entry point.
    fn paste_cmd_v_image(agent: &mut AgentView, clipboard_text: Option<&str>) {
        crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
            text: clipboard_text.map(str::to_owned),
            ..crate::clipboard::ClipboardProbeHook::with_raster(None)
        });
        let _ = agent.handle_prompt_key_for_test(&ctrl_v_key());
        let ctx = deferred_probe_ctx(agent).expect("an image paste must defer a probe");
        crate::clipboard::clear_clipboard_probe_hook();
        let pasted = crate::prompt_images::from_clipboard_data(&test_image_data());
        agent.complete_clipboard_attachment_paste(
            ctx,
            crate::app::actions::ProbedAttachment::Image(pasted),
            None,
        );
    }
    #[test]
    fn agent_paste_snapshot_no_raster_stays_synchronous() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
            text: Some("hello world".to_string()),
            snapshot_supported: Some(true),
            snapshot: Some((Some(1), false)),
            ..Default::default()
        });
        let outcome = agent.handle_prompt_key_for_test(&ctrl_v_key());
        let calls = crate::clipboard::clipboard_probe_call_count();
        let deferred = deferred_probe_target(&agent).is_some();
        crate::clipboard::clear_clipboard_probe_hook();
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(calls, 0, "snapshot with no raster must skip the probe");
        assert!(
            !deferred,
            "plain text with no raster must not defer a probe"
        );
        assert!(agent.prompt.images.is_empty());
        assert_eq!(agent.prompt.text(), "hello world");
    }
    #[test]
    fn agent_paste_snapshot_has_image_defers_probe() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
            text: Some("caption".to_string()),
            snapshot_supported: Some(true),
            snapshot: Some((Some(1), true)),
            ..Default::default()
        });
        let outcome = agent.handle_prompt_key_for_test(&ctrl_v_key());
        let calls = crate::clipboard::clipboard_probe_call_count();
        let ctx = deferred_probe_ctx(&agent);
        crate::clipboard::clear_clipboard_probe_hook();
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(calls, 0, "probe must NOT run inline on the event loop");
        let ctx = ctx.expect("an agent-targeted probe effect must be enqueued");
        assert!(matches!(
            ctx.target,
            crate::app::actions::ClipboardPasteTarget::AgentPrompt { .. }
        ));
        assert_eq!(ctx.source.text(), Some("caption"));
        assert!(ctx.source.is_clipboard_key());
        assert_eq!(
            agent.prompt.text(),
            "",
            "caption must not be inserted synchronously"
        );
        assert!(agent.prompt.images.is_empty());
    }
    #[test]
    fn agent_cmd_v_probe_ctx_not_bracketed() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
            text: Some("caption".to_string()),
            snapshot_supported: Some(true),
            snapshot: Some((Some(1), true)),
            ..Default::default()
        });
        let _ = agent.handle_prompt_key_for_test(&ctrl_v_key());
        let ctx = deferred_probe_ctx(&agent);
        crate::clipboard::clear_clipboard_probe_hook();
        let ctx = ctx.expect("a Cmd+V with a raster must defer a probe");
        assert!(
            !ctx.source.is_bracketed(),
            "Cmd+V source must remain a CLIPBOARD-key read"
        );
    }
    /// Regression: an IME commit delivered as bracketed paste (Otty)
    /// must not attach the unrelated clipboard image.
    #[cfg(any(target_os = "macos", target_os = "windows"))]
    #[test]
    fn agent_bracketed_paste_stamps_ctx_bracketed() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let registry = ActionRegistry::defaults();
        crate::clipboard::set_clipboard_probe_hook(
            crate::clipboard::ClipboardProbeHook::with_raster(None),
        );
        let _ = agent.handle_input(&Event::Paste("中".to_owned()), &registry);
        let ctx = deferred_probe_ctx(&agent);
        crate::clipboard::clear_clipboard_probe_hook();
        let ctx = ctx.expect("a short bracketed paste with a raster must defer a probe");
        assert!(
            ctx.source.is_bracketed(),
            "bracketed source must let the probe verify payload origin"
        );
        assert_eq!(ctx.source.text(), Some("中"));
        assert_eq!(
            ctx.source.synchronous_insertion(),
            Some(crate::app::actions::ClipboardTextInsertion::Inserted)
        );
        assert_eq!(agent.prompt.text(), "中");
    }
    #[test]
    fn agent_empty_paste_key_defers_probe() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
            snapshot_supported: Some(true),
            snapshot: Some((Some(1), false)),
            ..Default::default()
        });
        let _ = agent.handle_prompt_key_for_test(&ctrl_v_key());
        let calls = crate::clipboard::clipboard_probe_call_count();
        let target = deferred_probe_target(&agent);
        crate::clipboard::clear_clipboard_probe_hook();
        assert_eq!(calls, 0, "probe must NOT run inline on the event loop");
        assert!(matches!(
            target,
            Some(crate::app::actions::ClipboardPasteTarget::AgentPrompt { .. })
        ));
        assert!(agent.prompt.images.is_empty());
    }
    #[test]
    fn failed_clipboard_text_read_is_carried_into_deferred_context() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
            text_read_failed: true,
            ..crate::clipboard::ClipboardProbeHook::snapshot_unavailable()
        });
        let _ = agent.handle_prompt_key_for_test(&ctrl_v_key());
        let ctx =
            deferred_probe_ctx(&agent).expect("failed text read must still probe attachments");
        crate::clipboard::clear_clipboard_probe_hook();
        assert!(ctx.source.text_read_failed());
    }
    #[test]
    fn agent_completion_attaches_image_to_prompt() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let pasted = crate::prompt_images::from_clipboard_data(&test_image_data());
        let ctx = agent_completion_ctx(&agent, Some("caption"));
        let completion = agent.complete_clipboard_attachment_paste(
            ctx,
            crate::app::actions::ProbedAttachment::Image(pasted),
            None,
        );
        assert_eq!(
            completion,
            crate::app::actions::ClipboardPasteCompletion::Handled
        );
        assert_eq!(
            agent.prompt.images.len(),
            1,
            "deferred image attaches on completion"
        );
        assert!(agent.prompt.text().contains("[Image #"));
        assert!(
            !agent.prompt.text().contains("caption"),
            "caption must not be inserted when the probe returns an image: {:?}",
            agent.prompt.text()
        );
    }
    /// Image-wins across the deferral boundary: a Cmd+V with both a caption and a
    /// raster attaches ONLY the image (caption suppressed) — never image + caption.
    #[test]
    fn agent_cmd_v_image_wins_no_double_insert() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
            text: Some("caption text".to_string()),
            ..crate::clipboard::ClipboardProbeHook::with_raster(None)
        });
        let _ = agent.handle_prompt_key_for_test(&ctrl_v_key());
        let ctx = deferred_probe_ctx(&agent).expect("probe deferred");
        crate::clipboard::clear_clipboard_probe_hook();
        let pasted = crate::prompt_images::from_clipboard_data(&test_image_data());
        agent.complete_clipboard_attachment_paste(
            ctx,
            crate::app::actions::ProbedAttachment::Image(pasted),
            None,
        );
        assert_eq!(agent.prompt.images.len(), 1);
        assert!(agent.prompt.text().contains("[Image #1]"));
        assert!(
            !agent.prompt.text().contains("caption text"),
            "caption must not double-insert alongside the image: {:?}",
            agent.prompt.text()
        );
    }
    /// No-image miss on an image-wins Cmd+V inserts the carried caption instead.
    #[test]
    fn agent_cmd_v_caption_inserted_on_no_image_miss() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let ctx = agent_completion_ctx(&agent, Some("caption text"));
        let completion = agent.complete_clipboard_attachment_paste(
            ctx,
            crate::app::actions::ProbedAttachment::NoRaster,
            None,
        );
        assert_eq!(
            completion,
            crate::app::actions::ClipboardPasteCompletion::Handled
        );
        assert!(agent.prompt.images.is_empty());
        assert_eq!(
            agent.prompt.text(),
            "caption text",
            "the carried caption inserts when the probe finds no image"
        );
    }
    #[test]
    fn agent_deferred_caption_survives_failed_or_dropped_probe() {
        use crate::app::actions::{
            ClipboardPasteCompletion, ClipboardPasteFailure, ProbedAttachment,
        };
        for (probe, expected) in [
            (
                ProbedAttachment::ProbeFailed,
                ClipboardPasteCompletion::Failed(ClipboardPasteFailure::AttachmentRead),
            ),
            (
                ProbedAttachment::ProbeDropped,
                ClipboardPasteCompletion::Dropped,
            ),
        ] {
            let mut agent = make_agent();
            agent.set_active_pane(ActivePane::Prompt, true);
            let ctx = agent_completion_ctx(&agent, Some("caption text"));
            let completion = agent.complete_clipboard_attachment_paste(ctx, probe, None);
            assert_eq!(completion, expected);
            assert_eq!(agent.prompt.text(), "caption text");
        }
    }
    #[test]
    fn canonical_text_insertion_handles_same_text_replacement() {
        let mut agent = make_agent();
        agent.prompt.set_text("same text");
        agent.prompt.textarea.set_selection(0, "same text".len());
        let (_, completion) = agent.insert_prompt_plain_text(Some("same text"));
        assert_eq!(
            completion,
            crate::app::actions::ClipboardTextInsertion::Inserted
        );
        assert_eq!(agent.prompt.text(), "same text");
    }
    #[test]
    fn empty_and_whitespace_bracketed_completion_are_not_handled() {
        use crate::app::actions::{
            ClipboardPasteCompletion, ClipboardTextInsertion, ProbedAttachment,
        };
        for text in ["", " \n\t"] {
            let mut agent = make_agent();
            let (_, synchronous) = agent.insert_bracketed_prompt_text(text);
            assert_eq!(synchronous, ClipboardTextInsertion::Empty);
            let mut ctx = agent_completion_ctx(&agent, Some(text));
            ctx.source = crate::app::actions::ClipboardPasteSource::BracketedInserted {
                text: text.to_owned(),
                insertion: synchronous,
            };
            let completion =
                agent.complete_clipboard_attachment_paste(ctx, ProbedAttachment::NoRaster, None);
            assert_eq!(
                completion,
                ClipboardPasteCompletion::FullMiss,
                "bracketed payload {text:?}"
            );
        }
    }
    #[test]
    fn bracketed_probe_failure_or_drop_wins_over_synchronous_text() {
        use crate::app::actions::{
            ClipboardPasteCompletion, ClipboardPasteFailure, ClipboardTextInsertion,
            ProbedAttachment,
        };
        for (probe, expected) in [
            (
                ProbedAttachment::ProbeFailed,
                ClipboardPasteCompletion::Failed(ClipboardPasteFailure::AttachmentRead),
            ),
            (
                ProbedAttachment::ProbeDropped,
                ClipboardPasteCompletion::Dropped,
            ),
        ] {
            let mut agent = make_agent();
            let (_, synchronous) = agent.insert_bracketed_prompt_text("inserted");
            assert_eq!(synchronous, ClipboardTextInsertion::Inserted);
            let mut ctx = agent_completion_ctx(&agent, Some("inserted"));
            ctx.source = crate::app::actions::ClipboardPasteSource::BracketedInserted {
                text: "inserted".to_owned(),
                insertion: synchronous,
            };
            let completion = agent.complete_clipboard_attachment_paste(ctx, probe, None);
            assert_eq!(completion, expected);
        }
    }
    #[test]
    fn failed_clipboard_text_read_cannot_become_full_miss() {
        let mut agent = make_agent();
        let mut ctx = agent_completion_ctx(&agent, None);
        ctx.source = crate::app::actions::ClipboardPasteSource::ClipboardKey {
            text: crate::app::actions::ClipboardTextRead::Failed,
            tip_showing: false,
        };
        let completion = agent.complete_clipboard_attachment_paste(
            ctx,
            crate::app::actions::ProbedAttachment::NoRaster,
            None,
        );
        assert_eq!(
            completion,
            crate::app::actions::ClipboardPasteCompletion::Failed(
                crate::app::actions::ClipboardPasteFailure::TextRead,
            )
        );
    }
    #[test]
    fn agent_completion_inserts_unreadable_file_url_as_path_text() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let ctx = agent_completion_ctx(&agent, None);
        let completion = agent.complete_clipboard_attachment_paste(
            ctx,
            crate::app::actions::ProbedAttachment::NoRaster,
            Some("file:///definitely/missing/xai-primary-paste.png".to_owned()),
        );
        assert_eq!(
            completion,
            crate::app::actions::ClipboardPasteCompletion::Handled
        );
        assert_eq!(
            agent.prompt.text(),
            "/definitely/missing/xai-primary-paste.png "
        );
        assert!(agent.prompt.images.is_empty());
    }
    #[test]
    fn agent_completion_distinguishes_dropped_and_failed_probes() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let ctx = agent_completion_ctx(&agent, None);
        let dropped = agent.complete_clipboard_attachment_paste(
            ctx,
            crate::app::actions::ProbedAttachment::ProbeDropped,
            None,
        );
        let ctx = agent_completion_ctx(&agent, None);
        let failed = agent.complete_clipboard_attachment_paste(
            ctx,
            crate::app::actions::ProbedAttachment::ProbeFailed,
            None,
        );
        let ctx = agent_completion_ctx(&agent, None);
        let persist_failed = agent.complete_clipboard_attachment_paste(
            ctx,
            crate::app::actions::ProbedAttachment::PersistFailed("disk full".to_owned()),
            None,
        );
        assert_eq!(
            dropped,
            crate::app::actions::ClipboardPasteCompletion::Dropped
        );
        assert_eq!(
            failed,
            crate::app::actions::ClipboardPasteCompletion::Failed(
                crate::app::actions::ClipboardPasteFailure::AttachmentRead,
            )
        );
        assert_eq!(
            persist_failed,
            crate::app::actions::ClipboardPasteCompletion::Failed(
                crate::app::actions::ClipboardPasteFailure::AlreadyReported,
            )
        );
    }
    /// The `ContextualTip { ImageInput, Accepted }` funnel survives the deferral:
    /// a Cmd+V while the clipboard-image tip is showing emits the accept event
    /// when the deferred probe attaches an image.
    #[test]
    fn agent_cmd_v_tip_accept_emitted_on_deferred_image() {
        let mut agent = make_agent();
        agent.set_active_pane(ActivePane::Prompt, true);
        let _ = agent.ephemeral_tip.show(
            crate::tips::clipboard_focus::clipboard_image_tip(),
            &mut std::collections::HashMap::new(),
        );
        crate::clipboard::set_clipboard_probe_hook(
            crate::clipboard::ClipboardProbeHook::with_raster(None),
        );
        let _ = agent.handle_prompt_key_for_test(&ctrl_v_key());
        let ctx = deferred_probe_ctx(&agent).expect("probe deferred");
        crate::clipboard::clear_clipboard_probe_hook();
        assert!(
            ctx.source.tip_showing(),
            "tip-showing must thread into the deferred ctx"
        );
        let pasted = crate::prompt_images::from_clipboard_data(&test_image_data());
        agent.complete_clipboard_attachment_paste(
            ctx,
            crate::app::actions::ProbedAttachment::Image(pasted),
            None,
        );
        assert_eq!(agent.prompt.images.len(), 1);
    }
}
