//! Link affordances in scrollback: highlight cycling, hover tracking, the
//! macOS modifier poll, and painting link highlight styles.
#[cfg(test)]
use super::app_should_open_link_on_click_with;
#[cfg(target_os = "macos")]
use super::is_link_modifier_held;
#[cfg(test)]
use super::paste::paste_key_tests;
#[cfg(test)]
use super::{AgentPane, PromptMode, test_fixtures};
use super::{AgentView, app_should_open_link_on_click, has_native_link_hover};
#[cfg(test)]
use crate::actions::{ActionId, ActionRegistry, When};
#[cfg(test)]
use crate::app::actions::Action;
#[cfg(test)]
use crate::scrollback::render::ScratchBuffer;
#[cfg(test)]
use crate::scrollback::text_selection::{PendingTextDrag, RangeHit, ResolvedSelectionModel};
#[cfg(test)]
use crate::views::plan_approval_view::PlanApprovalFocus;
#[cfg(any(target_os = "macos", test))]
use crossterm::event::KeyModifiers;
#[cfg(test)]
use crossterm::event::{Event, KeyCode, KeyEvent};
#[cfg(test)]
use ratatui::buffer::Buffer;
use ratatui::style::Style;
#[cfg(test)]
use std::time::Instant;
impl AgentView {
    /// Cycle the highlighted link index forward or backward through visible links.
    ///
    /// When `forward` is true, advances to the next link (wrapping around).
    /// When no link is highlighted, selects the first (forward) or last (backward).
    pub fn cycle_highlighted_link(&mut self, forward: bool) {
        let count = self.visible_link_map.links().len();
        if count == 0 {
            self.highlighted_link_idx = None;
            return;
        }
        self.highlighted_link_idx = Some(match self.highlighted_link_idx {
            None => {
                if forward {
                    0
                } else {
                    count - 1
                }
            }
            Some(cur) => {
                if forward {
                    (cur + 1) % count
                } else {
                    (cur + count - 1) % count
                }
            }
        });
    }
    /// Return the URL of the currently highlighted link, if any.
    pub fn highlighted_link_url(&self) -> Option<&str> {
        self.highlighted_link_idx
            .and_then(|idx| self.visible_link_map.links().get(idx))
            .map(|link| &*link.url)
    }
    /// True when `(x, y)` lies inside an overlay drawn over the scrollback this
    /// frame (dropdown, goal detail). Such positions belong to the overlay, not
    /// to any scrollback link beneath it, so link hover/click must ignore them.
    pub(in crate::app) fn pos_occluded(&self, x: u16, y: u16) -> bool {
        let pos = ratatui::layout::Position { x, y };
        self.frame_occluder_rects.iter().any(|r| r.contains(pos))
    }
    /// Re-evaluate which link (if any) is under the cursor for the given
    /// modifier state.  Returns `true` when `hovered_link_idx` changed.
    pub(in crate::app) fn update_hovered_link(&mut self, modifier_held: bool) -> bool {
        if !modifier_held && self.hovered_link_idx.is_none() {
            return false;
        }
        let new = if modifier_held
            && !has_native_link_hover()
            && !self.pos_occluded(self.last_mouse_pos.0, self.last_mouse_pos.1)
        {
            self.visible_link_map
                .link_at(self.last_mouse_pos.0, self.last_mouse_pos.1)
                .filter(|hit| app_should_open_link_on_click(hit))
                .and_then(|hit| {
                    self.visible_link_map
                        .links()
                        .iter()
                        .position(|l| std::ptr::eq(l, hit))
                })
        } else {
            None
        };
        if new != self.hovered_link_idx {
            self.hovered_link_idx = new;
            true
        } else {
            false
        }
    }
    /// How long after the last pointer movement the Cmd link-hover poll stays
    /// armed. Within this window a Cmd press underlines the hovered link at
    /// tick latency; after it, the poll (and with it the animation tick loop)
    /// parks so a pointer merely resting over the window costs zero CPU.
    /// Any pointer movement — including the reflexive nudge people make
    /// before Cmd+clicking — re-arms it instantly via the mouse event.
    #[cfg(target_os = "macos")]
    const LINK_MODIFIER_POLL_WINDOW: std::time::Duration = std::time::Duration::from_secs(3);
    /// Whether macOS should keep ticking / polling Cmd for link hover.
    ///
    /// Covers scrollback (`hovered_entry`) **and** `/btw` panel links (map
    /// entries under `last_btw_area` / existing hover index) — poll must not
    /// require scrollback-only `hovered_entry` or Cmd release over the panel
    /// leaves a stuck highlight.
    ///
    /// The poll is bounded by recent pointer activity: `hovered_entry` is set
    /// whenever the pointer rests anywhere over content, so an unbounded gate
    /// would hold the ~30fps animation tick (each tick running a CoreGraphics
    /// modifier query) alive for as long as the mouse happens to sit over the
    /// window — i.e. approximately always. An active link highlight
    /// (`hovered_link_idx`) keeps polling regardless of the window so a held
    /// Cmd never strands a stuck underline.
    pub fn needs_link_modifier_poll(&self) -> bool {
        if has_native_link_hover() || self.visible_link_map.is_empty() {
            return false;
        }
        if self.hovered_link_idx.is_some() {
            return true;
        }
        #[cfg(target_os = "macos")]
        {
            let recently_moved = self
                .last_mouse_moved_at
                .is_some_and(|t| t.elapsed() < Self::LINK_MODIFIER_POLL_WINDOW);
            if !recently_moved {
                return false;
            }
        }
        if self.hovered_entry.is_some() {
            return true;
        }
        self.last_btw_area.area() > 0
            && self
                .last_btw_area
                .contains((self.last_mouse_pos.0, self.last_mouse_pos.1).into())
    }
    /// Poll macOS modifier state for link hover. Returns `true` when
    /// `hovered_link_idx` changed and a redraw is needed.
    ///
    /// On macOS, crossterm's Kitty protocol does not report modifier-only
    /// key events, so bare Cmd press/release is invisible to the input
    /// handler. This method polls CoreGraphics directly and is meant to
    /// be called from the animation tick loop.
    ///
    /// No-op on non-macOS (Ctrl key events are reported normally).
    pub fn poll_link_modifier(&mut self) -> bool {
        #[cfg(target_os = "macos")]
        if self.needs_link_modifier_poll() {
            return self.update_hovered_link(is_link_modifier_held(KeyModifiers::empty()));
        }
        false
    }
    /// Paint keyboard/hover active style for links whose index is in `index_range`.
    /// Shared by the scrollback and `/btw` highlight passes (z-order split).
    ///
    /// When `highlighted_link_idx == hovered_link_idx` the rects are painted
    /// once (the style is idempotent, but de-duping reads more clearly and
    /// avoids redundant buffer writes).
    pub(super) fn paint_link_highlights(
        &self,
        buf: &mut ratatui::buffer::Buffer,
        style: Style,
        index_range: std::ops::Range<usize>,
    ) {
        let mut paint = |idx: usize| {
            if !index_range.contains(&idx) {
                return;
            }
            if let Some(link) = self.visible_link_map.links().get(idx) {
                for r in &link.rects {
                    buf.set_style(*r, style);
                }
            }
        };
        if let Some(idx) = self.highlighted_link_idx {
            paint(idx);
        }
        if let Some(idx) = self.hovered_link_idx
            && Some(idx) != self.highlighted_link_idx
        {
            paint(idx);
        }
    }
}
#[cfg(test)]
mod link_click_tests {
    use super::test_fixtures::make_agent;
    use super::*;
    use crate::acp::model_state::ModelState;
    use crate::app::agent::{AgentId, AgentState};
    use crate::app::app_view::InputOutcome;
    use crate::render::osc8::{LinkOverlay, OverlayLink};
    use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
    use ratatui::layout::Rect;
    use std::sync::Arc;
    /// Set up the agent so clicks at (col, row) land in the scrollback pane.
    fn setup_scrollback_area(agent: &mut AgentView, area: Rect) {
        agent.pane_areas.scrollback = area;
        agent.active_pane = AgentPane::Scrollback;
    }
    /// Add a link to the visible_link_map covering (col_start..col_end, row).
    fn add_visible_link(agent: &mut AgentView, row: u16, col_start: u16, col_end: u16, url: &str) {
        let mut overlay = LinkOverlay::new();
        overlay.push(OverlayLink {
            screen_row: row,
            col_start,
            col_end,
            url: Arc::from(url),
            id: Some(1),
        });
        agent.visible_link_map.rebuild(1, &overlay, vec![]);
    }
    fn mouse_down(col: u16, row: u16) -> MouseEvent {
        MouseEvent {
            kind: MouseEventKind::Down(MouseButton::Left),
            column: col,
            row,
            modifiers: crossterm::event::KeyModifiers::empty(),
        }
    }
    fn mouse_up(col: u16, row: u16) -> MouseEvent {
        MouseEvent {
            kind: MouseEventKind::Up(MouseButton::Left),
            column: col,
            row,
            modifiers: crossterm::event::KeyModifiers::empty(),
        }
    }
    fn mouse_drag(col: u16, row: u16) -> MouseEvent {
        MouseEvent {
            kind: MouseEventKind::Drag(MouseButton::Left),
            column: col,
            row,
            modifiers: crossterm::event::KeyModifiers::empty(),
        }
    }
    fn mouse_moved(col: u16, row: u16) -> MouseEvent {
        MouseEvent {
            kind: MouseEventKind::Moved,
            column: col,
            row,
            modifiers: crossterm::event::KeyModifiers::empty(),
        }
    }
    /// Drive a real `Down`→`Drag` through `handle_input` on a selectable
    /// scrollback line so `drag_selection` is genuinely promoted, then leave the
    /// button held with no `Up` — the latched state the recovery guard targets.
    fn latch_real_scrollback_drag(agent: &mut AgentView, reg: &ActionRegistry) {
        setup_scrollback_area(agent, Rect::new(0, 0, 80, 24));
        let mut model = ResolvedSelectionModel::default();
        model.push_line(crate::scrollback::text_selection::ResolvedSelectableLine {
            entry_idx: 0,
            range_id: 0,
            block_line_idx: 0,
            screen_y: 5,
            screen_x: 0,
            selectable_cols: 0..40,
            text: "selectable scrollback line for drag selection".into(),
            joiner_to_previous: None,
        });
        agent.update_scrollback_selection_state(model, Default::default());
        let _ = agent.handle_input(&Event::Mouse(mouse_down(2, 5)), reg);
        let _ = agent.handle_input(&Event::Mouse(mouse_drag(10, 5)), reg);
        assert!(
            agent.drag_selection.is_some(),
            "setup: Down→Drag on a selectable line must promote drag_selection"
        );
        assert!(agent.left_mouse_down, "setup: button must still be held");
    }
    #[test]
    fn esc_unsticks_latched_drag() {
        let mut agent = make_agent();
        let reg = ActionRegistry::defaults();
        latch_real_scrollback_drag(&mut agent, &reg);
        let _ = agent.handle_input(
            &Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
            &reg,
        );
        assert!(!agent.left_mouse_down);
        assert!(agent.drag_selection.is_none());
        assert!(agent.pending_text_drag.is_none());
        assert!(agent.pending_block_drag.is_none());
        assert!(agent.block_drag_selection.is_none());
        assert!(!agent.scrollbar_dragging);
        let _ = agent.handle_input(&Event::Mouse(mouse_moved(30, 5)), &reg);
        assert!(agent.drag_selection.is_none());
    }
    #[test]
    fn fresh_mouse_down_clears_prior_latch() {
        let mut agent = make_agent();
        let reg = ActionRegistry::defaults();
        latch_real_scrollback_drag(&mut agent, &reg);
        let _ = agent.handle_input(&Event::Mouse(mouse_down(5, 10)), &reg);
        assert!(
            agent.drag_selection.is_none(),
            "the stale promoted selection must not survive into the fresh press"
        );
    }
    #[test]
    fn live_drag_events_not_interrupted() {
        let mut agent = make_agent();
        let reg = ActionRegistry::defaults();
        latch_real_scrollback_drag(&mut agent, &reg);
        let _ = agent.handle_input(&Event::Mouse(mouse_drag(20, 5)), &reg);
        assert!(agent.drag_selection.is_some());
        let _ = agent.handle_input(&Event::Mouse(mouse_moved(25, 5)), &reg);
        assert!(agent.drag_selection.is_some());
    }
    #[test]
    fn non_esc_key_clears_latch() {
        let mut agent = make_agent();
        let reg = ActionRegistry::defaults();
        latch_real_scrollback_drag(&mut agent, &reg);
        let _ = agent.handle_input(
            &Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
            &reg,
        );
        assert!(!agent.left_mouse_down);
        assert!(agent.drag_selection.is_none());
        assert!(agent.pending_text_drag.is_none());
        assert!(agent.pending_block_drag.is_none());
        assert!(agent.block_drag_selection.is_none());
        assert!(!agent.scrollbar_dragging);
    }
    /// Draw one 80x30 frame — shared fixture for the dropdown-suppression
    /// tests so `draw`'s long positional signature is spelled once.
    fn draw_banner_frame(agent: &mut AgentView, reg: &ActionRegistry, banner_height: u16) {
        draw_frame_sized(agent, reg, banner_height, 80);
    }
    fn draw_frame_sized(
        agent: &mut AgentView,
        reg: &ActionRegistry,
        banner_height: u16,
        cols: u16,
    ) -> Buffer {
        let area = Rect::new(0, 0, cols, 30);
        let bundle = crate::app::bundle::BundleState::default();
        let mut buf = Buffer::empty(area);
        let mut scratch = ScratchBuffer::new();
        agent.draw(
            area,
            &mut buf,
            reg,
            &mut scratch,
            None,
            false,
            banner_height,
            None,
            &bundle,
            false,
            &mut Vec::new(),
        );
        buf
    }
    #[test]
    fn open_prompt_dropdown_suppresses_stop_button_click_target() {
        let reg = ActionRegistry::defaults();
        let mut agent = make_agent();
        agent.last_terminal_size = (80, 30);
        agent.session.state = AgentState::TurnRunning;
        draw_banner_frame(&mut agent, &reg, 0);
        let rect = agent
            .hit_cancel_button
            .rect
            .expect("running turn must arm the stop rect");
        let outcome = agent.handle_input(&Event::Mouse(mouse_down(rect.x, rect.y)), &reg);
        assert!(
            matches!(outcome, InputOutcome::Action(Action::CancelTurn)),
            "sanity: visible stop must dispatch CancelTurn"
        );
        let _ = agent.prompt.handle_paste("/");
        agent.prompt.refresh_slash(&agent.session.models);
        assert!(
            agent.prompt.any_dropdown_open(),
            "setup: slash dropdown must be open"
        );
        draw_banner_frame(&mut agent, &reg, 0);
        assert!(
            agent.hit_cancel_button.rect.is_none(),
            "open dropdown must suppress the stop rect"
        );
        let outcome = agent.handle_input(&Event::Mouse(mouse_down(rect.x, rect.y)), &reg);
        assert!(
            !matches!(outcome, InputOutcome::Action(Action::CancelTurn)),
            "click where stop used to be must not cancel the turn under a dropdown"
        );
    }
    /// Bg twin: the `[↓]` demote button rides the same turn-status row, so its
    /// rect must drop under an open dropdown too — a dropdown click must never
    /// background the running execute tool.
    #[test]
    fn open_prompt_dropdown_suppresses_bg_button_click_target() {
        use crate::acp::meta::NotificationMeta;
        use agent_client_protocol as acp;
        let reg = ActionRegistry::defaults();
        let mut agent = make_agent();
        agent.last_terminal_size = (80, 30);
        agent.session.state = AgentState::TurnRunning;
        agent.session.handle_update(
            acp::SessionUpdate::ToolCall(
                acp::ToolCall::new(acp::ToolCallId::new(Arc::from("exec-1")), "sleep 5")
                    .kind(acp::ToolKind::Execute)
                    .status(acp::ToolCallStatus::InProgress)
                    .content(vec![])
                    .locations(vec![]),
            ),
            &NotificationMeta::default(),
            &mut agent.scrollback,
        );
        draw_banner_frame(&mut agent, &reg, 0);
        let rect = agent
            .hit_bg_button
            .rect
            .expect("running execute must arm the bg rect");
        let outcome = agent.handle_input(&Event::Mouse(mouse_down(rect.x, rect.y)), &reg);
        assert!(
            matches!(outcome, InputOutcome::Action(Action::DemoteToBackground)),
            "sanity: visible bg button must dispatch DemoteToBackground"
        );
        let _ = agent.prompt.handle_paste("/");
        agent.prompt.refresh_slash(&agent.session.models);
        assert!(
            agent.prompt.any_dropdown_open(),
            "setup: slash dropdown must be open"
        );
        draw_banner_frame(&mut agent, &reg, 0);
        assert!(
            agent.hit_bg_button.rect.is_none(),
            "open dropdown must suppress the bg rect"
        );
        let outcome = agent.handle_input(&Event::Mouse(mouse_down(rect.x, rect.y)), &reg);
        assert!(
            !matches!(outcome, InputOutcome::Action(Action::DemoteToBackground)),
            "click where the bg button used to be must not demote under a dropdown"
        );
    }
    /// Second suppression layer: a frame occluder (registered in
    /// `frame_occluder_rects`, NOT a dropdown, so the rects stay armed)
    /// covering the buttons must swallow their clicks.
    #[test]
    fn frame_occluder_over_stop_and_bg_buttons_swallows_clicks() {
        use crate::acp::meta::NotificationMeta;
        use agent_client_protocol as acp;
        let reg = ActionRegistry::defaults();
        let mut agent = make_agent();
        agent.last_terminal_size = (80, 30);
        agent.session.state = AgentState::TurnRunning;
        agent.session.handle_update(
            acp::SessionUpdate::ToolCall(
                acp::ToolCall::new(acp::ToolCallId::new(Arc::from("exec-1")), "sleep 5")
                    .kind(acp::ToolKind::Execute)
                    .status(acp::ToolCallStatus::InProgress)
                    .content(vec![])
                    .locations(vec![]),
            ),
            &NotificationMeta::default(),
            &mut agent.scrollback,
        );
        draw_banner_frame(&mut agent, &reg, 0);
        let stop = agent
            .hit_cancel_button
            .rect
            .expect("running turn must arm the stop rect");
        let bg = agent
            .hit_bg_button
            .rect
            .expect("running execute must arm the bg rect");
        assert!(
            agent.frame_occluder_rects.is_empty(),
            "setup: overlay-free frame must accumulate no occluders"
        );
        agent.frame_occluder_rects.push(Rect::new(0, stop.y, 80, 1));
        let outcome = agent.handle_input(&Event::Mouse(mouse_down(stop.x, stop.y)), &reg);
        assert!(
            !matches!(outcome, InputOutcome::Action(Action::CancelTurn)),
            "occluded [stop] click must not cancel the turn"
        );
        let outcome = agent.handle_input(&Event::Mouse(mouse_down(bg.x, bg.y)), &reg);
        assert!(
            !matches!(outcome, InputOutcome::Action(Action::DemoteToBackground)),
            "occluded bg click must not demote the turn"
        );
        assert!(
            agent.hit_cancel_button.rect.is_some() && agent.hit_bg_button.rect.is_some(),
            "occluder guard is click-time: the rects stay armed"
        );
        draw_banner_frame(&mut agent, &reg, 0);
        let outcome = agent.handle_input(&Event::Mouse(mouse_down(bg.x, bg.y)), &reg);
        assert!(
            matches!(outcome, InputOutcome::Action(Action::DemoteToBackground)),
            "overlay-free bg click must dispatch again"
        );
        let outcome = agent.handle_input(&Event::Mouse(mouse_down(stop.x, stop.y)), &reg);
        assert!(
            matches!(outcome, InputOutcome::Action(Action::CancelTurn)),
            "overlay-free [stop] click must dispatch again"
        );
    }
    #[test]
    fn drag_clears_pending_link_click() {
        let mut agent = make_agent();
        let area = Rect::new(0, 0, 80, 24);
        setup_scrollback_area(&mut agent, area);
        agent.pending_link_click = Some((15, 5, "https://example.com".into()));
        agent.left_mouse_down = true;
        let outcome = agent.handle_mouse(&mouse_drag(16, 5));
        assert!(matches!(
            outcome,
            InputOutcome::Changed | InputOutcome::Unchanged
        ));
        assert!(agent.pending_link_click.is_none());
    }
    #[test]
    fn up_at_same_position_returns_open_url_action() {
        let mut agent = make_agent();
        let area = Rect::new(0, 0, 80, 24);
        setup_scrollback_area(&mut agent, area);
        agent.pending_link_click = Some((15, 5, "https://example.com".into()));
        agent.left_mouse_down = true;
        let outcome = agent.handle_mouse(&mouse_up(15, 5));
        match outcome {
            InputOutcome::Action(Action::OpenUrl(url)) => {
                assert_eq!(url, "https://example.com");
            }
            other => panic!("expected Action::OpenUrl, got {other:?}"),
        }
    }
    /// A modifier+click on a `file://` link dispatches `OpenUrl` (Ctrl on
    /// Linux/Windows; macOS polls CoreGraphics so the Down step isn't
    /// reproducible in a unit test).
    #[test]
    #[cfg(not(target_os = "macos"))]
    fn modifier_click_on_file_link_opens_via_our_handler() {
        let mut agent = make_agent();
        let area = Rect::new(0, 0, 80, 24);
        setup_scrollback_area(&mut agent, area);
        add_visible_link(&mut agent, 5, 10, 30, "file:///tmp/session/images/1.png");
        let mut down = mouse_down(15, 5);
        down.modifiers = crossterm::event::KeyModifiers::CONTROL;
        assert!(matches!(agent.handle_mouse(&down), InputOutcome::Changed));
        match agent.handle_mouse(&mouse_up(15, 5)) {
            InputOutcome::Action(Action::OpenUrl(url)) => {
                assert_eq!(url, "file:///tmp/session/images/1.png");
            }
            other => panic!("expected Action::OpenUrl(file://…), got {other:?}"),
        }
    }
    fn test_link(url: &str, painted_w: u16) -> crate::scrollback::VisibleLink {
        crate::scrollback::VisibleLink {
            rects: vec![Rect::new(0, 0, painted_w, 1)],
            url: std::sync::Arc::from(url),
            id: None,
        }
    }
    #[test]
    fn app_should_open_link_visibility_matrix() {
        let bare = "https://example.com";
        let bare_w = unicode_width::UnicodeWidthStr::width(bare) as u16;
        let mailto = "mailto:a@b.com";
        let mailto_w = unicode_width::UnicodeWidthStr::width(mailto) as u16;
        let file = "file:///tmp/session/images/1.png";
        let file_w = unicode_width::UnicodeWidthStr::width(file) as u16;
        assert!(app_should_open_link_on_click_with(
            false,
            &test_link(bare, bare_w)
        ));
        assert!(app_should_open_link_on_click_with(
            false,
            &test_link(bare, 4)
        ));
        assert!(!app_should_open_link_on_click_with(
            true,
            &test_link(bare, bare_w)
        ));
        assert!(!app_should_open_link_on_click_with(
            true,
            &test_link(mailto, mailto_w)
        ));
        assert!(app_should_open_link_on_click_with(
            true,
            &test_link(bare, 4)
        ));
        assert!(app_should_open_link_on_click_with(
            true,
            &test_link(bare, bare_w.saturating_add(40))
        ));
        assert!(app_should_open_link_on_click_with(
            true,
            &test_link(file, file_w)
        ));
        assert!(app_should_open_link_on_click_with(
            true,
            &test_link(file, 8)
        ));
    }
    /// Regression: while the plan preview (line viewer) is open and the
    /// feedback prompt is focused, a left-drag that starts in the prompt
    /// must keep routing Drag/Up to the prompt so text selection works in
    /// the input — even when the pointer wanders up into the plan area. It
    /// must NOT be swallowed by the line viewer's plan-line gutter drag.
    #[test]
    fn plan_feedback_drag_routes_to_prompt_not_line_viewer() {
        let mut agent = make_agent();
        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\nStep one\nStep two".into()),
        };
        agent.plan_approval_view = Some(
            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,
            ),
        );
        agent.show_plan_preview();
        assert!(
            agent.line_viewer.is_some(),
            "plan preview should open the line viewer"
        );
        if let Some(ref mut pav) = agent.plan_approval_view {
            pav.focus = PlanApprovalFocus::Prompt;
        }
        agent.pane_areas.prompt = Rect::new(0, 20, 80, 3);
        let registry = ActionRegistry::defaults();
        let _ = agent.handle_input(&Event::Mouse(mouse_down(5, 21)), &registry);
        assert!(
            agent.plan_prompt_mouse_drag,
            "down in prompt should arm the prompt drag"
        );
        let _ = agent.handle_input(&Event::Mouse(mouse_drag(40, 5)), &registry);
        assert!(
            agent.plan_prompt_mouse_drag,
            "drag should keep the prompt drag armed"
        );
        assert!(
            agent
                .line_viewer
                .as_ref()
                .and_then(|v| v.plan_ref())
                .and_then(|p| p.gutter_drag_start)
                .is_none(),
            "forwarded drag must not start a plan-line gutter selection"
        );
        let _ = agent.handle_input(&Event::Mouse(mouse_up(40, 5)), &registry);
        assert!(
            !agent.plan_prompt_mouse_drag,
            "up should disarm the prompt drag"
        );
    }
    #[test]
    fn up_at_different_position_does_not_open_url() {
        let mut agent = make_agent();
        let area = Rect::new(0, 0, 80, 24);
        setup_scrollback_area(&mut agent, area);
        agent.pending_link_click = Some((15, 5, "https://example.com".into()));
        agent.left_mouse_down = true;
        let outcome = agent.handle_mouse(&mouse_up(16, 5));
        assert!(!matches!(outcome, InputOutcome::Action(Action::OpenUrl(_))));
        assert!(agent.pending_link_click.is_none());
    }
    #[test]
    fn down_on_non_link_clears_pending_link_click() {
        let mut agent = make_agent();
        let area = Rect::new(0, 0, 80, 24);
        setup_scrollback_area(&mut agent, area);
        add_visible_link(&mut agent, 5, 10, 30, "https://example.com");
        agent.pending_link_click = Some((15, 5, "https://example.com".into()));
        let outcome = agent.handle_mouse(&mouse_down(5, 3));
        assert!(matches!(outcome, InputOutcome::Changed));
        assert!(agent.pending_link_click.is_none());
    }
    #[test]
    fn moved_with_left_mouse_down_clears_pending_link_click() {
        let mut agent = make_agent();
        let area = Rect::new(0, 0, 80, 24);
        setup_scrollback_area(&mut agent, area);
        agent.pending_link_click = Some((15, 5, "https://example.com".into()));
        agent.left_mouse_down = true;
        agent.pending_text_drag = Some(PendingTextDrag {
            start_col: 15,
            start_row: 5,
            anchor: RangeHit {
                entry_idx: 0,
                range_id: 0,
                block_line_idx: 0,
                col_within_range: 15,
            },
            anchor_content_width: None,
        });
        let _outcome = agent.handle_mouse(&mouse_moved(16, 5));
        assert!(
            agent.pending_link_click.is_none(),
            "Moved with left_mouse_down should clear pending_link_click"
        );
    }
    #[test]
    fn pending_link_click_survives_up_on_non_scrollback_pane() {
        let mut agent = make_agent();
        setup_scrollback_area(&mut agent, Rect::new(0, 0, 80, 20));
        agent.active_pane = AgentPane::Prompt;
        agent.pending_link_click = Some((15, 5, "https://example.com".into()));
        agent.left_mouse_down = true;
        let outcome = agent.handle_mouse(&mouse_up(15, 5));
        match outcome {
            InputOutcome::Action(Action::OpenUrl(url)) => {
                assert_eq!(url, "https://example.com");
            }
            other => panic!("expected Action::OpenUrl, got {other:?}"),
        }
    }
    /// `/btw` panel links share the pane-agnostic Up path: once Down records
    /// `pending_link_click` (mouse-fallback terminals, Cmd/Ctrl held), Up over
    /// the panel opens the URL even though active_pane is Prompt.
    #[test]
    fn btw_panel_pending_link_click_opens_on_up() {
        let mut agent = make_agent();
        agent.last_btw_area = Rect::new(2, 18, 70, 6);
        agent.active_pane = AgentPane::Prompt;
        agent.btw_focused = true;
        add_visible_link(&mut agent, 20, 4, 40, "https://example.com/btw");
        agent.pending_link_click = Some((10, 20, "https://example.com/btw".into()));
        agent.left_mouse_down = true;
        let outcome = agent.handle_mouse(&mouse_up(10, 20));
        match outcome {
            InputOutcome::Action(Action::OpenUrl(url)) => {
                assert_eq!(url, "https://example.com/btw");
            }
            other => panic!("expected Action::OpenUrl for btw link, got {other:?}"),
        }
    }
    /// On mouse-fallback terminals, Down on a `/btw` link with the link
    /// modifier records `pending_link_click` instead of starting text drag.
    #[test]
    fn btw_panel_down_on_link_sets_pending_when_mouse_fallback() {
        if has_native_link_hover() {
            return;
        }
        let mut agent = make_agent();
        agent.last_btw_area = Rect::new(2, 18, 70, 6);
        agent.active_pane = AgentPane::Prompt;
        add_visible_link(&mut agent, 20, 4, 40, "https://example.com/btw");
        let outcome = agent.handle_mouse(&mouse_down(10, 20));
        assert!(matches!(
            outcome,
            InputOutcome::Changed | InputOutcome::Unchanged
        ));
        assert!(agent.pending_link_click.is_none());
        #[cfg(not(target_os = "macos"))]
        {
            let mut down = mouse_down(10, 20);
            down.modifiers = crossterm::event::KeyModifiers::CONTROL;
            let outcome = agent.handle_mouse(&down);
            assert!(matches!(outcome, InputOutcome::Changed));
            assert_eq!(
                agent
                    .pending_link_click
                    .as_ref()
                    .map(|(c, r, u)| (*c, *r, u.as_str())),
                Some((10, 20, "https://example.com/btw"))
            );
        }
    }
    #[test]
    fn needs_link_modifier_poll_true_over_btw_area() {
        let mut agent = make_agent();
        agent.visible_link_map.rebuild(
            1,
            &{
                let mut o = LinkOverlay::new();
                o.push(OverlayLink {
                    screen_row: 20,
                    col_start: 4,
                    col_end: 40,
                    url: Arc::from("https://example.com/btw"),
                    id: Some(1),
                });
                o
            },
            vec![],
        );
        agent.last_btw_area = Rect::new(2, 18, 70, 6);
        agent.last_mouse_pos = (10, 20);
        agent.last_mouse_moved_at = Some(Instant::now());
        agent.hovered_entry = None;
        if has_native_link_hover() {
            assert!(!agent.needs_link_modifier_poll());
        } else {
            assert!(agent.needs_link_modifier_poll());
        }
    }
    /// The Cmd link-hover poll is bounded by pointer activity: a pointer
    /// merely resting over content (hovered_entry set, no recent movement)
    /// must not demand ticks forever — that held a permanent ~30fps loop
    /// with a CoreGraphics query per tick on macOS. An active link
    /// highlight keeps polling regardless (so Cmd release is observed).
    #[test]
    #[cfg(target_os = "macos")]
    fn needs_link_modifier_poll_expires_without_recent_mouse_movement() {
        let mut agent = make_agent();
        add_multiple_links(&mut agent);
        agent.hovered_entry = Some(0);
        agent.last_mouse_moved_at = None;
        assert!(!agent.needs_link_modifier_poll());
        agent.last_mouse_moved_at = Some(Instant::now());
        assert_eq!(agent.needs_link_modifier_poll(), !has_native_link_hover());
        agent.last_mouse_moved_at = Some(
            Instant::now()
                - AgentView::LINK_MODIFIER_POLL_WINDOW
                - std::time::Duration::from_millis(1),
        );
        assert!(!agent.needs_link_modifier_poll());
        agent.hovered_link_idx = Some(0);
        assert_eq!(agent.needs_link_modifier_poll(), !has_native_link_hover());
    }
    fn add_multiple_links(agent: &mut AgentView) {
        let mut overlay = LinkOverlay::new();
        for (i, url) in ["https://a.com", "https://b.com", "https://c.com"]
            .iter()
            .enumerate()
        {
            overlay.push(OverlayLink {
                screen_row: i as u16,
                col_start: 0,
                col_end: 10,
                url: Arc::from(*url),
                id: Some(i as u32),
            });
        }
        agent.visible_link_map.rebuild(1, &overlay, vec![]);
    }
    #[test]
    fn cycle_forward_from_none_selects_first() {
        let mut agent = make_agent();
        add_multiple_links(&mut agent);
        agent.cycle_highlighted_link(true);
        assert_eq!(agent.highlighted_link_idx, Some(0));
        assert_eq!(agent.highlighted_link_url(), Some("https://a.com"));
    }
    #[test]
    fn cycle_backward_from_none_selects_last() {
        let mut agent = make_agent();
        add_multiple_links(&mut agent);
        agent.cycle_highlighted_link(false);
        assert_eq!(agent.highlighted_link_idx, Some(2));
        assert_eq!(agent.highlighted_link_url(), Some("https://c.com"));
    }
    #[test]
    fn cycle_forward_wraps_around() {
        let mut agent = make_agent();
        add_multiple_links(&mut agent);
        agent.highlighted_link_idx = Some(2);
        agent.cycle_highlighted_link(true);
        assert_eq!(agent.highlighted_link_idx, Some(0));
    }
    #[test]
    fn cycle_backward_wraps_around() {
        let mut agent = make_agent();
        add_multiple_links(&mut agent);
        agent.highlighted_link_idx = Some(0);
        agent.cycle_highlighted_link(false);
        assert_eq!(agent.highlighted_link_idx, Some(2));
    }
    #[test]
    fn cycle_with_no_links_clears_index() {
        let mut agent = make_agent();
        agent.highlighted_link_idx = Some(5);
        agent.cycle_highlighted_link(true);
        assert_eq!(agent.highlighted_link_idx, None);
    }
    #[test]
    fn enter_opens_highlighted_link() {
        let mut agent = make_agent();
        setup_scrollback_area(&mut agent, Rect::new(0, 0, 80, 24));
        add_multiple_links(&mut agent);
        agent.highlighted_link_idx = Some(1);
        let registry = ActionRegistry::defaults();
        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
        let outcome = agent.handle_scrollback_key(&enter, &registry);
        match outcome {
            InputOutcome::Action(Action::OpenUrl(url)) => {
                assert_eq!(url, "https://b.com");
            }
            other => panic!("expected Action::OpenUrl, got {other:?}"),
        }
        assert_eq!(agent.highlighted_link_idx, None);
    }
    #[test]
    fn enter_without_highlight_does_not_open_link() {
        let mut agent = make_agent();
        setup_scrollback_area(&mut agent, Rect::new(0, 0, 80, 24));
        add_multiple_links(&mut agent);
        assert_eq!(agent.highlighted_link_idx, None);
        let registry = ActionRegistry::defaults();
        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
        let outcome = agent.handle_scrollback_key(&enter, &registry);
        assert!(!matches!(outcome, InputOutcome::Action(Action::OpenUrl(_))));
    }
    /// Enter with a previous user prompt selected enters inline edit mode
    /// (edit-and-resubmit) instead of falling through to OpenBlockViewer.
    #[test]
    fn enter_on_selected_user_prompt_enters_inline_edit() {
        let mut agent = make_agent();
        agent
            .scrollback
            .push_block(crate::scrollback::block::RenderBlock::user_prompt(
                "fix the bug",
            ));
        agent
            .scrollback
            .push_block(crate::scrollback::block::RenderBlock::agent_message("done"));
        agent.scrollback.prepare_layout(80, 40);
        agent.scrollback.set_selected(Some(0));
        let registry = ActionRegistry::defaults();
        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
        let outcome = agent.handle_scrollback_key(&enter, &registry);
        assert!(matches!(outcome, InputOutcome::Changed), "got {outcome:?}");
        assert!(agent.inline_edit.is_some(), "Enter must start inline edit");
    }
    /// Bash prompts are not inline-editable: Enter falls through to the
    /// registry (OpenBlockViewer) exactly as before.
    #[test]
    fn enter_on_selected_bash_prompt_falls_through() {
        let mut agent = make_agent();
        agent
            .scrollback
            .push_block(crate::scrollback::block::RenderBlock::bash_prompt("ls"));
        agent.scrollback.prepare_layout(80, 40);
        agent.scrollback.set_selected(Some(0));
        let registry = ActionRegistry::defaults();
        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
        let outcome = agent.handle_scrollback_key(&enter, &registry);
        assert!(agent.inline_edit.is_none());
        assert!(
            matches!(outcome, InputOutcome::Action(Action::OpenBlockViewer)),
            "expected fall-through to OpenBlockViewer, got {outcome:?}"
        );
    }
    /// Double-click on a user prompt enters inline edit mode (replacing the
    /// old fold-toggle for editable prompts).
    #[test]
    fn double_click_on_user_prompt_enters_inline_edit() {
        let mut agent = make_agent();
        agent
            .scrollback
            .push_block(crate::scrollback::block::RenderBlock::user_prompt(
                "fix the bug",
            ));
        agent
            .scrollback
            .push_block(crate::scrollback::block::RenderBlock::agent_message("done"));
        agent.scrollback.prepare_layout(80, 40);
        let now = std::time::Instant::now();
        (agent.last_click, _) = agent.handle_scrollback_click(now, 0, false);
        let _ = agent.handle_scrollback_click(now + std::time::Duration::from_millis(10), 0, false);
        assert!(
            agent.inline_edit.is_some(),
            "double-click must start inline edit"
        );
    }
    #[test]
    fn enter_on_subagent_group_header_falls_through_to_group_toggle() {
        let mut agent = make_agent();
        let mut appearance = crate::appearance::AppearanceConfig::default();
        appearance.scrollback.display.group_max_visible = 3;
        agent.scrollback.set_appearance(appearance);
        agent
            .scrollback
            .push_block(crate::scrollback::block::RenderBlock::Subagent(
                crate::scrollback::blocks::SubagentBlock::started(
                    "child task",
                    "child-sid",
                    "general-purpose",
                    None,
                    None,
                    None,
                    false,
                ),
            ));
        for i in 0..5 {
            agent
                .scrollback
                .push_block(crate::scrollback::block::RenderBlock::tool_call(
                    format!("Tool{i}"),
                    "info",
                    true,
                ));
        }
        for i in 0..6 {
            if let Some(e) = agent.scrollback.entry_mut(i) {
                e.display_mode = crate::scrollback::types::DisplayMode::Collapsed;
            }
        }
        agent
            .subagent_views
            .insert("child-sid".into(), Box::new(make_agent()));
        agent.scrollback.prepare_layout(80, 40);
        agent.scrollback.set_selected(Some(0));
        assert!(agent.scrollback.is_selected_group_header());
        let registry = ActionRegistry::defaults();
        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
        let outcome = agent.handle_scrollback_key(&enter, &registry);
        assert!(
            agent.active_subagent.is_none(),
            "Enter on a group header must not open the hidden entry's subagent fullscreen"
        );
        assert!(
            matches!(outcome, InputOutcome::Action(Action::OpenBlockViewer)),
            "Enter must fall through to OpenBlockViewer (group toggle), got {outcome:?}"
        );
    }
    #[test]
    fn single_click_on_plan_tool_group_header_does_not_open_plan_preview() {
        use crate::scrollback::blocks::tool::{OtherToolCallBlock, ToolCallBlock};
        let mut agent = make_agent();
        let mut appearance = crate::appearance::AppearanceConfig::default();
        appearance.scrollback.display.group_max_visible = 3;
        agent.scrollback.set_appearance(appearance);
        agent
            .scrollback
            .push_block(crate::scrollback::block::RenderBlock::ToolCall(
                ToolCallBlock::Other(OtherToolCallBlock::new(
                    "enter_plan_mode",
                    "enter plan mode",
                )),
            ));
        for i in 0..5 {
            agent
                .scrollback
                .push_block(crate::scrollback::block::RenderBlock::tool_call(
                    format!("Tool{i}"),
                    "info",
                    true,
                ));
        }
        for i in 0..6 {
            if let Some(e) = agent.scrollback.entry_mut(i) {
                e.display_mode = crate::scrollback::types::DisplayMode::Collapsed;
            }
        }
        agent.scrollback.prepare_layout(80, 40);
        let _ = agent.handle_scrollback_click(std::time::Instant::now(), 0, false);
        assert!(agent.scrollback.is_selected_group_header());
        assert!(
            agent.toast.is_none(),
            "single click on a group header must not trigger the plan preview, got toast {:?}",
            agent.toast
        );
    }
    #[test]
    fn esc_clears_highlighted_link() {
        let mut agent = make_agent();
        setup_scrollback_area(&mut agent, Rect::new(0, 0, 80, 24));
        add_multiple_links(&mut agent);
        agent.highlighted_link_idx = Some(0);
        let registry = ActionRegistry::defaults();
        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
        let outcome = agent.handle_scrollback_key(&esc, &registry);
        assert!(matches!(outcome, InputOutcome::Changed));
        assert_eq!(agent.highlighted_link_idx, None);
    }
    #[test]
    fn highlighted_link_url_returns_none_for_out_of_bounds() {
        let mut agent = make_agent();
        add_multiple_links(&mut agent);
        agent.highlighted_link_idx = Some(99);
        assert_eq!(agent.highlighted_link_url(), None);
    }
    fn make_search_agent() -> (AgentView, ActionRegistry) {
        use crate::scrollback::block::RenderBlock;
        let mut agent = make_agent();
        agent.vim_mode = true;
        setup_scrollback_area(&mut agent, Rect::new(0, 0, 80, 24));
        agent
            .scrollback
            .push_block(RenderBlock::user_prompt("foo one"));
        agent
            .scrollback
            .push_block(RenderBlock::user_prompt("foo two"));
        agent.scrollback.prepare_layout(80, 24);
        (agent, ActionRegistry::defaults())
    }
    /// Empty-scrollback (new-session) counterpart to `make_search_agent`.
    fn make_empty_vim_agent() -> (AgentView, ActionRegistry) {
        let mut agent = make_agent();
        agent.vim_mode = true;
        setup_scrollback_area(&mut agent, Rect::new(0, 0, 80, 24));
        assert!(agent.scrollback.is_empty());
        (agent, ActionRegistry::defaults())
    }
    fn press(agent: &mut AgentView, reg: &ActionRegistry, code: KeyCode) -> InputOutcome {
        agent.handle_scrollback_key(&KeyEvent::new(code, KeyModifiers::NONE), reg)
    }
    fn type_query(agent: &mut AgentView, reg: &ActionRegistry, query: &str) {
        for c in query.chars() {
            press(agent, reg, KeyCode::Char(c));
        }
    }
    /// Wait for the daemon to publish the result of the most recent keystroke.
    ///
    /// One keystroke is one atomic `Update`, so it yields exactly one snapshot
    /// bump; break on the first `poll` that observes it. Panics if the daemon
    /// never responds so a wedged daemon surfaces here, not as a confusing
    /// downstream assertion.
    fn settle_search(agent: &mut AgentView) {
        for _ in 0..1000 {
            if agent.poll_scrollback_search() {
                return;
            }
            std::thread::sleep(std::time::Duration::from_millis(1));
        }
        panic!("scrollback search daemon did not publish a result");
    }
    /// Type a query one keystroke at a time, settling after each so the daemon
    /// processes one query per bump. Settling between keystrokes keeps each
    /// `poll` aligned to a single keystroke (no cross-keystroke coalescing), so
    /// the final assertions see the result for the complete query.
    fn type_query_and_settle(agent: &mut AgentView, reg: &ActionRegistry, query: &str) {
        for c in query.chars() {
            press(agent, reg, KeyCode::Char(c));
            settle_search(agent);
        }
    }
    #[test]
    fn vim_slash_opens_scrollback_search() {
        let (mut agent, reg) = make_search_agent();
        assert!(agent.scrollback_search.is_none());
        let out = press(&mut agent, &reg, KeyCode::Char('/'));
        assert!(matches!(out, InputOutcome::Changed));
        let search = agent.scrollback_search.as_ref().expect("search opened");
        assert!(search.is_composing());
        assert_eq!(search.query(), "");
    }
    /// Vim `/` on an empty scrollback (new session) must focus the prompt and
    /// forward `/` like non-vim mode rather than open an empty search.
    #[test]
    fn vim_slash_empty_scrollback_focuses_prompt() {
        let (mut agent, reg) = make_empty_vim_agent();
        let out = press(&mut agent, &reg, KeyCode::Char('/'));
        assert!(
            matches!(out, InputOutcome::ActionThenForward(Action::FocusPrompt)),
            "vim `/` on empty scrollback must focus the prompt, got {out:?}"
        );
        assert!(agent.scrollback_search.is_none());
    }
    #[test]
    fn non_vim_slash_does_not_open_search() {
        let (mut agent, reg) = make_search_agent();
        agent.vim_mode = false;
        press(&mut agent, &reg, KeyCode::Char('/'));
        assert!(agent.scrollback_search.is_none());
    }
    /// Regression (user report): non-vim '/' from scrollback must
    /// focus-forward into the prompt like letters do.
    #[test]
    fn non_vim_slash_focuses_prompt_and_forwards() {
        let (mut agent, reg) = make_search_agent();
        agent.vim_mode = false;
        let out = press(&mut agent, &reg, KeyCode::Char('/'));
        assert!(
            matches!(out, InputOutcome::ActionThenForward(Action::FocusPrompt)),
            "'/' from scrollback (non-vim) must focus the prompt and forward, got {out:?}"
        );
        assert!(agent.scrollback_search.is_none());
    }
    /// '?' must bubble past the pane handler (agent-level palette alt key).
    #[test]
    fn non_vim_question_mark_still_bubbles_to_palette() {
        let (mut agent, reg) = make_search_agent();
        agent.vim_mode = false;
        let out = press(&mut agent, &reg, KeyCode::Char('?'));
        assert!(
            matches!(out, InputOutcome::Unchanged),
            "'?' must bubble (palette binding lives at agent level), got {out:?}"
        );
    }
    /// Drive the FULL input router with an unmodified `/` so the overlay
    /// intercepts ahead of `handle_scrollback_key` are exercised.
    fn route_slash(agent: &mut AgentView, reg: &ActionRegistry) -> InputOutcome {
        agent.handle_input(
            &Event::Key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)),
            reg,
        )
    }
    #[test]
    fn router_slash_opens_search_when_no_overlay_pending() {
        let (mut agent, reg) = make_search_agent();
        route_slash(&mut agent, &reg);
        assert!(agent.scrollback_search.is_some());
    }
    /// Router-level symmetry with the non-empty case: empty scrollback focuses the prompt, not search.
    #[test]
    fn router_slash_focuses_prompt_when_scrollback_empty() {
        let (mut agent, reg) = make_empty_vim_agent();
        let out = route_slash(&mut agent, &reg);
        assert!(
            matches!(out, InputOutcome::ActionThenForward(Action::FocusPrompt)),
            "router `/` on empty scrollback must focus the prompt, got {out:?}"
        );
        assert!(agent.scrollback_search.is_none());
    }
    #[test]
    fn router_slash_blocked_while_permission_pending() {
        let (mut agent, reg) = make_search_agent();
        agent
            .permission_queue
            .push_back(super::paste_key_tests::make_followup_permission_state());
        route_slash(&mut agent, &reg);
        assert!(agent.scrollback_search.is_none());
    }
    #[test]
    fn router_slash_blocked_while_plan_approval_pending() {
        let (mut agent, reg) = make_search_agent();
        agent.plan_approval_view = Some(super::paste_key_tests::make_plan_approval_view_state());
        route_slash(&mut agent, &reg);
        assert!(agent.scrollback_search.is_none());
    }
    #[test]
    fn router_slash_blocked_while_cancel_turn_pending() {
        let (mut agent, reg) = make_search_agent();
        agent.cancel_turn_view = Some(crate::views::modal::CancelTurnViewState {
            active_idx: 0,
            running_count: 1,
        });
        route_slash(&mut agent, &reg);
        assert!(agent.scrollback_search.is_none());
    }
    #[test]
    fn router_slash_blocked_while_question_pending() {
        let (mut agent, reg) = make_search_agent();
        agent.question_view =
            Some(super::paste_key_tests::make_question_view_state_in_input_mode());
        route_slash(&mut agent, &reg);
        assert!(agent.scrollback_search.is_none());
    }
    #[test]
    fn router_slash_blocked_while_btw_panel_open() {
        let (mut agent, reg) = make_search_agent();
        agent.btw_state = Some(crate::views::btw_overlay::BtwOverlayState::Loading {
            question: "q".into(),
        });
        route_slash(&mut agent, &reg);
        assert!(agent.scrollback_search.is_none());
    }
    #[test]
    fn router_slash_restarts_search_while_browsing() {
        let (mut agent, reg) = make_search_agent();
        press(&mut agent, &reg, KeyCode::Char('/'));
        type_query_and_settle(&mut agent, &reg, "foo");
        press(&mut agent, &reg, KeyCode::Enter);
        assert!(!agent.scrollback_search.as_ref().unwrap().is_composing());
        route_slash(&mut agent, &reg);
        assert!(agent.scrollback_search.as_ref().unwrap().is_composing());
    }
    #[test]
    fn typing_builds_query_and_finds_matches() {
        let (mut agent, reg) = make_search_agent();
        press(&mut agent, &reg, KeyCode::Char('/'));
        type_query_and_settle(&mut agent, &reg, "foo");
        let search = agent.scrollback_search.as_ref().unwrap();
        assert_eq!(search.query(), "foo");
        assert_eq!(search.match_count(), 2);
        assert_eq!(search.current_index(), Some(0));
    }
    #[test]
    fn backspace_edits_the_query() {
        let (mut agent, reg) = make_search_agent();
        press(&mut agent, &reg, KeyCode::Char('/'));
        type_query_and_settle(&mut agent, &reg, "fox");
        assert_eq!(agent.scrollback_search.as_ref().unwrap().match_count(), 0);
        press(&mut agent, &reg, KeyCode::Backspace);
        settle_search(&mut agent);
        let search = agent.scrollback_search.as_ref().unwrap();
        assert_eq!(search.query(), "fo");
        assert_eq!(search.match_count(), 2);
    }
    #[test]
    fn enter_accepts_then_n_and_shift_n_navigate() {
        let (mut agent, reg) = make_search_agent();
        press(&mut agent, &reg, KeyCode::Char('/'));
        type_query_and_settle(&mut agent, &reg, "foo");
        press(&mut agent, &reg, KeyCode::Enter);
        assert!(!agent.scrollback_search.as_ref().unwrap().is_composing());
        assert_eq!(
            agent.scrollback_search.as_ref().unwrap().current_index(),
            Some(0)
        );
        press(&mut agent, &reg, KeyCode::Char('n'));
        assert_eq!(
            agent.scrollback_search.as_ref().unwrap().current_index(),
            Some(1)
        );
        press(&mut agent, &reg, KeyCode::Char('n'));
        assert_eq!(
            agent.scrollback_search.as_ref().unwrap().current_index(),
            Some(0),
            "n wraps"
        );
        agent.handle_scrollback_key(
            &KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT),
            &reg,
        );
        assert_eq!(
            agent.scrollback_search.as_ref().unwrap().current_index(),
            Some(1)
        );
    }
    #[test]
    fn esc_cancels_search() {
        let (mut agent, reg) = make_search_agent();
        press(&mut agent, &reg, KeyCode::Char('/'));
        type_query(&mut agent, &reg, "foo");
        let out = press(&mut agent, &reg, KeyCode::Esc);
        assert!(matches!(out, InputOutcome::Changed));
        assert!(agent.scrollback_search.is_none());
    }
    #[test]
    fn arrow_keys_navigate_matches_while_browsing() {
        let (mut agent, reg) = make_search_agent();
        press(&mut agent, &reg, KeyCode::Char('/'));
        type_query_and_settle(&mut agent, &reg, "foo");
        press(&mut agent, &reg, KeyCode::Enter);
        assert_eq!(
            agent.scrollback_search.as_ref().unwrap().current_index(),
            Some(0)
        );
        let out = press(&mut agent, &reg, KeyCode::Down);
        assert!(matches!(out, InputOutcome::Changed));
        assert_eq!(
            agent.scrollback_search.as_ref().unwrap().current_index(),
            Some(1),
            "Down advances to the next match"
        );
        press(&mut agent, &reg, KeyCode::Up);
        assert_eq!(
            agent.scrollback_search.as_ref().unwrap().current_index(),
            Some(0),
            "Up returns to the previous match"
        );
        assert!(!agent.scrollback_search.as_ref().unwrap().is_composing());
    }
    #[test]
    fn arrow_keys_navigate_matches_while_composing() {
        let (mut agent, reg) = make_search_agent();
        press(&mut agent, &reg, KeyCode::Char('/'));
        type_query_and_settle(&mut agent, &reg, "foo");
        assert!(agent.scrollback_search.as_ref().unwrap().is_composing());
        assert_eq!(
            agent.scrollback_search.as_ref().unwrap().current_index(),
            Some(0)
        );
        press(&mut agent, &reg, KeyCode::Down);
        assert_eq!(
            agent.scrollback_search.as_ref().unwrap().current_index(),
            Some(1),
            "Down advances a match without leaving the composing phase"
        );
        assert!(
            agent.scrollback_search.as_ref().unwrap().is_composing(),
            "arrows must not accept the query"
        );
        press(&mut agent, &reg, KeyCode::Up);
        assert_eq!(
            agent.scrollback_search.as_ref().unwrap().current_index(),
            Some(0)
        );
    }
    #[test]
    fn open_scrollback_search_focuses_pane_and_starts_composing() {
        let (mut agent, _reg) = make_search_agent();
        agent.set_active_pane(AgentPane::Prompt, false);
        assert_eq!(agent.active_pane, AgentPane::Prompt);
        assert!(agent.scrollback_search.is_none());
        agent.open_scrollback_search(None);
        assert_eq!(agent.active_pane, AgentPane::Scrollback);
        let search = agent.scrollback_search.as_ref().expect("search opened");
        assert!(search.is_composing());
        assert_eq!(search.query(), "");
    }
    #[test]
    fn open_scrollback_search_prefills_query_and_stays_composing() {
        use crate::scrollback::block::RenderBlock;
        let (mut agent, _reg) = make_search_agent();
        agent.scrollback.push_block(RenderBlock::user_prompt("abc"));
        agent.scrollback.prepare_layout(80, 24);
        agent.set_active_pane(AgentPane::Prompt, false);
        agent.open_scrollback_search(Some("a.c"));
        settle_search(&mut agent);
        assert_eq!(agent.active_pane, AgentPane::Scrollback);
        let search = agent.scrollback_search.as_ref().expect("search opened");
        assert_eq!(search.query(), "a.c");
        assert_eq!(
            search.match_count(),
            1,
            "regex `a.c` must match `abc`; a literal search would find nothing"
        );
        assert_eq!(search.current_index(), Some(0));
        assert!(
            search.is_composing(),
            "a pre-filled query must mirror typing and not auto-accept"
        );
    }
    #[test]
    fn open_scrollback_search_blocked_by_dirty_edit_does_not_orphan_session() {
        let (mut agent, _reg) = make_search_agent();
        agent.set_active_pane(AgentPane::Prompt, false);
        agent.prompt_mode = PromptMode::EditingQueued {
            id: 0,
            original: "queued text".into(),
            server_id: None,
            kind: crate::app::agent::QueueEntryKind::Prompt,
        };
        assert!(
            agent.prompt.text() != "queued text",
            "empty prompt must read dirty against the snapshot"
        );
        agent.open_scrollback_search(Some("foo"));
        assert!(
            agent.scrollback_search.is_none(),
            "blocked pane switch must not open a search session"
        );
        assert_eq!(agent.active_pane, AgentPane::Prompt);
        assert!(
            agent.active_modal.is_none(),
            "the blocked switch must not arm an invisible EditConfirm modal"
        );
    }
    /// Render `agent` into a fresh buffer of `area` and return it. Centralizes
    /// the `draw` boilerplate (including the dev-only tracing arg) so render
    /// tests stay readable.
    fn render_agent(agent: &mut AgentView, area: Rect, reg: &ActionRegistry) -> Buffer {
        let mut buf = Buffer::empty(area);
        let mut scratch = ScratchBuffer::new();
        let bundle = crate::app::bundle::BundleState {
            has_cache: false,
            version: String::new(),
            personas: Vec::new(),
            roles: Vec::new(),
            agents: Vec::new(),
            skills: Vec::new(),
            persona_details: Vec::new(),
            role_details: Vec::new(),
        };
        agent.draw(
            area,
            &mut buf,
            reg,
            &mut scratch,
            None,
            false,
            0,
            None,
            &bundle,
            false,
            &mut Vec::new(),
        );
        buf
    }
    /// Concatenated symbols of buffer row `y` across `width` columns.
    fn buffer_row(buf: &Buffer, width: u16, y: u16) -> String {
        (0..width)
            .filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string()))
            .collect()
    }
    /// Draw-path integration: an active ephemeral tip paints into the banner
    /// row on a tall, unoccluded terminal; the draw-path height gate suppresses
    /// it when the area is too short; and a co-occurring mode-switch banner
    /// wins the slot while the tip merely yields (its TTL keeps ticking).
    #[test]
    fn ephemeral_tip_draw_path_reserve_paint_and_precedence() {
        use std::collections::HashMap;
        let reg = ActionRegistry::defaults();
        let tall = Rect::new(0, 0, 80, 30);
        let mut agent = make_agent();
        agent.last_terminal_size = (80, 30);
        let _ = agent.show_ephemeral_tip(
            crate::tips::EphemeralTip::new("t", ratatui::text::Line::from("ZZTIPZZ")),
            &mut HashMap::new(),
        );
        assert!(
            agent.ephemeral_tip.is_active(),
            "tall, unoccluded show must put the tip on screen"
        );
        let buf = render_agent(&mut agent, tall, &reg);
        assert!(
            (0..tall.height).any(|y| buffer_row(&buf, tall.width, y).contains("ZZTIPZZ")),
            "tip text must paint into the banner row"
        );
        let short = Rect::new(0, 0, 80, 16);
        let buf = render_agent(&mut agent, short, &reg);
        assert!(
            !(0..short.height).any(|y| buffer_row(&buf, short.width, y).contains("ZZTIPZZ")),
            "short terminal must not reserve or paint the tip row"
        );
        agent.show_mode_switch_banner("PlanMode");
        let buf = render_agent(&mut agent, tall, &reg);
        let frame: String = (0..tall.height)
            .map(|y| buffer_row(&buf, tall.width, y))
            .collect();
        assert!(
            frame.contains("Switched to mode: PlanMode"),
            "mode-switch banner must own the slot"
        );
        assert!(
            !frame.contains("ZZTIPZZ"),
            "tip yields the slot while the mode-switch banner is active"
        );
        assert!(
            agent.ephemeral_tip.is_active(),
            "tip is only hidden by precedence, not cleared"
        );
    }
    /// Regression (#send-now bold leak): an ephemeral tip that reserved the
    /// banner row must render with its own styling even when a session tip is
    /// (wrongly or historically) handed to `draw` at the same time. The
    /// session tip's bold `Tip: ` prefix used to underpaint the row and —
    /// because `Cell::set_style` merges modifiers — leak BOLD into the first
    /// five cells of the ephemeral tip ("**Queue**d · Enter to send now").
    #[test]
    fn ephemeral_tip_not_bolded_by_session_tip_underpaint() {
        use ratatui::style::Modifier;
        use std::collections::HashMap;
        let reg = ActionRegistry::defaults();
        let tall = Rect::new(0, 0, 80, 30);
        let mut agent = make_agent();
        agent.last_terminal_size = (80, 30);
        let _ =
            agent.show_ephemeral_tip(crate::tips::send_now::send_now_tip(), &mut HashMap::new());
        assert!(agent.ephemeral_tip.is_active());
        let mut buf = Buffer::empty(tall);
        let mut scratch = ScratchBuffer::new();
        let bundle = crate::app::bundle::BundleState {
            has_cache: false,
            version: String::new(),
            personas: Vec::new(),
            roles: Vec::new(),
            agents: Vec::new(),
            skills: Vec::new(),
            persona_details: Vec::new(),
            role_details: Vec::new(),
        };
        agent.draw(
            tall,
            &mut buf,
            &reg,
            &mut scratch,
            None,
            false,
            0,
            Some("ZZSESSIONTIPZZ never shown in agent view"),
            &bundle,
            false,
            &mut Vec::new(),
        );
        let tip_y = (0..tall.height)
            .find(|&y| buffer_row(&buf, tall.width, y).contains("Queued"))
            .expect("ephemeral tip must paint into the banner row");
        let row = buffer_row(&buf, tall.width, tip_y);
        assert!(
            !(0..tall.height).any(|y| buffer_row(&buf, tall.width, y).contains("ZZSESSIONTIPZZ")),
            "session tip must not remain visible in the agent view"
        );
        let start = row[..row.find("Queued").expect("tip text")].chars().count() as u16;
        let bold_cols: Vec<u16> = (0..tall.width)
            .filter(|&x| {
                buf.cell((x, tip_y))
                    .expect("cell in row")
                    .modifier
                    .contains(Modifier::BOLD)
            })
            .collect();
        assert_eq!(
            bold_cols,
            (start + 9..start + 14).collect::<Vec<u16>>(),
            "only the Enter chord may be bold, got row {row:?}"
        );
    }
    #[test]
    fn search_active_reserves_two_bottom_rows() {
        let (mut agent, reg) = make_search_agent();
        press(&mut agent, &reg, KeyCode::Char('/'));
        type_query(&mut agent, &reg, "foo");
        let area = Rect::new(0, 0, 80, 24);
        let buf = render_agent(&mut agent, area, &reg);
        let bar_y = (0..area.height)
            .find(|&y| buffer_row(&buf, area.width, y).contains("search:"))
            .expect("the search bar should render in a reserved row");
        assert!(bar_y >= 1, "the search bar can't sit on the top row");
        assert!(
            buffer_row(&buf, area.width, bar_y - 1).contains('\u{2500}'),
            "a divider rule should occupy the row directly above the search bar"
        );
    }
    #[test]
    fn search_reserved_rows_clamps_to_available_height() {
        assert_eq!(AgentView::search_reserved_rows(24, false), 0);
        assert_eq!(AgentView::search_reserved_rows(24, true), 2);
        assert_eq!(AgentView::search_reserved_rows(2, true), 2);
        assert_eq!(AgentView::search_reserved_rows(1, true), 1);
        assert_eq!(AgentView::search_reserved_rows(0, true), 0);
    }
    #[test]
    fn browsing_leaves_non_search_keys_to_normal_handling() {
        let (mut agent, reg) = make_search_agent();
        press(&mut agent, &reg, KeyCode::Char('/'));
        type_query(&mut agent, &reg, "foo");
        press(&mut agent, &reg, KeyCode::Enter);
        press(&mut agent, &reg, KeyCode::Char('j'));
        assert!(agent.scrollback_search.is_some());
        assert!(!agent.scrollback_search.as_ref().unwrap().is_composing());
    }
    #[test]
    fn search_is_smart_case() {
        use crate::scrollback::block::RenderBlock;
        let mut agent = make_agent();
        agent.vim_mode = true;
        setup_scrollback_area(&mut agent, Rect::new(0, 0, 80, 24));
        agent
            .scrollback
            .push_block(RenderBlock::user_prompt("Error and error"));
        agent.scrollback.prepare_layout(80, 24);
        let reg = ActionRegistry::defaults();
        press(&mut agent, &reg, KeyCode::Char('/'));
        type_query_and_settle(&mut agent, &reg, "error");
        assert_eq!(
            agent.scrollback_search.as_ref().unwrap().match_count(),
            2,
            "a lowercase query is case-insensitive"
        );
        press(&mut agent, &reg, KeyCode::Esc);
        press(&mut agent, &reg, KeyCode::Char('/'));
        type_query_and_settle(&mut agent, &reg, "Error");
        assert_eq!(
            agent.scrollback_search.as_ref().unwrap().match_count(),
            1,
            "an uppercase query is case-sensitive (smart-case)"
        );
    }
    #[test]
    fn enter_on_empty_query_closes_search() {
        let (mut agent, reg) = make_search_agent();
        press(&mut agent, &reg, KeyCode::Char('/'));
        let out = press(&mut agent, &reg, KeyCode::Enter);
        assert!(matches!(out, InputOutcome::Changed));
        assert!(agent.scrollback_search.is_none());
    }
    #[test]
    fn esc_dismisses_search_while_browsing() {
        let (mut agent, reg) = make_search_agent();
        press(&mut agent, &reg, KeyCode::Char('/'));
        type_query(&mut agent, &reg, "foo");
        press(&mut agent, &reg, KeyCode::Enter);
        let out = press(&mut agent, &reg, KeyCode::Esc);
        assert!(matches!(out, InputOutcome::Changed));
        assert!(agent.scrollback_search.is_none());
    }
    #[test]
    fn navigate_with_no_matches_is_noop() {
        let (mut agent, reg) = make_search_agent();
        press(&mut agent, &reg, KeyCode::Char('/'));
        type_query_and_settle(&mut agent, &reg, "zzz");
        press(&mut agent, &reg, KeyCode::Enter);
        assert_eq!(agent.scrollback_search.as_ref().unwrap().match_count(), 0);
        press(&mut agent, &reg, KeyCode::Char('n'));
        let search = agent.scrollback_search.as_ref().unwrap();
        assert_eq!(search.current_index(), None);
        assert!(agent.scrollback_search.is_some());
    }
    #[test]
    fn composing_swallows_tab_without_changing_pane() {
        let (mut agent, reg) = make_search_agent();
        press(&mut agent, &reg, KeyCode::Char('/'));
        let out = press(&mut agent, &reg, KeyCode::Tab);
        assert!(matches!(out, InputOutcome::Unchanged));
        assert_eq!(agent.active_pane, AgentPane::Scrollback);
        assert!(agent.scrollback_search.as_ref().unwrap().is_composing());
    }
    #[test]
    fn leaving_scrollback_pane_clears_search() {
        let (mut agent, reg) = make_search_agent();
        press(&mut agent, &reg, KeyCode::Char('/'));
        assert!(agent.scrollback_search.is_some());
        agent.set_active_pane(AgentPane::Prompt, false);
        assert!(agent.scrollback_search.is_none());
    }
    #[test]
    fn esc_with_running_turn_dismisses_search_not_cancel_turn() {
        let (mut agent, reg) = make_search_agent();
        agent.session.state = AgentState::TurnRunning;
        press(&mut agent, &reg, KeyCode::Char('/'));
        type_query(&mut agent, &reg, "foo");
        let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
        let out = agent.handle_input(&esc, &reg);
        assert!(!matches!(out, InputOutcome::Action(Action::CancelTurn)));
        assert!(agent.scrollback_search.is_none());
    }
    #[test]
    fn is_bare_scrollback_excludes_open_search() {
        let (mut agent, reg) = make_search_agent();
        assert!(
            agent.is_bare_scrollback(),
            "scrollback focused with nothing layered on top"
        );
        press(&mut agent, &reg, KeyCode::Char('/'));
        assert!(
            !agent.is_bare_scrollback(),
            "an open scrollback search is a layered sub-state"
        );
    }
    #[test]
    fn subagent_esc_cancels_open_search_before_closing_view() {
        let reg = ActionRegistry::defaults();
        let mut parent = make_agent();
        let (mut child, _) = make_search_agent();
        press(&mut child, &reg, KeyCode::Char('/'));
        type_query(&mut child, &reg, "foo");
        assert!(child.scrollback_search.is_some());
        let child_sid = "child-sid".to_string();
        parent
            .subagent_views
            .insert(child_sid.clone(), Box::new(child));
        parent.active_subagent = Some(child_sid.clone());
        let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
        parent.handle_input(&esc, &reg);
        assert!(
            parent.active_subagent.is_some(),
            "view stays open while the child's search is cancelled"
        );
        assert!(
            parent.subagent_views[&child_sid]
                .scrollback_search
                .is_none(),
            "the forwarded Esc cancels the child's search"
        );
        parent.handle_input(&esc, &reg);
        assert!(
            parent.active_subagent.is_none(),
            "Esc closes the subagent view once no search is open"
        );
    }
    #[test]
    fn subagent_q_while_searching_types_into_query_not_close() {
        let reg = ActionRegistry::defaults();
        let mut parent = make_agent();
        let (mut child, _) = make_search_agent();
        press(&mut child, &reg, KeyCode::Char('/'));
        type_query(&mut child, &reg, "fo");
        assert!(child.scrollback_search.as_ref().unwrap().is_composing());
        let child_sid = "child-sid".to_string();
        parent
            .subagent_views
            .insert(child_sid.clone(), Box::new(child));
        parent.active_subagent = Some(child_sid.clone());
        let q = Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
        parent.handle_input(&q, &reg);
        assert!(
            parent.active_subagent.is_some(),
            "view stays open while a search is composing"
        );
        assert_eq!(
            parent.subagent_views[&child_sid]
                .scrollback_search
                .as_ref()
                .unwrap()
                .query(),
            "foq",
            "q is typed into the query, not treated as a close key"
        );
    }
    #[test]
    fn subagent_scrollback_search_delivers_results_via_child_poll() {
        let reg = ActionRegistry::defaults();
        let mut parent = make_agent();
        let (mut child, _) = make_search_agent();
        press(&mut child, &reg, KeyCode::Char('/'));
        assert!(child.scrollback_search.is_some());
        let child_sid = "child-sid".to_string();
        parent
            .subagent_views
            .insert(child_sid.clone(), Box::new(child));
        parent.active_subagent = Some(child_sid.clone());
        for c in "foo".chars() {
            parent.handle_input(
                &Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)),
                &reg,
            );
            let mut delivered = false;
            for _ in 0..1000 {
                if parent
                    .subagent_views
                    .get_mut(&child_sid)
                    .unwrap()
                    .poll_scrollback_search()
                {
                    delivered = true;
                    break;
                }
                std::thread::sleep(std::time::Duration::from_millis(1));
            }
            assert!(delivered, "child search daemon did not publish a result");
        }
        let search = parent.subagent_views[&child_sid]
            .scrollback_search
            .as_ref()
            .unwrap();
        assert_eq!(
            search.match_count(),
            2,
            "child search finds both 'foo' entries"
        );
        assert_eq!(
            search.current_index(),
            Some(0),
            "child search parks the cursor on the first match"
        );
    }
    #[test]
    fn o_key_cycles_links_via_registry() {
        let registry = ActionRegistry::defaults();
        let o = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE);
        assert_eq!(
            registry.lookup(&o, When::ScrollbackFocused),
            Some(ActionId::OpenNextLink)
        );
    }
    #[test]
    fn shift_o_key_cycles_links_backward_via_registry() {
        let registry = ActionRegistry::defaults();
        let shift_o = KeyEvent::new(KeyCode::Char('O'), KeyModifiers::SHIFT);
        assert_eq!(
            registry.lookup(&shift_o, When::ScrollbackFocused),
            Some(ActionId::OpenPrevLink)
        );
    }
    #[test]
    fn navigation_clears_highlighted_link_via_dispatch() {
        use crate::app::actions::Action;
        use crate::app::app_view::AppView;
        use crate::app::dispatch::{SwitchCause, dispatch, switch_to_agent};
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let mut app = AppView::new(tx.clone(), ModelState::default(), Vec::new());
        let id = AgentId(0);
        let mut agent = make_agent();
        add_multiple_links(&mut agent);
        agent.highlighted_link_idx = Some(1);
        app.agents.insert(id, agent);
        switch_to_agent(&mut app, id, SwitchCause::New);
        dispatch(Action::SelectNext, &mut app);
        let agent = app.agents.get(&id).unwrap();
        assert_eq!(
            agent.highlighted_link_idx, None,
            "navigation should clear highlighted link"
        );
    }
}
