//! Queued-prompt editing (`PromptMode::EditingQueued`) state machine.
//!
//! Extracted from `agent_view.rs` as a sibling `impl AgentView` block (same
//! pattern as kigi-shell's `compaction.rs`): entry from the queue pane,
//! editing-mode key intercepts, the dirty-edit focus lock, and the
//! exit/cleanup paths.
//!
//! Stash invariant: `stashed_prompt` is set exactly once on entry
//! (`enter_queue_edit`) and `take()`n exactly once on exit —
//! `exit_editing_mode` is the sole restore owner: every exit (including
//! lost-row cancel) restores the draft exactly once.
//!
//! A dirty pane switch is blocked and never arms the undrawn `EditConfirm`
//! modal, which would otherwise capture all input invisibly.

use crossterm::event::{KeyCode, KeyEvent};

use crate::key;
use crate::views::modal::{ActiveModal, EditConfirmResult, ModalConfirmation};
use crate::views::queue_pane::QueueRowRef;

use super::actions::Action;
use super::agent_view::{AgentPane, AgentView, PromptInputMode};
use super::app_view::InputOutcome;

/// State of the prompt widget's editing context.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromptMode {
    /// Normal mode: typing a new prompt to send.
    Normal,
    /// Editing a queued prompt.
    EditingQueued {
        /// Stable selection ID of the prompt being edited. For local rows
        /// this is the `QueuedPrompt.id` monotonic counter; for server rows
        /// this is the synthesized `QueuedPromptEntry.id` (a hash of
        /// `server_id`) so [`AgentView::queue`] selection works uniformly.
        id: u64,
        /// Snapshot of the original text (for dirty detection).
        original: String,
        /// When `Some`, this is a server-authoritative shared-queue row and
        /// `server_id` is the agent's stable `prompt_id`. On save we route
        /// the change through `Action::QueueEditShared` (and rely on the
        /// `kigi/queue/changed` rebroadcast for the visual result) instead
        /// of mutating the local `pending_prompts` mirror. `None` is the
        /// pre-existing local-origin path.
        server_id: Option<String>,
        /// Kind snapshot for the interject guard's vanished-row fallback.
        kind: crate::app::agent::QueueEntryKind,
    },
}

impl AgentView {
    /// Editing-mode key intercepts for the prompt pane.
    ///
    /// Bare Enter saves, Esc cancels.
    /// Interject-key handling for edit mode lives in
    /// `interject_editing_queued_intercept`, reached via the
    /// `ActionId::InterjectPrompt` registry arm in `handle_prompt_key` (the
    /// binding is remappable, so it cannot be matched on a raw key here).
    /// Shift-Enter / Alt-Enter fall through to widget (newline insertion).
    /// Tab removed as cancel trigger — too easy to hit accidentally.
    /// Ctrl-C on empty prompt also cancels (matches cancel-turn pattern).
    ///
    /// Returns `None` when not editing or for any unhandled key — the key
    /// MUST fall through to the widget (typing, newline insertion).
    pub(super) fn handle_editing_queued_key(&mut self, key: &KeyEvent) -> Option<InputOutcome> {
        if let PromptMode::EditingQueued { id, server_id, .. } = &self.prompt_mode {
            let (id, server_id) = (*id, server_id.clone());
            let ctrl_c_empty = key!('c', CONTROL).matches(key) && self.prompt.text().is_empty();

            if key!(Enter).matches(key) && !self.prompt.text().trim().is_empty() {
                return Some(self.save_edited_queued_row(id, server_id, true));
            }
            if key.code == KeyCode::Esc || ctrl_c_empty {
                // Discard changes.
                self.exit_editing_mode();
                return Some(InputOutcome::Action(Action::DrainQueue));
            }
            // Everything else (including Shift-Enter, Alt-Enter, typing) falls through.
        }
        None
    }

    /// Dirty-edit focus lock checked by `set_active_pane`.
    ///
    /// `None` = not editing / no lock — the caller proceeds with the normal
    /// pane switch. `Some(switched)` = the lock handled the switch: `false`
    /// when blocked behind the confirm modal, `true` after a clean exit.
    pub(super) fn editing_lock_on_pane_switch(&mut self, target: AgentPane) -> Option<bool> {
        if let PromptMode::EditingQueued { ref original, .. } = self.prompt_mode
            && target != AgentPane::Prompt
        {
            let dirty = self.prompt.text() != original;
            if dirty {
                // Block the switch; never arm `EditConfirm` here (it has no draw
                // arm, so it would capture all input invisibly). Resolve with Enter/Esc.
                // Clear the overlay-focus flip toggle callers set before switching.
                match target {
                    AgentPane::Queue => self.queue.overlay.focused = false,
                    AgentPane::Todo => self.todo.overlay.focused = false,
                    AgentPane::Tasks => self.tasks.overlay.focused = false,
                    AgentPane::Catalog => self.catalog.overlay.focused = false,
                    _ => {}
                }
                self.show_toast("Editing a queued prompt: press Enter to save, Esc to discard");
                return Some(false); // blocked, no modal armed
            }
            // Clean edit — silently exit editing mode.
            self.exit_editing_mode();
            self.active_pane = target;
            return Some(true);
        }
        None
    }

    /// Resolve an `EditConfirm` modal keypress (the modal has already been
    /// `take()`n out of `active_modal` by `handle_modal_key`).
    pub(super) fn handle_edit_confirm_choice(
        &mut self,
        confirm: ModalConfirmation<EditConfirmResult>,
        pending_target: AgentPane,
        ch: char,
    ) -> InputOutcome {
        if let Some(result) = confirm.resolve(ch) {
            let was_drain_blocked = self.drain_blocked();
            match result {
                EditConfirmResult::Cancel => {
                    // Dismiss dialog, stay in editing mode.
                    // (active_modal already taken)
                }
                EditConfirmResult::Save => {
                    // Empty edit: keep the original row text — a
                    // queued prompt must never be blanked by Save.
                    if self.prompt.text().trim().is_empty() {
                        self.exit_editing_mode();
                        self.set_active_pane(pending_target, true);
                        if was_drain_blocked {
                            return InputOutcome::Action(Action::DrainQueue);
                        }
                        return InputOutcome::Changed;
                    }
                    let outcome = match self.prompt_mode.clone() {
                        PromptMode::EditingQueued { id, server_id, .. } => {
                            // Drain only when "save & send" was the
                            // advertised label (see helper doc).
                            self.save_edited_queued_row(id, server_id, was_drain_blocked)
                        }
                        // Unreachable in practice: the modal only
                        // opens from `EditingQueued`.
                        _ => {
                            self.exit_editing_mode();
                            InputOutcome::Changed
                        }
                    };
                    self.set_active_pane(pending_target, true);
                    return outcome;
                }
                EditConfirmResult::Discard => {
                    // Discard changes (revert to original), exit editing.
                    self.exit_editing_mode();
                    self.set_active_pane(pending_target, true);
                    if was_drain_blocked {
                        return InputOutcome::Action(Action::DrainQueue);
                    }
                    return InputOutcome::Changed;
                }
                EditConfirmResult::Delete => {
                    // Delete the prompt entirely from the queue.
                    // Server-origin rows route through
                    // `Action::QueueRemoveShared`; local rows mutate
                    // the mirror.
                    if let PromptMode::EditingQueued {
                        id: _,
                        server_id: Some(server_id),
                        ..
                    } = self.prompt_mode.clone()
                    {
                        let expected_version = self
                            .shared_queue
                            .iter()
                            .find(|e| e.id == server_id)
                            .map(|e| e.version)
                            .unwrap_or(0);
                        self.exit_editing_mode();
                        self.set_active_pane(pending_target, true);
                        return InputOutcome::Action(Action::QueueRemoveShared {
                            id: server_id,
                            expected_version,
                        });
                    }
                    if let PromptMode::EditingQueued { id, .. } = self.prompt_mode {
                        self.session.pending_prompts.retain(|p| p.id != id);
                    }
                    self.exit_editing_mode();
                    self.set_active_pane(pending_target, true);
                    // If drain was blocked and we deleted the front,
                    // the next prompt (if any) should now drain.
                    if was_drain_blocked {
                        return InputOutcome::Action(Action::DrainQueue);
                    }
                    return InputOutcome::Changed;
                }
            }
        } else {
            // Key didn't match any option — restore modal, keep blocking.
            self.active_modal = Some(ActiveModal::EditConfirm {
                modal: confirm,
                pending_target,
            });
        }
        InputOutcome::Changed
    }

    /// Enter editing mode for the queue row selected via
    /// `QueueEvent::EditSelected` (called from `handle_queue_key`).
    pub(super) fn enter_queue_edit(&mut self, id: u64, is_server: bool, row: Option<QueueRowRef>) {
        use crate::app::agent::QueueEntryKind;
        type QueueEditEntryData = (
            String,
            QueueEntryKind,
            Option<String>,
            Vec<crate::prompt_images::PastedImage>,
            Vec<crate::app::agent::ChipElement>,
        );

        // Resolve text + display kind from whichever mirror owns
        // the row, plus the server `prompt_id` for server-origin
        // rows. The save path in `save_edited_queued_row` / the
        // modal-confirm `Save` arm branches on `server_id`.
        let entry_data: Option<QueueEditEntryData> = if is_server {
            row.as_ref()
                .and_then(|r| r.server_id.clone())
                .and_then(|server_id| {
                    self.shared_queue
                        .iter()
                        .find(|e| e.id == server_id)
                        .map(|w| {
                            (
                                w.text.clone(),
                                crate::views::queue_pane::kind_from_wire(&w.kind),
                                Some(server_id),
                                Vec::new(),
                                Vec::new(),
                            )
                        })
                })
        } else {
            // Only local rows own image and chip state.
            self.session
                .pending_prompts
                .iter()
                .find(|p| p.id == id)
                .map(|p| {
                    (
                        p.text.clone(),
                        p.kind,
                        None,
                        p.images.clone(),
                        p.chip_elements.clone(),
                    )
                })
        };
        if let Some((text, kind, server_id, images, chip_elements)) = entry_data {
            self.stashed_prompt = if self.prompt.text().is_empty() {
                None
            } else {
                Some(self.prompt.stash())
            };
            // Load queued text and enter editing mode.
            // Set prompt_input_mode based on entry kind so the prompt
            // renders with the correct visual (yellow `!` prefix
            // for bash entries, normal for prompts/commands).
            self.prompt
                .restore(crate::views::prompt_widget::StashedPrompt::from_submission(
                    text.clone(),
                    images,
                    chip_elements,
                ));
            // `server_id: Some(_)` routes the save through
            // `Action::QueueEditShared` (server LWW); `None` is
            // the existing local-mirror mutation path.
            self.prompt_mode = PromptMode::EditingQueued {
                id,
                original: text,
                server_id,
                kind,
            };
            self.prompt_input_mode = if kind == QueueEntryKind::BashCommand {
                PromptInputMode::Bash
            } else {
                PromptInputMode::Normal
            };
            self.set_active_pane(AgentPane::Prompt, false);
        }
    }

    /// Save the edited composer text back to the queued row and exit edit
    /// mode. Single owner of the save invariants for the bare-Enter
    /// intercept, the idle edit-interject, and the modal Save arm.
    ///
    /// `drain`: whether a local-row save requests a queue drain. Enter-save
    /// and idle edit-interject always drain (the user just released the
    /// front edit lock); modal Save drains only when the drain was blocked
    /// on this edit — a plain save of a non-front row must not start the
    /// head prompt's turn.
    fn save_edited_queued_row(
        &mut self,
        id: u64,
        server_id: Option<String>,
        drain: bool,
    ) -> InputOutcome {
        match server_id {
            Some(server_id) => {
                let new_text = self.prompt.text().to_string();
                // Server-origin row: route the edit through the agent (LWW).
                // The rebroadcast updates every client's shared queue mirror
                // — do NOT mutate locally.
                self.exit_editing_mode();
                InputOutcome::Action(Action::QueueEditShared {
                    id: server_id,
                    new_text,
                })
            }
            None => {
                let edited = self.prompt.stash();
                let (new_text, mut images, chip_elements) = edited.into_submission();
                // Local row: in-place mutation (existing behavior).
                // Recompute token ranges for the edited text — the stale
                // ranges would point at the pre-edit byte offsets.
                let skill_token_ranges = self
                    .prompt
                    .slash_controller
                    .recognized_token_ranges(&new_text, &self.session.models);
                if let Some(entry) = self.session.pending_prompts.iter_mut().find(|p| p.id == id) {
                    let retained: std::collections::HashSet<u64> = images
                        .iter()
                        .map(|image| image.preview.identity())
                        .collect();
                    for old in entry.images.drain(..) {
                        if !retained.contains(&old.preview.identity()) {
                            crate::prompt_images::cleanup_temp_file(&old);
                        }
                    }
                    entry.text = new_text;
                    entry.images = std::mem::take(&mut images);
                    entry.chip_elements = chip_elements;
                    entry.skill_token_ranges = skill_token_ranges;
                    // Clear stale wire_blocks — edited text may no longer match
                    // the original skill invocation. The prompt will be sent as
                    // plain text via the normal path. If it still starts with `/`,
                    // the shell's resolve() handles it.
                    entry.wire_blocks = None;
                    // display_as_skill rides wire_blocks (see its field doc) — clear both
                    // together, or the drain keeps stale skill styling over the ranges.
                    entry.display_as_skill = false;
                }
                crate::prompt_images::drain_and_cleanup(&mut images);
                self.exit_editing_mode();
                if drain {
                    InputOutcome::Action(Action::DrainQueue)
                } else {
                    InputOutcome::Changed
                }
            }
        }
    }

    /// Interject-key intercept while editing a queued row, delegated from
    /// the `ActionId::InterjectPrompt` registry arm in `handle_prompt_key`.
    /// Falling through would strand `EditingQueued` with the row still
    /// queued (dirty-modal loop + blocked drain). `None` = not editing —
    /// the arm proceeds with its normal interject handling.
    pub(super) fn interject_editing_queued_intercept(&mut self) -> Option<InputOutcome> {
        if let PromptMode::EditingQueued {
            id,
            server_id,
            kind,
            ..
        } = &self.prompt_mode
        {
            let (id, server_id, kind) = (*id, server_id.clone(), *kind);
            return Some(self.interject_edited_queued(id, server_id, kind));
        }
        None
    }

    /// Interject key pressed while editing a queued row: turn running →
    /// interject the EDITED text and remove the row; idle → bare-Enter
    /// save; empty composer → no-op, stay in edit mode.
    fn interject_edited_queued(
        &mut self,
        id: u64,
        server_id: Option<String>,
        kind: crate::app::agent::QueueEntryKind,
    ) -> InputOutcome {
        let text = self.prompt.text().trim().to_string();
        if text.is_empty() {
            return InputOutcome::Changed;
        }
        if !self.session.state.is_turn_running() {
            return self.save_edited_queued_row(id, server_id, true);
        }
        // Non-prompt rows stay queued (see `queue_row_prompt_like`): save the edit.
        let row_prompt_like = self.queue_row_prompt_like(id);
        if row_prompt_like == Some(false) {
            self.show_toast("Can't send this mid-turn — it runs when the current turn ends");
            return self.save_edited_queued_row(id, server_id, true);
        }
        if row_prompt_like.is_none() && kind != crate::app::agent::QueueEntryKind::Prompt {
            self.show_toast("Queued prompt is no longer in the queue");
            return self.save_edited_queued_row(id, server_id, true);
        }
        match server_id {
            Some(server_id) => {
                // Server rows: the queue wire (`kigi/queue/interject` newText)
                // is text-only, so composer images can't ride along — known
                // limitation, dropped with an accurate toast.
                if !self.prompt.images.is_empty() {
                    self.prompt.images.clear();
                    self.show_toast("Images can't be attached when editing a shared queued prompt");
                }
                // new_text carries the edit — without it the agent would
                // interject the original server-side text.
                let expected_version = self.queue.row_ref(id).map(|r| r.version);
                self.exit_editing_mode();
                match expected_version {
                    Some(expected_version) => InputOutcome::Action(Action::QueueInterjectShared {
                        id: server_id,
                        expected_version,
                        new_text: Some(text),
                    }),
                    // Row vanished from the mirror — just interject the text.
                    None => InputOutcome::Action(Action::Interject {
                        text,
                        images: vec![],
                    }),
                }
            }
            None => {
                // Exit before row removal so auto-hide cannot re-enter or strand edit mode.
                let edited = self.prompt.stash();
                let (_, images, _) = edited.into_submission();
                self.exit_editing_mode();
                if let Some(mut row) = self.remove_local_queue_row(id) {
                    let retained: std::collections::HashSet<u64> = images
                        .iter()
                        .map(|image| image.preview.identity())
                        .collect();
                    for old in row.images.drain(..) {
                        if !retained.contains(&old.preview.identity()) {
                            crate::prompt_images::cleanup_temp_file(&old);
                        }
                    }
                }
                InputOutcome::Action(Action::Interject { text, images })
            }
        }
    }

    /// Whether the drain is blocked because the user is editing the front prompt.
    pub(crate) fn drain_blocked(&self) -> bool {
        if let PromptMode::EditingQueued { id, .. } = &self.prompt_mode {
            self.session.state.is_idle()
                && self
                    .session
                    .pending_prompts
                    .front()
                    .is_some_and(|p| p.id == *id)
        } else {
            false
        }
    }

    /// Exit a server-origin queue edit whose row vanished, restoring the pre-edit draft.
    pub(crate) fn cancel_editing_queued_for_lost_row(&mut self) {
        if !matches!(
            self.prompt_mode,
            PromptMode::EditingQueued {
                server_id: Some(_),
                ..
            }
        ) {
            return;
        }
        // Restore the pre-edit draft; keeping the orphaned edit text would look
        // "duplicated" (the row is now the running turn). A concurrent-removal edit is lost.
        self.exit_editing_mode();
        self.show_toast("Queued prompt is no longer in the queue");
    }

    /// Exit editing mode: restore stashed text, clear mode, focus queue pane.
    /// No-op unless `EditingQueued`.
    ///
    /// Always resets `prompt_input_mode` to `Normal` so it doesn't leak
    /// into subsequent normal prompt entry.
    pub(super) fn exit_editing_mode(&mut self) {
        // Idempotent: remove_local_queue_row's guard may have exited already;
        // a second take() of the spent stash would wipe the composer.
        if !matches!(self.prompt_mode, PromptMode::EditingQueued { .. }) {
            return;
        }
        let stash = self.stashed_prompt.take().unwrap_or_default();
        self.prompt.restore(stash);
        self.finish_editing_exit();
    }

    /// Shared tail of every edit exit — stash policy stays with the callers.
    fn finish_editing_exit(&mut self) {
        self.prompt_mode = PromptMode::Normal;
        self.prompt_input_mode = PromptInputMode::Normal;
        // Editing is over, so a pending EditConfirm is meaningless — left
        // behind it would eat all input without ever rendering. Other
        // modal variants are untouched.
        if matches!(self.active_modal, Some(ActiveModal::EditConfirm { .. })) {
            self.active_modal = None;
        }
        // Return focus to queue pane (if still visible).
        // Force=true: we just cleared editing mode, no lock to check.
        if self.queue.is_visible() {
            self.set_active_pane(AgentPane::Queue, true);
        } else {
            self.set_active_pane(AgentPane::Scrollback, true);
        }
    }
}

#[cfg(test)]
mod tests {
    use agent_client_protocol as acp;
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

    use crate::app::actions::{Action, Effect};
    use crate::app::agent::AgentState;
    use crate::app::agent_view::test_fixtures::{
        force_interject_key, make_running_agent, non_vscode_registry, running_agent_local_only,
        test_pasted_image,
    };
    use crate::app::agent_view::{AgentPane, AgentView, PromptMode};
    use crate::app::app_view::InputOutcome;
    use crate::app::dispatch::maybe_drain_queue;
    use crate::scrollback::block::RenderBlock;
    use crate::views::modal::{ActiveModal, ModalConfirmation};

    fn edit_key() -> KeyEvent {
        // Default key binding for QueueEvent::EditSelected.
        KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)
    }

    fn delete_key() -> KeyEvent {
        // Default key binding for QueueEvent::DeleteSelected.
        KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)
    }

    fn enter_key() -> KeyEvent {
        KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
    }

    fn attach_image_to_local_row(agent: &mut AgentView) {
        let mut image = test_pasted_image();
        image.display_number = 1;
        let row = agent.session.pending_prompts.front_mut().unwrap();
        row.text = "local one [Image #1] ".into();
        row.images = vec![image];
        row.chip_elements = vec![crate::app::agent::ChipElement {
            range: 10..20,
            kind: crate::views::prompt_widget::KIND_IMAGE,
            display: None,
        }];
        agent.queue.sync_from_merged(
            &agent.session.pending_prompts,
            &agent.shared_queue,
            None,
            None,
            &agent.send_now_painted_blocks,
        );
    }

    /// Editing a Server-origin row enters `EditingQueued` with `server_id`
    /// populated (the new behavior replacing the "isn't supported yet" toast).
    #[test]
    fn edit_server_row_enters_editing_queued_with_server_id() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();

        let ids = agent.queue.entry_ids();
        // Server row first.
        agent.queue.list_state.select_by_id(ids[0]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);

        match &agent.prompt_mode {
            PromptMode::EditingQueued {
                server_id: Some(sid),
                original,
                ..
            } => {
                assert_eq!(sid, "p1");
                assert_eq!(original, "server one");
            }
            other => panic!("expected EditingQueued with server_id Some, got {other:?}"),
        }
        assert_eq!(agent.prompt.text(), "server one");
    }

    /// Submitting an edit on a server-origin row dispatches
    /// `Action::QueueEditShared` and does NOT mutate the local mirror.
    #[test]
    fn submit_server_edit_routes_to_action_no_local_mutation() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[0]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);

        // Type a replacement.
        agent.prompt.set_text("server one EDITED");

        let outcome = agent.handle_prompt_key_for_test(&enter_key());
        match outcome {
            InputOutcome::Action(Action::QueueEditShared { id, new_text }) => {
                assert_eq!(id, "p1");
                assert_eq!(new_text, "server one EDITED");
            }
            other => panic!("expected QueueEditShared, got {other:?}"),
        }
        // Local mirror untouched.
        assert_eq!(agent.shared_queue.len(), 1);
        assert_eq!(agent.shared_queue[0].text, "server one");
        // EditingQueued cleared.
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
    }

    #[test]
    fn shared_queue_edit_rejects_image_before_normal_save() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();
        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[0]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        let ctx = 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(None),
                tip_showing: false,
            },
        };
        let completion = agent.complete_clipboard_attachment_paste(
            ctx,
            crate::app::actions::ProbedAttachment::Image(test_pasted_image()),
            None,
        );
        assert_eq!(
            completion,
            crate::app::actions::ClipboardPasteCompletion::Failed(
                crate::app::actions::ClipboardPasteFailure::AlreadyReported,
            )
        );
        assert!(agent.prompt.images.is_empty());
        assert!(!agent.prompt.text().contains("[Image #"));
        assert!(agent.toast.is_some());

        agent.prompt.set_text("server one EDITED");
        let outcome = agent.handle_prompt_key_for_test(&enter_key());
        match outcome {
            InputOutcome::Action(Action::QueueEditShared { new_text, .. }) => {
                assert_eq!(new_text, "server one EDITED");
                assert!(!new_text.contains("[Image #"));
            }
            other => panic!("expected QueueEditShared, got {other:?}"),
        }
    }

    /// Submitting an edit on a local-origin row continues to mutate the local
    /// `pending_prompts` in place (existing behavior preserved).
    #[test]
    fn submit_local_edit_mutates_local_pending_prompts() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();

        let ids = agent.queue.entry_ids();
        // Local row is second (server rendered first).
        agent.queue.list_state.select_by_id(ids[1]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);

        agent.prompt.set_text("local one EDITED");

        let outcome = agent.handle_prompt_key_for_test(&enter_key());
        match outcome {
            InputOutcome::Action(Action::DrainQueue) => {}
            other => panic!("expected DrainQueue, got {other:?}"),
        }
        assert_eq!(agent.session.pending_prompts.len(), 1);
        assert_eq!(agent.session.pending_prompts[0].text, "local one EDITED");
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
    }

    /// Editing a skill-injection row clears `display_as_skill` alongside
    /// `wire_blocks`, so the drained echo styles the recomputed mid-text
    /// ranges (not stale leading-token skill styling) and the wire send is
    /// plain text.
    #[test]
    fn edit_skill_row_drains_with_recomputed_ranges_not_skill_styling() {
        let mut agent = running_agent_local_only();
        agent.session.state = AgentState::Idle;
        let registry = non_vscode_registry();
        // Advertise the skill so the edited text's mid-text token is recognized.
        let models = agent.session.models.clone();
        agent.prompt.sync_acp_commands(
            &[
                acp::AvailableCommand::new("pr-workflow", "PR workflow skill").meta(
                    serde_json::json!({
                        "path": "/tmp/skills/pr-workflow/SKILL.md",
                        "scope": "local",
                    })
                    .as_object()
                    .cloned(),
                ),
            ],
            None,
            &models,
        );
        // Turn the fixture row into an InjectSkill-shaped row.
        {
            let row = agent.session.pending_prompts.front_mut().unwrap();
            row.text = "/pr-workflow ship it".into();
            row.wire_blocks = Some(vec![acp::ContentBlock::Text(acp::TextContent::new(
                "<skill>pr-workflow instructions</skill>",
            ))]);
            row.display_as_skill = true;
        }
        agent.queue.sync_from_merged(
            &agent.session.pending_prompts,
            &agent.shared_queue,
            None,
            None,
            &agent.send_now_painted_blocks,
        );

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[0]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        agent.prompt.set_text("great /pr-workflow go");
        let outcome = agent.handle_prompt_key_for_test(&enter_key());
        assert!(matches!(outcome, InputOutcome::Action(Action::DrainQueue)));

        let effects = maybe_drain_queue(&mut agent);
        match &agent.scrollback.get(0).unwrap().block {
            RenderBlock::UserPrompt(b) => {
                assert_eq!(b.text, "great /pr-workflow go");
                assert_eq!(
                    b.skill_token_ranges,
                    vec![6..18],
                    "echo must style the recomputed mid-text token"
                );
            }
            other => panic!("expected UserPrompt, got {other:?}"),
        }
        match &effects[0] {
            Effect::SendPrompt {
                text,
                skill_token_ranges,
                ..
            } => {
                assert_eq!(text, "great /pr-workflow go");
                assert_eq!(skill_token_ranges.len(), 1);
                assert_eq!(skill_token_ranges[0], 6..18);
            }
            other => panic!("expected plain SendPrompt, got {other:?}"),
        }
    }

    #[test]
    fn cancel_editing_queued_for_lost_row_exits_when_server_origin() {
        let mut agent = make_running_agent();
        agent.stashed_prompt = Some(crate::views::prompt_widget::StashedPrompt {
            text: "pre-edit draft".into(),
            cursor: 0,
            images: Vec::new(),
            chip_elements: Vec::new(),
            image_counter: 0,
            image_undo_stash: Vec::new(),
        });
        agent.prompt.set_text("replacement typed over queued row");
        agent.prompt_mode = PromptMode::EditingQueued {
            id: 999,
            original: "server one".into(),
            server_id: Some("p1".into()),
            kind: crate::app::agent::QueueEntryKind::Prompt,
        };
        agent.cancel_editing_queued_for_lost_row();
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
        assert_eq!(agent.prompt.text(), "pre-edit draft");
        assert!(agent.stashed_prompt.is_none());
    }

    /// Idempotent: the cleanup hook is a no-op when not in `EditingQueued`
    /// with a server origin (e.g. local-origin edit, normal mode).
    #[test]
    fn cancel_editing_queued_for_lost_row_noop_for_local_or_normal() {
        let mut agent = make_running_agent();
        // Local-origin: should NOT exit (the local row hasn't disappeared).
        agent.prompt_mode = PromptMode::EditingQueued {
            id: 0,
            original: "local one".into(),
            server_id: None,
            kind: crate::app::agent::QueueEntryKind::Prompt,
        };
        agent.cancel_editing_queued_for_lost_row();
        assert!(matches!(
            agent.prompt_mode,
            PromptMode::EditingQueued { .. }
        ));

        // Normal mode: no-op.
        agent.prompt_mode = PromptMode::Normal;
        agent.cancel_editing_queued_for_lost_row();
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
    }

    /// A dirty pane switch must not arm the undrawn EditConfirm; lost-row cancel
    /// restores the pre-edit draft.
    #[test]
    fn dirty_pane_switch_blocks_without_modal_then_lost_row_restores_draft() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();
        agent.prompt.set_text("draft");

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[0]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        agent.prompt.set_text("server one EDITED");

        assert!(!agent.set_active_pane(AgentPane::Scrollback, false));
        assert!(
            agent.active_modal.is_none(),
            "dirty pane switch must never arm an invisible input-eating EditConfirm"
        );
        assert!(
            matches!(agent.prompt_mode, PromptMode::EditingQueued { .. }),
            "blocked switch keeps the user in the edit"
        );

        agent.cancel_editing_queued_for_lost_row();
        assert!(agent.active_modal.is_none());
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
        assert_eq!(agent.prompt.text(), "draft");
    }

    /// Ctrl+; (toggle_queue_pane) while dirty-editing must not brick: the switch
    /// is blocked, no modal armed, and the queue overlay is not left focused.
    #[test]
    fn toggle_queue_pane_while_dirty_editing_does_not_brick() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[0]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        agent.prompt.set_text("server one EDITED");
        assert!(matches!(
            agent.prompt_mode,
            PromptMode::EditingQueued { .. }
        ));

        agent.toggle_queue_pane();

        assert!(
            agent.active_modal.is_none(),
            "toggling the queue mid-edit must never arm an invisible EditConfirm"
        );
        assert!(
            matches!(agent.prompt_mode, PromptMode::EditingQueued { .. }),
            "the blocked switch keeps the user in the edit"
        );
        assert!(
            !agent.queue.overlay.focused,
            "a blocked switch must not leave the queue overlay focused while input is in the prompt"
        );
    }

    /// Interject key while editing a LOCAL queued row mid-turn: the row
    /// leaves the queue, the EDITED text interjects, edit mode exits, and
    /// the stashed composer draft is restored (the pre-fix bug stranded
    /// `EditingQueued` with the row still queued).
    #[test]
    fn edit_interject_local_row_interjects_edited_text() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();
        agent.prompt.set_text("draft");

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[1]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        agent.prompt.set_text("local one EDITED");

        let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
        match outcome {
            InputOutcome::Action(Action::Interject { text, .. }) => {
                assert_eq!(text, "local one EDITED");
            }
            other => panic!("expected Interject, got {other:?}"),
        }
        assert!(agent.session.pending_prompts.is_empty());
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
        assert_eq!(agent.prompt.text(), "draft");
    }

    #[test]
    fn queue_edit_cancel_restores_draft_image_state() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();
        attach_image_to_local_row(&mut agent);
        agent.prompt.set_text("draft ");
        let draft_end = agent.prompt.text().len();
        agent.prompt.set_cursor(draft_end);
        agent.prompt.insert_image(test_pasted_image()).unwrap();

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[1]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        assert_eq!(agent.prompt.text(), "local one [Image #1] ");
        assert_eq!(agent.prompt.images.len(), 1);
        agent.prompt.set_text("discard this edit");

        let outcome =
            agent.handle_prompt_key_for_test(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
        assert!(matches!(outcome, InputOutcome::Action(Action::DrainQueue)));
        assert_eq!(agent.prompt.text(), "draft [Image #1] ");
        assert_eq!(
            agent
                .prompt
                .textarea()
                .elements()
                .iter()
                .filter(|element| element.kind == crate::views::prompt_widget::KIND_IMAGE)
                .count(),
            1
        );
        assert_eq!(
            agent.prompt.drain_images().len(),
            1,
            "restored draft image must remain sendable"
        );
        let row = agent.session.pending_prompts.front().unwrap();
        assert_eq!(row.text, "local one [Image #1] ");
        assert_eq!(row.images.len(), 1);
        assert_eq!(row.chip_elements.len(), 1);
    }

    #[test]
    fn queue_edit_save_moves_complete_state_into_send() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();
        attach_image_to_local_row(&mut agent);

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[1]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        agent
            .prompt
            .insert_image(crate::prompt_images::from_clipboard_data(
                &crate::clipboard::ImageData {
                    data: vec![1, 2, 3],
                    mime_type: "image/png".into(),
                },
            ))
            .unwrap();

        let outcome = agent.handle_prompt_key_for_test(&enter_key());
        assert!(matches!(outcome, InputOutcome::Action(Action::DrainQueue)));
        let row = agent.session.pending_prompts.front().unwrap();
        assert_eq!(row.images.len(), 2);
        assert_eq!(
            row.chip_elements
                .iter()
                .filter(|chip| chip.kind == crate::views::prompt_widget::KIND_IMAGE)
                .count(),
            2
        );

        agent.session.state = AgentState::Idle;
        // Running server turn completed: its shared-queue row is gone, so the
        // local edited row is the next turn (server-owns-next-turn gate clears).
        agent.shared_queue.clear();
        let effects = maybe_drain_queue(&mut agent);
        assert!(matches!(
            effects.as_slice(),
            [Effect::SendPromptBlocks { .. }]
        ));
        let in_flight = agent.session.in_flight_prompt.as_ref().unwrap();
        assert_eq!(in_flight.images.len(), 2);
        assert_eq!(in_flight.chip_elements.len(), 2);
    }

    /// Lone-local-row agent with "draft" stashed, edit mode entered on the
    /// row. Interjecting empties the queue → the pane auto-hide switch runs
    /// mid-flow (the setup `make_running_agent` can't reach: its server row
    /// keeps the pane open).
    fn edit_lone_local_row() -> AgentView {
        let mut agent = running_agent_local_only();
        let registry = non_vscode_registry();
        agent.prompt.set_text("draft");
        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[0]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        // Fixture breakage must fail here, not in the flows under test.
        assert!(matches!(
            agent.prompt_mode,
            PromptMode::EditingQueued { .. }
        ));
        agent
    }

    /// Edit-interject of a local bash row saves the edit and keeps it queued.
    #[test]
    fn edit_interject_local_bash_row_saves_edit_and_keeps_kind() {
        use crate::app::agent::QueueEntryKind;
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();
        agent.session.pending_prompts.clear();
        agent.session.enqueue_bash_command("ls".into());
        agent.queue.sync_from_merged(
            &agent.session.pending_prompts,
            &agent.shared_queue,
            agent.session.current_prompt_id.as_deref(),
            agent.expect_send_now_cancel.as_deref(),
            &agent.send_now_painted_blocks,
        );

        let ids = agent.queue.entry_ids();
        // The local bash row renders after the fixture's server row.
        agent.queue.list_state.select_by_id(*ids.last().unwrap());
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        assert!(matches!(
            agent.prompt_mode,
            PromptMode::EditingQueued { .. }
        ));
        agent.prompt.set_text("ls -la");

        let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
        assert!(
            !matches!(outcome, InputOutcome::Action(Action::Interject { .. })),
            "bash edit-interject must not interject, got {outcome:?}"
        );
        let row = &agent.session.pending_prompts[0];
        assert_eq!(row.text, "ls -la", "edit must be saved");
        assert_eq!(row.kind, QueueEntryKind::BashCommand, "kind must survive");
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
        assert!(agent.toast.is_some(), "guard must explain itself");
    }

    /// Interject a DIRTY edit of the last visible queue row: the auto-hide
    /// pane switch must not strand an invisible `EditConfirm` modal that
    /// silently consumes all subsequent input.
    #[test]
    fn edit_interject_lone_local_row_dirty_leaves_no_orphaned_modal() {
        let mut agent = edit_lone_local_row();
        agent.prompt.set_text("local one EDITED");

        let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
        match outcome {
            InputOutcome::Action(Action::Interject { text, .. }) => {
                assert_eq!(text, "local one EDITED");
            }
            other => panic!("expected Interject, got {other:?}"),
        }
        assert!(agent.session.pending_prompts.is_empty());
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
        assert!(
            agent.active_modal.is_none(),
            "orphaned EditConfirm would brick input"
        );
        assert_eq!(agent.prompt.text(), "draft");
    }

    /// Interject a CLEAN (unmodified) edit of the last visible queue row:
    /// the auto-hide pane switch must not exit editing mode a second time
    /// and wipe the just-restored composer draft.
    #[test]
    fn edit_interject_lone_local_row_clean_restores_draft_once() {
        let mut agent = edit_lone_local_row();

        let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
        match outcome {
            InputOutcome::Action(Action::Interject { text, .. }) => {
                assert_eq!(text, "local one");
            }
            other => panic!("expected Interject, got {other:?}"),
        }
        assert!(agent.session.pending_prompts.is_empty());
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
        assert!(agent.active_modal.is_none());
        assert_eq!(
            agent.prompt.text(),
            "draft",
            "stash must be restored exactly once, not wiped by a double exit"
        );
    }

    /// Deleting the lone LOCAL row while it is being edited (dirty): the
    /// delete discards the edit — it must not leave `EditingQueued` pointing
    /// at a removed row or let the auto-hide arm the invisible modal.
    #[test]
    fn delete_edited_lone_local_row_discards_edit_without_orphaned_modal() {
        let mut agent = edit_lone_local_row();
        let registry = non_vscode_registry();
        agent.prompt.set_text("local one EDITED");

        let _ = agent.handle_queue_key(&delete_key(), &registry);

        assert!(agent.session.pending_prompts.is_empty());
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
        assert!(
            agent.active_modal.is_none(),
            "orphaned EditConfirm would brick input"
        );
        assert_eq!(agent.prompt.text(), "draft");
    }

    /// `PromptMode` for an in-progress edit of the fixture's local row.
    fn editing_lone_local() -> PromptMode {
        PromptMode::EditingQueued {
            id: 0,
            original: "local one".into(),
            server_id: None,
            kind: crate::app::agent::QueueEntryKind::Prompt,
        }
    }

    /// `exit_editing_mode` clears a stray `EditConfirm` (unreachable in-tree
    /// after the reorder, so pin the backstop directly) and leaves every
    /// other modal variant alone.
    #[test]
    fn exit_editing_mode_clears_only_stray_edit_confirm() {
        let mut agent = make_running_agent();
        agent.prompt_mode = editing_lone_local();
        agent.active_modal = Some(ActiveModal::EditConfirm {
            modal: ModalConfirmation::edit_confirm(),
            pending_target: AgentPane::Scrollback,
        });
        agent.exit_editing_mode();
        assert!(agent.active_modal.is_none());
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));

        // Scoping: a non-EditConfirm modal survives the exit untouched.
        // Re-enter edit mode so the guarded body (not the idempotency
        // early-return) is what leaves the palette alone.
        agent.prompt_mode = editing_lone_local();
        agent.active_modal = Some(ActiveModal::CommandPalette {
            entries: crate::views::modal::default_palette_entries(),
            state: crate::views::picker::PickerState::input_active(),
            window: crate::views::modal_window::ModalWindowState::new(),
        });
        agent.exit_editing_mode();
        assert!(matches!(
            agent.active_modal,
            Some(ActiveModal::CommandPalette { .. })
        ));
    }

    /// `exit_editing_mode` outside `EditingQueued` is a no-op — the
    /// remove-then-exit caller shape must not wipe the composer by
    /// double-taking the spent stash.
    #[test]
    fn exit_editing_mode_is_noop_when_not_editing() {
        let mut agent = make_running_agent();
        agent.prompt.set_text("draft");
        agent.exit_editing_mode();
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
        assert_eq!(agent.prompt.text(), "draft");
    }

    /// Interject key while editing a SERVER queued row mid-turn routes to
    /// `Action::QueueInterjectShared` with the edited text as `new_text`
    /// and exits edit mode without touching the local mirror.
    #[test]
    fn edit_interject_server_row_routes_to_queue_interject_shared_with_new_text() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[0]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        agent.prompt.set_text("server one EDITED");

        let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
        match outcome {
            InputOutcome::Action(Action::QueueInterjectShared {
                id,
                expected_version,
                new_text,
            }) => {
                assert_eq!(id, "p1");
                assert_eq!(expected_version, 2);
                assert_eq!(new_text.as_deref(), Some("server one EDITED"));
            }
            other => panic!("expected QueueInterjectShared with new_text, got {other:?}"),
        }
        // Shared mirror untouched — the rebroadcast is the source of truth.
        assert_eq!(agent.shared_queue.len(), 1);
        assert_eq!(agent.shared_queue[0].text, "server one");
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
    }

    /// Interject key while editing and IDLE behaves like the bare-Enter save
    /// (row text updated + drain) instead of silently dropping the key.
    #[test]
    fn edit_interject_while_idle_saves_local_edit() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[1]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        agent.session.state = AgentState::Idle;
        agent.prompt.set_text("local one EDITED");

        let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
        match outcome {
            InputOutcome::Action(Action::DrainQueue) => {}
            other => panic!("expected DrainQueue, got {other:?}"),
        }
        assert_eq!(agent.session.pending_prompts.len(), 1);
        assert_eq!(agent.session.pending_prompts[0].text, "local one EDITED");
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
    }

    /// Interject key with an empty composer while editing is a no-op — no
    /// empty interjection, and edit mode stays active.
    #[test]
    fn edit_interject_empty_composer_stays_in_edit_mode() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[1]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        agent.prompt.set_text("   ");

        let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
        assert!(
            matches!(outcome, InputOutcome::Changed),
            "empty edit-interject must be a no-op, got {outcome:?}"
        );
        assert!(matches!(
            agent.prompt_mode,
            PromptMode::EditingQueued { .. }
        ));
        assert_eq!(agent.session.pending_prompts.len(), 1);
    }

    /// Modal Save with an empty composer keeps the original row text — a
    /// queued prompt must never be blanked by Save.
    #[test]
    fn edit_confirm_save_empty_preserves_original_text() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[1]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        agent.prompt.set_text("");

        // Arm the modal directly (the pane switch no longer arms it) to test empty-Save.
        agent.active_modal = Some(ActiveModal::EditConfirm {
            modal: ModalConfirmation::edit_confirm(),
            pending_target: AgentPane::Scrollback,
        });
        let _ = agent.handle_modal_key(&KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));

        assert_eq!(agent.session.pending_prompts[0].text, "local one");
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
    }

    /// Modal Save on a NON-front local row while idle saves the text but must
    /// NOT dispatch `DrainQueue` — the modal advertised plain "save" (not
    /// "save & send"), and draining would start the head prompt's turn.
    #[test]
    fn edit_confirm_save_non_front_row_does_not_drain() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();
        agent.shared_queue.clear();
        agent.session.enqueue_prompt("local two".to_string());
        agent.queue.sync_from_merged(
            &agent.session.pending_prompts,
            &agent.shared_queue,
            None,
            None,
            &agent.send_now_painted_blocks,
        );
        agent.session.state = AgentState::Idle;

        // Edit the non-front row; arm the modal directly (the pane switch no longer arms it).
        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[1]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        agent.prompt.set_text("local two EDITED");
        agent.active_modal = Some(ActiveModal::EditConfirm {
            modal: ModalConfirmation::edit_confirm(),
            pending_target: AgentPane::Scrollback,
        });

        let outcome =
            agent.handle_modal_key(&KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
        assert!(
            !matches!(outcome, InputOutcome::Action(Action::DrainQueue)),
            "plain save must not drain the head prompt, got {outcome:?}"
        );
        assert_eq!(agent.session.pending_prompts.len(), 2);
        assert_eq!(agent.session.pending_prompts[1].text, "local two EDITED");
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
    }

    /// A bash row that vanished mid-edit saves via the kind snapshot, never plain-interjects.
    #[test]
    fn edit_interject_vanished_bash_row_saves_instead_of_plain_interject() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();
        agent.shared_queue[0].kind = "bash".into();
        agent.queue.sync_from_merged(
            &agent.session.pending_prompts,
            &agent.shared_queue,
            agent.session.current_prompt_id.as_deref(),
            agent.expect_send_now_cancel.as_deref(),
            &agent.send_now_painted_blocks,
        );

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[0]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        assert!(matches!(
            agent.prompt_input_mode,
            crate::app::agent_view::PromptInputMode::Bash
        ));

        agent.shared_queue.clear();
        agent.queue.sync_from_merged(
            &agent.session.pending_prompts,
            &agent.shared_queue,
            agent.session.current_prompt_id.as_deref(),
            agent.expect_send_now_cancel.as_deref(),
            &agent.send_now_painted_blocks,
        );
        agent.prompt_input_mode = crate::app::agent_view::PromptInputMode::Normal;
        agent.prompt.set_text("ls -la EDITED");

        let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
        match outcome {
            InputOutcome::Action(Action::QueueEditShared { id, new_text }) => {
                assert_eq!(id, "p1");
                assert_eq!(new_text, "ls -la EDITED");
            }
            other => panic!("expected QueueEditShared save, got {other:?}"),
        }
        assert!(agent.toast.is_some(), "guard must explain itself");
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
    }

    /// Server row vanished mid-edit (drained / removed by another client):
    /// edit-interject falls back to a plain interjection of the edited text.
    #[test]
    fn edit_interject_server_row_vanished_falls_back_to_plain_interject() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[0]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);

        // Row disappears from the broadcast while the user edits.
        agent.shared_queue.clear();
        agent.queue.sync_from_merged(
            &agent.session.pending_prompts,
            &agent.shared_queue,
            None,
            None,
            &agent.send_now_painted_blocks,
        );
        agent.prompt.set_text("server one EDITED");

        let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
        match outcome {
            InputOutcome::Action(Action::Interject { text, .. }) => {
                assert_eq!(text, "server one EDITED");
            }
            other => panic!("expected Interject fallback, got {other:?}"),
        }
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
    }

    /// Interject key while editing a SERVER row and IDLE saves via
    /// `Action::QueueEditShared` (same route as bare-Enter save).
    #[test]
    fn edit_interject_while_idle_saves_server_edit() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[0]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        agent.session.state = AgentState::Idle;
        agent.prompt.set_text("server one EDITED");

        let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
        match outcome {
            InputOutcome::Action(Action::QueueEditShared { id, new_text }) => {
                assert_eq!(id, "p1");
                assert_eq!(new_text, "server one EDITED");
            }
            other => panic!("expected QueueEditShared, got {other:?}"),
        }
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
    }

    /// Composer images pasted while edit-interjecting a LOCAL row ride
    /// along on the interjection instead of being dropped with a toast.
    #[test]
    fn edit_interject_local_row_carries_composer_images() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[1]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        agent.prompt.set_text("local one EDITED");
        // Insert via the widget so the chip element exists (drain_images
        // reconciles against live elements).
        let len = agent.prompt.textarea().text().len();
        agent.prompt.textarea.set_cursor(len);
        agent
            .prompt
            .insert_image(crate::prompt_images::from_clipboard_data(
                &crate::clipboard::ImageData {
                    data: vec![1, 2, 3],
                    mime_type: "image/png".into(),
                },
            ))
            .unwrap();

        let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
        match outcome {
            InputOutcome::Action(Action::Interject { images, .. }) => {
                assert_eq!(images.len(), 1);
            }
            other => panic!("expected Interject with images, got {other:?}"),
        }
        assert!(agent.prompt.images.is_empty());
        assert!(agent.toast.is_none(), "no drop toast expected");
    }

    /// Deciding test for the display_number-collision question: edit a LOCAL
    /// row whose stored image is `[Image #1]`,
    /// paste a NEW image mid-edit, interject — BOTH images must survive with
    /// distinct display_numbers (no silent eviction via the dedup merge).
    #[test]
    fn edit_interject_pasted_image_does_not_collide_with_row_image() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();

        // The local row carries a stored image numbered 1 and its placeholder.
        let mut row_img = crate::prompt_images::from_clipboard_data(&crate::clipboard::ImageData {
            data: vec![9, 9, 9],
            mime_type: "image/png".into(),
        });
        row_img.display_number = 1;
        agent.session.pending_prompts[0].text = "local one [Image #1]".into();
        agent.session.pending_prompts[0].images.push(row_img);
        agent.session.pending_prompts[0]
            .chip_elements
            .push(crate::app::agent::ChipElement {
                range: 10..20,
                kind: crate::views::prompt_widget::KIND_IMAGE,
                display: None,
            });
        agent.queue.sync_from_merged(
            &agent.session.pending_prompts,
            &agent.shared_queue,
            None,
            None,
            &agent.send_now_painted_blocks,
        );

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[1]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);

        // Paste a fresh image while editing.
        let len = agent.prompt.textarea().text().len();
        agent.prompt.textarea.set_cursor(len);
        agent
            .prompt
            .insert_image(crate::prompt_images::from_clipboard_data(
                &crate::clipboard::ImageData {
                    data: vec![1, 2, 3],
                    mime_type: "image/png".into(),
                },
            ))
            .unwrap();

        let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
        match outcome {
            InputOutcome::Action(Action::Interject { images, .. }) => {
                assert_eq!(
                    images.len(),
                    2,
                    "row image + new paste must BOTH survive, got numbers {:?}",
                    images.iter().map(|i| i.display_number).collect::<Vec<_>>()
                );
                let mut numbers: Vec<usize> = images.iter().map(|i| i.display_number).collect();
                numbers.sort_unstable();
                numbers.dedup();
                assert_eq!(numbers.len(), 2, "display_numbers must be distinct");
            }
            other => panic!("expected Interject with images, got {other:?}"),
        }
    }

    /// Composer images on a SERVER-row edit-interject are still dropped with
    /// an accurate toast — the queue wire's `newText` is text-only.
    #[test]
    fn edit_interject_server_row_clears_images_with_toast() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[0]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        agent.prompt.set_text("server one EDITED");
        agent
            .prompt
            .images
            .push(crate::prompt_images::from_clipboard_data(
                &crate::clipboard::ImageData {
                    data: vec![1, 2, 3],
                    mime_type: "image/png".into(),
                },
            ));

        let outcome = agent.handle_prompt_key_for_test(&force_interject_key());
        assert!(matches!(
            outcome,
            InputOutcome::Action(Action::QueueInterjectShared { .. })
        ));
        assert!(agent.prompt.images.is_empty());
        let (msg, _) = agent.toast.as_ref().expect("toast shown");
        assert!(msg.contains("can't be attached"), "got toast {msg:?}");
    }

    /// Modal Save with an empty composer on a SERVER row must not emit
    /// `QueueEditShared` (the row text would be blanked agent-side).
    #[test]
    fn edit_confirm_save_empty_server_row_skips_queue_edit() {
        let mut agent = make_running_agent();
        let registry = non_vscode_registry();

        let ids = agent.queue.entry_ids();
        agent.queue.list_state.select_by_id(ids[0]);
        let _ = agent.handle_queue_key(&edit_key(), &registry);
        agent.prompt.set_text("");

        // Arm the modal directly (the pane switch no longer arms it).
        agent.active_modal = Some(ActiveModal::EditConfirm {
            modal: ModalConfirmation::edit_confirm(),
            pending_target: AgentPane::Scrollback,
        });
        let outcome =
            agent.handle_modal_key(&KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));

        assert!(
            !matches!(
                outcome,
                InputOutcome::Action(Action::QueueEditShared { .. })
            ),
            "empty Save must not blank the server row, got {outcome:?}"
        );
        assert_eq!(agent.shared_queue[0].text, "server one");
        assert!(matches!(agent.prompt_mode, PromptMode::Normal));
    }
}
