//! Per-agent view component.
//!
//! [`AgentView`] is a view-model that owns both business state (session,
//! entries) and UI state (scroll, selection, focus, mode). It handles
//! input routing to the active pane and renders the agent layout.
//!
//! ## Input flow (three-level bubbling)
//!
//! ```text
//! key press
//!   → overlays / modals / dropdowns / search / selection steal Esc first
//!   → 1. pane level (exact context match):
//!       prompt focused:
//!         prompt.handle_key(key) → Submit / Edited / Ignored
//!         if Ignored → registry.lookup(key, PromptFocused) → pane-specific actions
//!         if still unmatched → Tab structural FocusScrollback
//!       scrollback focused:
//!         Space/i → FocusPrompt
//!         registry.lookup(key, ScrollbackFocused) → navigation, view, etc.
//!   → 2. agent level (if pane returned Unchanged):
//!       registry.lookup(key, AgentScreen) → CancelTurn (Ctrl+C), ToggleYolo, NextModel
//!       CancelTurn has runtime guards (Running→cancel, Cancelling→quit on Ctrl+C).
//!       From the prompt pane, Ctrl+C CancelTurn is a two-step gesture:
//!       a non-empty prompt skips the AgentScreen promotion so the key
//!       falls through to the widget's clear path; the next Ctrl+C (now
//!       on an empty prompt) re-enters this level and runs CancelTurn.
//!   → 3. Esc policy (try_handle_esc_policy) on Prompt or Scrollback only,
//!       after overlays/dropdowns/selection returned Changed / stole Esc:
//!       turn running → Changed (swallow; Esc does not cancel)
//!       turn cancelling → CancelTurn (retry lost ack; Ctrl+C escalates to Quit)
//!       idle + non-empty prompt, prompt pane only → ArmPending ClearPrompt (2× within 800ms, hint)
//!       idle + empty + messages, either pane (Normal composer mode, no
//!         needs-input overlay pending, no open history search) → ArmPending
//!         RewindShowPicker (2×, silent)
//!       idle otherwise (scrollback-pane draft / latent mode / pending overlay /
//!         open history search, or empty + no messages) → Changed (swallow Esc;
//!         not FocusScrollback)
//!   → 4. return Unchanged → bubbles to app_view for global actions (quit)
//! ```
//!
//! Esc policy is independent of `[ui].simple_mode` (prompt editor) and
//! `[ui].vim_mode` (scrollback nav). Tab remains leave-prompt in both modes.
//!
//! ## Future: data/view split
//!
//! When multi-agent views arrive (swarm overview, aggregate stats), we'll
//! need views that read from MULTIPLE agents. At that point, split into:
//! - `AgentData` — entries, session, tracker (shared, view-agnostic)
//! - `AgentViewState` — scroll, selection, folds (per-view instance)
//!
//! See `plan/app-reorg.md` for details.
//!
//! ## Module organization
//!
//! The `AgentView` struct, its supporting types, and shared free functions
//! live here; the `impl AgentView` method clusters live in the per-domain
//! child modules declared below (input routing, rendering, selection, panes,
//! modals, ...). Child modules import from `super::` with at most one hop;
//! their `#[cfg(test)]` mods share `test_fixtures` below.
use crate::actions::ActionId;
use crate::key;
use crate::render::SafeBuf;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
/// Hit areas for inline media buttons, rebuilt each frame.
///
/// All hit areas are cleared at the start of inline media rendering and
/// repopulated only when the media is visible. This ensures scroll
/// resilience — stale hit areas are never left around.
#[derive(Debug, Clone, Default)]
pub(crate) struct InlineMediaHitAreas {
    /// Inline image areas (image overlay): clicking opens the file natively.
    pub media_areas: Vec<(ratatui::layout::Rect, std::path::PathBuf)>,
    /// Video poster areas: clicking starts/restarts inline playback.
    pub video_play_areas: Vec<(ratatui::layout::Rect, std::path::PathBuf)>,
    /// `[Play]` button rects: clicking starts/replays inline video.
    pub play_buttons: Vec<(ratatui::layout::Rect, std::path::PathBuf)>,
    /// `[Open]` button rects (overlay and text fallback): open the file natively.
    pub open_buttons: Vec<(ratatui::layout::Rect, std::path::PathBuf)>,
    /// `[Copy]` button rects (images only): clicking copies the image.
    pub copy_image_buttons: Vec<(ratatui::layout::Rect, std::path::PathBuf)>,
    /// Filepath line rects: clicking copies the path to the clipboard.
    pub filepath_areas: Vec<(ratatui::layout::Rect, std::path::PathBuf)>,
    /// Mermaid affordance-row button rects: `(rect, kind, source_idx)` where
    /// `source_idx` indexes [`mermaid_sources`](Self::mermaid_sources). `[Open]`/
    /// `[Copy path]` render the source lazily on click; `[Copy source]` copies
    /// it. Indexing keeps each diagram's source cloned once per frame, not per
    /// button.
    pub mermaid_buttons: Vec<(
        ratatui::layout::Rect,
        crate::scrollback::blocks::mermaid_content::AffordanceKind,
        usize,
    )>,
    /// Diagram sources for the visible affordance rows; [`mermaid_buttons`] index
    /// into this (one entry per visible diagram).
    ///
    /// [`mermaid_buttons`]: Self::mermaid_buttons
    pub mermaid_sources: Vec<String>,
}
/// Inline video playback state for scrollback media entries.
///
/// Created when the user clicks or presses Enter on a video poster frame.
/// Frames are extracted via ffmpeg in a background thread. Playback
/// advances one frame per tick, stops on the last frame (no loop).
#[derive(Debug)]
pub(crate) struct InlineVideoState {
    /// Video file path (used to match against visible placements).
    pub path: std::path::PathBuf,
    /// Pre-extracted frames, protocol-prepared (PNG for Kitty, JPEG for iTerm2).
    pub frames: Vec<Vec<u8>>,
    /// Current frame index (0-based).
    pub current_frame: usize,
    /// Timestamp of last frame advance (for fps pacing).
    pub last_frame_time: std::time::Instant,
    /// Target playback frame rate.
    pub fps: f64,
    /// True after the last frame has been displayed.
    pub finished: bool,
}
use super::actions::Action;
use super::agent::AgentSession;
use super::app_view::InputOutcome;
use crate::scrollback::EntryId;
use crate::scrollback::ScrollbackSearchState;
use crate::scrollback::state::ScrollbackState;
use crate::scrollback::text_selection::{
    ActiveBlockDrag, ActiveTextDrag, DragAutoScrollState, PendingBlockDrag, PendingTextDrag,
    PersistentTextSelection, ResolvedSelectionBoundaries, ResolvedSelectionModel,
    TableSelectionGeometry,
};
use crate::theme::Theme;
pub use crate::views::agent::{ActivePane, AgentViewLayout, InputMode, PaneAreas};
use crate::views::block_viewer::BlockViewerPane;
use crate::views::extensions_modal::ExtensionsModalState;
use crate::views::file_search::line_viewer::LineViewerState;
use crate::views::modal::{self, ActiveModal, ModalButtonHit};
use crate::views::permission_view::{PermissionViewState, SubagentInfo};
use crate::views::plan_approval_view::{PlanApprovalViewState, PlanComment};
use crate::views::prompt_widget::{PromptWidget, StashedPrompt};
use crate::views::question_view::QuestionViewState;
use crate::views::queue_pane::QueuePane;
use crate::views::subagent_catalog_pane::SubagentCatalogPane;
use crate::views::tasks_pane::TasksPane;
use crate::views::todo_pane::TodoPane;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::widgets::Widget;
use std::collections::{HashMap, HashSet, VecDeque};
use std::time::Instant;
mod cta;
mod input;
mod interactions;
mod jump;
mod links;
mod media;
mod modals;
mod notices;
mod panes;
mod paste;
mod plan;
mod prompt;
mod queue;
mod render;
mod rewind;
mod selection;
mod session;
mod shell_completion;
mod viewer;
use super::actions;
use super::dispatch;
pub(super) fn active_contexts_for_pane(pane: ActivePane) -> Vec<crate::actions::When> {
    use crate::actions::When;
    match pane {
        ActivePane::Prompt => vec![When::PromptFocused, When::AgentScreen, When::Always],
        ActivePane::Scrollback => {
            vec![When::ScrollbackFocused, When::AgentScreen, When::Always]
        }
        _ => vec![When::AgentScreen, When::Always],
    }
}
/// Pane focus within the agent view.
///
/// This will grow as we add more panes (tasks, review files, etc.).
pub type AgentPane = ActivePane;
/// Per-agent view-model.
///
/// Owns both business state (session, entries) and UI state (scroll,
/// selection, pane focus). See module docs for future split plans.
/// MCP server initialization progress, received from the shell.
#[derive(Debug, Clone)]
pub struct McpInitProgress {
    pub total: u32,
    pub connected: u32,
    pub started_at: Instant,
}
impl McpInitProgress {
    /// Max age for a `total == 0` seed before it auto-expires.
    pub const SEED_EXPIRE: std::time::Duration = std::time::Duration::from_secs(30);
    /// Whether the progress indicator should be visible in the UI.
    ///
    /// - `total > 0` (real servers): always visible until
    ///   `kigi/mcp_initialized` clears the progress.
    /// - `total == 0` (seed / 0-server): visible for at most
    ///   [`SEED_EXPIRE`] seconds, then auto-expires as
    ///   defense-in-depth against the shell failing to send
    ///   `mcp_initialized`.
    pub fn is_visible(&self) -> bool {
        self.total > 0 || self.started_at.elapsed() < Self::SEED_EXPIRE
    }
}
#[cfg(test)]
mod mcp_init_progress_tests {
    use super::McpInitProgress;
    #[test]
    fn is_visible_requires_servers_or_fresh_seed() {
        let real = McpInitProgress {
            total: 3,
            connected: 1,
            started_at: std::time::Instant::now(),
        };
        assert!(real.is_visible(), "real progress must be visible");
        let fresh = McpInitProgress {
            total: 0,
            connected: 0,
            started_at: std::time::Instant::now(),
        };
        assert!(fresh.is_visible(), "fresh seed must be visible");
        let expired = McpInitProgress {
            total: 0,
            connected: 0,
            started_at: std::time::Instant::now()
                - McpInitProgress::SEED_EXPIRE
                - std::time::Duration::from_secs(1),
        };
        assert!(!expired.is_visible(), "expired seed must not be visible");
    }
}
/// A clickable/hoverable screen region.
///
/// Tracks an optional screen rect (set during render) and whether the
/// mouse is currently hovering over it. Consolidates the repeated
/// `area: Option<Rect>` + `hovered: bool` pattern.
#[derive(Debug, Clone, Copy, Default)]
pub struct HitArea {
    pub rect: Option<Rect>,
    pub hovered: bool,
}
impl HitArea {
    /// Update hover state for a mouse position. Returns `true` if changed.
    pub fn update_hover(&mut self, col: u16, row: u16) -> bool {
        let new = self.rect.is_some_and(|r| r.contains((col, row).into()));
        let changed = new != self.hovered;
        self.hovered = new;
        changed
    }
    /// Check if a position is inside the rect.
    pub fn contains(&self, col: u16, row: u16) -> bool {
        self.rect.is_some_and(|r| r.contains((col, row).into()))
    }
    /// Set the rect (called during render).
    pub fn set(&mut self, rect: Option<Rect>) {
        self.rect = rect;
    }
    /// Like [`Self::set`], but drops the rect while a dropdown is open: dropdowns paint over these rows post-arm.
    pub fn set_unless_dropdown(&mut self, rect: Option<Rect>, dropdown_open: bool) {
        self.set(if dropdown_open { None } else { rect });
    }
    /// Clear rect and hover.
    pub fn clear(&mut self) {
        self.rect = None;
        self.hovered = false;
    }
}
pub use super::queue_edit::PromptMode;
/// Which special input mode the prompt is currently in.
///
/// These modes are **mutually exclusive**: only one can be active at a time.
/// `Normal` is the default. Each variant changes the prompt's visual appearance
/// (accent color, prefix, placeholder) and the action dispatched on Enter.
///
/// Orthogonal to `multiline_mode` ... and `PromptMode` ... and `InputMode`...
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PromptInputMode {
    /// Standard prompt: Enter sends `Action::SendPrompt`.
    #[default]
    Normal,
    /// Bash mode (`!` prefix): Enter sends `Action::SendBashCommand`.
    Bash,
    /// Remember mode (`#` prefix, green accent): Enter sends `Action::SendRememberNote`.
    Remember,
}
impl PromptInputMode {
    pub fn accent_color(self, theme: &Theme) -> Option<ratatui::style::Color> {
        match self {
            PromptInputMode::Normal => None,
            PromptInputMode::Bash => Some(theme.command),
            PromptInputMode::Remember => Some(theme.accent_remember),
        }
    }
    pub fn prefix_override(self, theme: &Theme) -> Option<(&'static str, ratatui::style::Color)> {
        match self {
            PromptInputMode::Normal => None,
            PromptInputMode::Bash => Some(("! ", theme.command)),
            PromptInputMode::Remember => Some(("# ", theme.accent_remember)),
        }
    }
    pub fn placeholder_override(self, multiline: bool) -> Option<&'static str> {
        match self {
            PromptInputMode::Normal | PromptInputMode::Bash => None,
            PromptInputMode::Remember => {
                if multiline {
                    Some("Save a memory note... (Enter for newline, Shift+Enter to save)")
                } else {
                    Some("Save a memory note... (Shift+Enter for multiline)")
                }
            }
        }
    }
    pub fn prompt_info_override(self) -> Option<&'static str> {
        match self {
            PromptInputMode::Normal => None,
            PromptInputMode::Bash => Some("Run shell command"),
            PromptInputMode::Remember => Some("Save memory note"),
        }
    }
    pub fn send_action(self, text: String) -> Action {
        match self {
            PromptInputMode::Normal => Action::SendPrompt(text),
            PromptInputMode::Bash => Action::SendBashCommand(text),
            PromptInputMode::Remember => Action::SendRememberNote(text),
        }
    }
    pub fn is_exit_key(self, key: &KeyEvent) -> bool {
        match self {
            PromptInputMode::Normal => false,
            PromptInputMode::Bash | PromptInputMode::Remember => {
                let ctrl_w = key!('w', CONTROL).matches(key);
                let ctrl_u = key!('u', CONTROL).matches(key);
                let ctrl_c = key!('c', CONTROL).matches(key);
                key.code == KeyCode::Backspace
                    || key.code == KeyCode::Esc
                    || ctrl_w
                    || ctrl_u
                    || ctrl_c
            }
        }
    }
}
/// Multi-click state for text-level selection (word/line).
///
/// Unlike block-level `last_click` (which tracks by entry_idx),
/// this tracks by (entry_idx, range_id, block_line_idx) to ensure
/// double-click word selection only triggers when clicking the same text region.
#[derive(Debug, Clone)]
pub struct TextClickState {
    pub time: Instant,
    pub entry_idx: usize,
    pub range_id: u16,
    pub block_line_idx: usize,
    pub col_within_range: u16,
    pub click_count: u8,
}
/// Maximum time (ms) between consecutive clicks to count as a multi-click.
pub(super) const MULTI_CLICK_TIMEOUT_MS: u128 = 300;
/// Minimum interval (ms) between clipboard toasts for rapid word/line
/// selections. Drag completions always show the toast regardless.
const CLIPBOARD_TOAST_DEBOUNCE_MS: u128 = 500;
/// Minimum interval (ms) between consecutive context-bar clicks. Each click
/// fires an async ACP `session/info` request, so without this debounce a
/// double/triple-click would spawn redundant backend round-trips and reopen
/// the modal multiple times.
pub(super) const CONTEXT_CLICK_DEBOUNCE_MS: u128 = 300;
/// Default highlight TTL when `keep_text_selection` is `flash`.
const DEFAULT_SELECTION_HIGHLIGHT_DURATION_MS: u64 = 150;
/// Duration of the transient mode-switch banner (shown above prompt on Shift+Tab).
/// 2 s full visibility + 0.3 s fade out @ 30 fps.
const MODE_BANNER_TOTAL_TICKS: u8 = 69;
/// Final portion of the banner lifetime spent fading out (full → invisible).
const MODE_BANNER_FADE_TICKS: u8 = 9;
/// Whether `Event::Paste(text)` should probe the clipboard for image
/// bytes / a file reference. See [`crate::clipboard::paste_payload_needs_clipboard_attachment_probe`].
#[cfg(any(target_os = "macos", target_os = "windows"))]
pub(super) fn bracketed_paste_should_probe(text: &str) -> bool {
    crate::clipboard::paste_payload_needs_clipboard_attachment_probe(text)
}
/// Check if the platform link-action modifier is held.
/// macOS: Cmd (via CoreGraphics). Linux/Windows: Ctrl (from mouse modifiers).
#[cfg(target_os = "macos")]
pub(super) fn is_link_modifier_held(_mouse_modifiers: KeyModifiers) -> bool {
    crate::input::macos_modifiers::snapshot().command
}
#[cfg(not(target_os = "macos"))]
pub(super) fn is_link_modifier_held(mouse_modifiers: KeyModifiers) -> bool {
    mouse_modifiers.contains(KeyModifiers::CONTROL)
}
/// Determine whether the link modifier is held during a key event.
///
/// On macOS, polls CoreGraphics directly (independent of the event's modifier bits).
/// On Linux/Windows, derives from the key event's modifier flags, with a
/// special case for Ctrl-key release events (where the modifier bit is still
/// set in the event but the physical key is no longer held).
fn is_link_modifier_for_key(key: &KeyEvent) -> bool {
    #[cfg(target_os = "macos")]
    {
        let _ = key;
        crate::input::macos_modifiers::snapshot().command
    }
    #[cfg(not(target_os = "macos"))]
    {
        if key.kind == crossterm::event::KeyEventKind::Release
            && matches!(
                key.code,
                KeyCode::Modifier(
                    crossterm::event::ModifierKeyCode::LeftControl
                        | crossterm::event::ModifierKeyCode::RightControl
                )
            )
        {
            false
        } else {
            key.modifiers.contains(KeyModifiers::CONTROL)
        }
    }
}
fn supports_osc22() -> bool {
    crate::terminal::terminal_context()
        .hyperlink_capabilities()
        .osc22_cursor
}
pub(super) fn has_native_link_hover() -> bool {
    crate::terminal::terminal_context()
        .hyperlink_capabilities()
        .native_link_hover
}
/// Whether app Cmd/Ctrl+click should open this link (vs terminal bare-URL open).
pub(super) fn app_should_open_link_on_click(link: &crate::scrollback::VisibleLink) -> bool {
    app_should_open_link_on_click_with(
        crate::terminal::terminal_context()
            .hyperlink_capabilities()
            .native_plain_url_open,
        link,
    )
}
/// Test form of [`app_should_open_link_on_click`]. Yields only Standard-scheme
/// bare-URL-text hits when `native_plain_url_open` (labels/citations/file stay).
pub(super) fn app_should_open_link_on_click_with(
    native_plain_url_open: bool,
    link: &crate::scrollback::VisibleLink,
) -> bool {
    if !native_plain_url_open {
        return true;
    }
    if !crate::app::link_opener::is_safe_to_open(
        &link.url,
        crate::terminal::hyperlinks::SchemeFilter::Standard,
    ) {
        return true;
    }
    !link.looks_like_bare_url_text()
}
/// Whether double/triple-click performs terminal-like word/line text selection
/// (and copy) instead of toggling a fold.
///
/// Unified into the `keep_text_selection` setting (the `word_select` mode):
/// reads the live appearance cache, so a Settings-panel change applies without
/// a restart and can never drift from the highlight-persistence behavior. The
/// default (`flash`) is fold-toggle, preserving backwards compatibility.
pub(super) fn is_text_selection_on_double_click() -> bool {
    crate::appearance::cache::load_keep_text_selection().selects_word()
}
/// A driver-side turn-end broadcast awaiting its `session/prompt` RPC
/// response. See [`AgentView::pending_turn_end_reconcile`].
#[derive(Debug, Clone)]
pub(crate) struct PendingTurnEnd {
    /// The prompt whose turn the broadcast declared ended.
    pub prompt_id: String,
    /// `stopReason` from the broadcast (`"cancelled"`, `"end_turn"`, …).
    pub stop_reason: Option<String>,
    /// `agentResult` detail from the broadcast (error text, when present).
    pub agent_result: Option<String>,
    /// `_meta.cancelTrigger` from the broadcast (`"send_now"` marks a
    /// cancel-and-send whose "Turn cancelled" marker is suppressed). `None`
    /// on older shells / non-cancel ends.
    pub cancel_trigger: Option<String>,
    /// When the broadcast arrived; the reconcile fires after
    /// [`super::dispatch::TURN_END_RECONCILE_GRACE`].
    pub received_at: std::time::Instant,
}
/// Stop/stop_failure hook runs held for the live turn's terminal marker.
/// See [`AgentView::pending_stop_hooks`].
#[derive(Debug, Clone, Default)]
pub(crate) struct PendingStopHooks {
    /// The turn the stash belongs to; a stash that can't be matched to the
    /// ending turn flushes standalone instead of attaching to its marker.
    pub prompt_id: Option<String>,
    /// `(event_name, runs)` per hook batch, in arrival order
    /// (`stop_failure` before `stop` on error turns).
    pub groups: Vec<(String, Vec<crate::scrollback::blocks::tool::HookRunEntry>)>,
}
/// Components for the deferred fork banner. Stored by
/// `dispatch_fork_resolved` and formatted into the final banner text
/// in `TaskResult::SessionLoaded` once the child's session id is known.
#[derive(Debug, Clone)]
pub(crate) struct PendingForkBanner {
    /// Full session id of the parent session.
    pub parent_sid: String,
    /// Whether the fork created a new worktree.
    pub worktree: bool,
}
/// In-flight reconnect session reload.
///
/// Opened by [`AgentView::begin_session_reload`]: the pre-outage scrollback
/// and tracker are stashed here while the live fields point at fresh state
/// the `session/load` replay streams into. [`AgentView::finish_session_reload`]
/// then keeps the replayed state, merges a cursor-resolved live tail onto the
/// stash, or restores the stash wholesale on failure — so a failed or
/// superseded reload can never leave the transcript blank.
///
/// Restore scope: the stash covers the transcript-critical state below (plus
/// the todo list). Satellite state the replay also mutates —
/// `subagent_sessions`/`subagent_views`, bg/scheduled tasks,
/// `available_commands`, context usage — is NOT restored on failure: live
/// updates keep routing through those maps during the window, so stashing
/// them would break mid-window routing, and they re-converge on the next
/// successful reload. A `SubagentInfo::scrollback_entry_id` replayed during a
/// failed window dangles into the discarded staging state (harmless no-op
/// lookups; never aliased, thanks to the shared `EntryId` space).
pub(crate) struct SessionReload {
    /// Reconnect generation (from `ConnectionStatus::Connected`) this reload
    /// was opened for; finalization is rejected for any other generation.
    generation: u64,
    /// Pre-outage transcript.
    scrollback: ScrollbackState,
    /// Pre-outage streaming tracker (paired with `scrollback`).
    tracker: crate::acp::tracker::AcpUpdateTracker,
    /// Pre-outage todo list (replayed Plan updates overwrite the live pane).
    todo: TodoPane,
    /// Reconnect cursor as of window open, restored with the stash so a
    /// later reload doesn't skip events the restored transcript never got.
    last_seen_event_id: Option<String>,
    /// Live dedup highwaters (ACP + xAI) as of window open (same restore
    /// rationale).
    last_applied_event_seq: Option<u64>,
    last_applied_xai_event_seq: Option<u64>,
    /// Whether any `isReplay` update applied during this window. False means
    /// the agent resolved the cursor and sent only a live post-cursor tail.
    saw_replay: bool,
    /// Whether a Plan update applied during this window: the cursor-merge
    /// outcome then keeps the staging todo list (newer) instead of the stash.
    saw_todo_update: bool,
}
/// Follow-up suggestion chips for the latest assistant response
/// (`kigi/follow_ups`). Streaming-only: never persisted, does not survive a
/// session reload. Keyed by the assistant `response_id` (the newest-wins key).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FollowUps {
    /// `params.response_id` these chips belong to (the newest-wins key).
    pub(crate) response_id: String,
    /// Suggestion labels, already sanitized (control + bidi/format chars
    /// stripped) and length-bounded at ingestion. Non-empty whenever
    /// `AgentView::follow_ups` is `Some`.
    pub(crate) suggestions: Vec<String>,
}
/// A prompt submit stashed while a clipboard attachment probe is off-thread.
///
/// Kind-only: the payload is re-derived from the live widget when the send is
/// re-issued (see [`AgentView::build_deferred_send_action`]), so the freshly
/// attached image chip (and its aligned chip range) travels with it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AgentDeferredSend {
    /// Enter — a normal prompt send.
    SendPrompt,
    /// Ctrl+Enter — a mid-turn interjection.
    Interject,
}
/// How the parked-marker slot was consumed. Both variants carry the turn's
/// prompt id and both keep the parked (idle) chrome. `Rendered` markers keep
/// flowing on tail staleness (see `maybe_push_parked_marker`); `Forgone` (an
/// interjection continued the parked turn) is final — a later "Worked for"
/// line would land below the interjected message, flipping the transcript.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ParkedMarkerSlot {
    /// A "Worked for X. … still running." marker block was pushed.
    Rendered {
        prompt_id: String,
        entry_id: EntryId,
        agent_output_epoch: u64,
    },
    /// The marker was forgone: an interjection continued the parked turn.
    Forgone(String),
}
impl ParkedMarkerSlot {
    /// The prompt id the slot was consumed for, regardless of variant.
    pub(crate) fn prompt_id(&self) -> &str {
        match self {
            ParkedMarkerSlot::Rendered { prompt_id, .. } | ParkedMarkerSlot::Forgone(prompt_id) => {
                prompt_id
            }
        }
    }
    /// The mutable parked marker entry and its parent-output boundary.
    pub(crate) fn rendered_marker(&self) -> Option<(EntryId, u64)> {
        match self {
            ParkedMarkerSlot::Rendered {
                entry_id,
                agent_output_epoch,
                ..
            } => Some((*entry_id, *agent_output_epoch)),
            ParkedMarkerSlot::Forgone(_) => None,
        }
    }
}
pub struct AgentView {
    pub session: AgentSession,
    pub scrollback: ScrollbackState,
    pub prompt: PromptWidget,
    /// Sticky: once the user types in the prompt, hide the tip for the session.
    pub tip_typing_dismissed: bool,
    pub todo: TodoPane,
    pub tasks: TasksPane,
    pub catalog: SubagentCatalogPane,
    pub queue: QueuePane,
    /// Per-agent mirror of the server-authoritative shared prompt queue
    /// (`AppView::shared_prompt_queues[sid]`), kept in sync by
    /// `handle_queue_changed` and the immediate-send path. The queue
    /// pane renders the union of this and the local `pending_prompts`; the
    /// edit handlers read it to route remove/reorder by origin. Empty unless a
    /// plain prompt was queued server-side while a turn was running.
    pub shared_queue: Vec<crate::app::prompt_queue::QueueEntryWire>,
    /// True when this session was opened via `session/load` (session picker
    /// resume, `/resume`, or a leader dashboard roster attach) rather than
    /// created locally — i.e. this client is *viewing* a session it did not
    /// start. While set, the ACP gate adopts the prompt id of incoming live
    /// `session/update` deltas (the driver's turn) instead of dropping them,
    /// so the viewer renders the in-flight (and subsequent) turns live. This
    /// must NOT be applied to a locally-created driver, whose post-rewind
    /// stale-chunk drop semantics rely on the strict prompt-id match. Cleared
    /// in `maybe_drain_queue` the moment this client sends its own prompt
    /// ("takes the wheel"), and re-derived per turn from
    /// [`Self::self_originated_prompt_ids`] in the ACP gate / turn-start shim:
    /// a client that has driven a turn can still go on to VIEW a turn another
    /// client drives (e.g. a `/loop` cron, or a plain prompt typed in another
    /// pane), so this flag is no longer a one-way latch.
    pub attached_as_viewer: bool,
    /// Prompt ids of turns THIS client originated (sent to the agent as the
    /// turn driver). The ACP gate consults this to keep `attached_as_viewer`
    /// per-turn accurate: a prompt id present here is this client's own turn
    /// (drive it — and drop a stale post-rewind chunk on a mismatch), while one
    /// that is absent is another client's (or a server-initiated) turn (adopt +
    /// render it as a viewer). Without this, the flag latched false after the
    /// first local prompt and the gate dropped every later turn a different
    /// pane drove. Bounded FIFO — only recent ids matter (a stale chunk arrives
    /// right after its turn ends).
    pub self_originated_prompt_ids: VecDeque<String>,
    /// Highwater of the largest `eventId` counter applied to this session's
    /// scrollback (see `acp::meta::NotificationMeta::event_seq`). Incoming
    /// `session/update`s with a counter `<=` this are duplicates (replay/live
    /// overlap, a re-emit after the reconnect gate, or duplicate routing) and
    /// are dropped so each event renders exactly once. `None` until the first
    /// `eventId`-bearing update.
    ///
    /// ACP stream only — the xAI stream keeps its own highwater
    /// ([`Self::last_applied_xai_event_seq`]) because the two streams are not
    /// delivered in one id order: ACP lines ride the agent's FIFO event
    /// pipeline while xAI lines are emitted direct-to-gateway, so a fresh xAI
    /// id arriving ahead of queued lower-id ACP chunks must not make the
    /// chunks look stale (silent live-text loss).
    pub last_applied_event_seq: Option<u64>,
    /// xAI-stream sibling of [`Self::last_applied_event_seq`] (see there for
    /// why the highwaters are split). Same drop rule, replay-exempt.
    pub last_applied_xai_event_seq: Option<u64>,
    /// Raw `eventId` of the most recent update APPLIED to this root session —
    /// replay or live, on both the ACP and xAI paths; dropped updates (dedup,
    /// promptId gate, unexpected replay) don't move it. Sent as `_meta.cursor`
    /// on a reconnect `session/load` so the agent replays only the post-cursor
    /// tail. Why the full string: see
    /// [`crate::acp::meta::NotificationMeta::event_id`].
    pub last_seen_event_id: Option<String>,
    /// Open reconnect reload window, if any. See [`SessionReload`].
    pub(crate) session_reload: Option<SessionReload>,
    /// Unexpected-replay drops since the last reload window opened. Gates the
    /// drop log to one `warn!` per incident (a late replay is one line per
    /// event — thousands for a large transcript).
    pub(crate) unexpected_replay_drops: u32,
    /// Prompt ids whose durable `TurnCompleted` terminal arrived during THIS
    /// load's replay window (`loading_replay`). The running turn is not adopted
    /// until replay finishes, so a terminal seen mid-replay can't be finalized
    /// yet — it is recorded here and consulted by
    /// [`Self::should_adopt_running_prompt`] so the post-replay adoption skips a
    /// turn that already ended (otherwise the viewer re-strands on "Waiting…").
    /// Reset at the start of every load so it never leaks across loads.
    pub(crate) replayed_terminal_prompts: HashSet<String>,
    pub active_pane: AgentPane,
    /// Current mode of the prompt widget (normal vs editing a queued prompt).
    pub prompt_mode: PromptMode,
    /// Current special prompt input mode (Normal/Bash/Feedback/Remember).
    pub prompt_input_mode: PromptInputMode,
    /// Multiline input mode: swap Enter (insert newline) and Shift+Enter (send).
    /// Toggled by `Ctrl+M` or `/multiline`. Not persisted across sessions.
    pub multiline_mode: bool,
    /// Vim-mode scrollback keybindings. When `false` (default), bare-letter
    /// and Shift+letter scrollback bindings (j/k, h/l, g/G, y/Y, o/O, r,
    /// x, e/E, L/H, plus the `i` FocusPrompt alt) are suppressed and the
    /// pressed letter forwards to the prompt textarea via
    /// `Action::FocusPrompt` + `ActionThenForward`. Arrows / Tab / Esc /
    /// Space / PgUp / PgDn and all `Ctrl+letter` bindings remain active
    /// regardless of this flag. Pager-owned ephemeral field — reset each session.
    pub vim_mode: bool,
    /// Runtime InputMode synced from persisted `simple_mode` bool (false=Vim). Pane reconcile only on Vim+empty prompt. Subagent children are forced to Vim after new() (no prompt UI).
    pub input_mode: InputMode,
    /// Whether the current/last turn was a bash-mode command.
    /// Set when a bash command starts, cleared on next non-bash turn.
    /// Used to auto-focus scrollback after bash turn completion.
    pub bash_turn: bool,
    /// The task ID of the currently running cron turn, if any.
    /// Set when a cron prompt is drained, cleared on turn completion.
    pub cron_task_id: Option<String>,
    /// Stashed normal prompt state while editing a queued prompt.
    /// Restored when editing ends.
    pub stashed_prompt: Option<StashedPrompt>,
    /// Complete prompt stashed from a turn that failed because the login
    /// expired (401 / re-auth). Used by the `AuthComplete` handler to
    /// auto-resubmit the prompt after a successful mid-session re-auth so
    /// the user doesn't have to retype it.
    pub reauth_stashed_prompt: Option<crate::app::agent::InFlightPrompt>,
    /// Currently active modal dialog (blocks all other input).
    pub active_modal: Option<ActiveModal>,
    /// Hit areas for modal buttons (from last render).
    pub(crate) modal_buttons: Vec<ModalButtonHit>,
    /// Currently hovered modal button key (for highlight).
    pub(crate) modal_hovered_key: Option<char>,
    /// Cached server-reported context state.
    pub context_state: Option<kigi_shell::session::ContextInfo>,
    /// Gateway light-frontend session (`kind: "chat"` / `--chat` / conversation
    /// resume). Suppresses Build credits / local sampler context telemetry so the
    /// status bar and prompt never imply remote usage from wrong metrics.
    pub chat_kind: bool,
    /// Process-wide `--chat` (mirrors `AppView::chat_mode`; set via
    /// [`Self::apply_app_scoped_gates`]). UI policy only: hides picker
    /// source filter / delete / deep search on a conversations-only list.
    /// Unlike `chat_kind`, stays `false` for a `/chat` one-shot session in
    /// a Build process, whose picker still lists local sessions.
    pub app_chat_mode: bool,
    /// Current goal orchestration state. Set by `GoalUpdated` session
    /// notifications, cleared when a new session starts.
    pub goal_state: Option<super::agent::GoalDisplayState>,
    /// Current graph orchestration state. Set by `GraphUpdated` session
    /// notifications, cleared on the `"cleared"` sentinel / new session.
    pub graph_state: Option<super::agent::GraphDisplayState>,
    /// The consumed parked-wait marker slot for the current turn, if any.
    /// Keyed by prompt id: a new turn naturally invalidates the slot with no
    /// explicit clear site. See [`ParkedMarkerSlot`].
    pub(crate) parked_wait_marker_for: Option<ParkedMarkerSlot>,
    /// A turn-end marker announced background work ("N still running"), so
    /// no-wake completions landing between turns re-emit a fresh work-only
    /// status line after their chip (wake-bound ones get the wake turn's end
    /// marker instead). Written by exactly one assignment — the shared
    /// marker tail `push_end_marker_block` (real-turn AND wake markers:
    /// counted opens, workless closes) — and cleared when a real turn starts
    /// and on every replay-window entry, closing the between-turns window.
    pub(crate) end_work_announced: bool,
    /// Live `stop`/`stop_failure` hook runs held for the turn's terminal
    /// marker (driver order: the hooks arrive before the `PromptResponse`
    /// that pushes it). Consumed or flushed by `push_turn_terminal_marker`;
    /// dropped on every replay-window entry.
    pub(crate) pending_stop_hooks: Option<PendingStopHooks>,
    /// Goal id of the most recently cleared goal, captured from the dropped
    /// state (the `cleared` event itself carries an empty id). Drops a late
    /// in-flight `GoalUpdated` that would otherwise resurrect the cleared
    /// chip/modal. Single slot: goal ids are unique, so only the latest clear
    /// can race a stale update.
    pub last_cleared_goal_id: Option<String>,
    /// Whether the expanded goal detail overlay is visible.
    /// Toggled by `Action::ToggleGoalDetail`. Only shown when
    /// `goal_state` is `Some`.
    pub show_goal_detail: bool,
    /// UTC ms when the current turn started (`turnStartMs` from notification meta).
    /// Used for turn elapsed display.
    pub turn_start_ms: Option<i64>,
    /// `(prompt_id, turnStartMs)` of the streaming wake turn, recorded off its
    /// live deltas. Non-adopted synthetic turns never set `turn_started_at`,
    /// and the durable `TurnCompleted` envelope carries no timing — this is
    /// the wake-end marker's only elapsed source. Consumed (pid-matched) at
    /// that marker's push; `None` there renders the marker without a duration.
    pub wake_turn_start: Option<(String, i64)>,
    /// Local wall-clock time when the current turn started.
    /// Set by `maybe_drain_queue` when a prompt is sent. Used to compute
    /// elapsed time for "Worked for Xm Ys." system messages.
    pub turn_started_at: Option<Instant>,
    /// Turn-start anchor a `turn.first_activity` log was already emitted for (fire-once-per-turn guard).
    pub first_activity_logged_for: Option<Instant>,
    /// Accumulated duration the turn timer was paused (while the user was
    /// answering questions via `AskUserQuestion`). Reset when the turn ends.
    pub turn_paused_duration: std::time::Duration,
    /// IDs of interjections this client sent and already rendered locally
    /// (optimistic echo). The shell broadcasts `kigi/session/interjection` to
    /// every attached pane; when our own broadcast echoes back carrying an id
    /// in this set, `handle_interjection` drops it (we already showed it) and
    /// removes the id. Other panes (which lack the id) render it. This is the
    /// queue's optimistic-echo + reconcile-by-id pattern, applied so the
    /// originator gets instant feedback AND viewers stay in sync.
    pub self_interjection_ids: std::collections::HashSet<String>,
    /// Local wall-clock time when the most recent turn finished
    /// (success, failure, or cancellation). Used by the dashboard
    /// modal to display "Nm ago" idle markers. Initialised to the
    /// agent-creation time in [`AgentView::new`] so newly-created
    /// agents that have never run a turn still show a sensible
    /// relative time.
    pub last_active_at: Option<Instant>,
    pub current_branch: Option<String>,
    pub is_worktree: bool,
    pub main_repo: Option<String>,
    /// Human-readable worktree label from the worktree metadata DB, when
    /// this agent's cwd is a managed worktree. Refreshed off the git
    /// caches when the dashboard opens; drives the dashboard row's
    /// worktree-name subtitle. `None` for non-worktree agents.
    pub worktree_label: Option<String>,
    /// Local wall-clock time when the current activity phase started.
    /// Reset on each activity transition (thinking → responding → tool, etc.).
    /// Used for the `(5s)` phase timer in the turn status line.
    pub activity_started_at: Option<Instant>,
    /// Last observed [`TurnActivity`] — used to detect phase transitions
    /// and reset `activity_started_at`.
    pub(crate) last_activity: Option<crate::acp::tracker::TurnActivity>,
    /// Cached pane areas from last render, for mouse hit-testing.
    pub pane_areas: PaneAreas,
    /// Entry index currently hovered by the mouse (for dimmed selection box).
    pub hovered_entry: Option<usize>,
    /// Pending markdown text drag before the pointer crosses the drag threshold.
    pub pending_text_drag: Option<PendingTextDrag>,
    /// Active markdown text drag selection.
    pub drag_selection: Option<ActiveTextDrag>,
    /// Pending whole-block drag before the pointer crosses the drag threshold.
    pub pending_block_drag: Option<PendingBlockDrag>,
    /// Active whole-block drag selection.
    pub block_drag_selection: Option<ActiveBlockDrag>,
    /// Deferred text-drag anchor, armed (with the press position, for
    /// tracing) by a scrollback press that hit no selectable text — chrome,
    /// vpad, and gap rows all count, so any scrollback point can start a
    /// selection gesture — or by a press on the passive strips between the
    /// scrollback pane and the prompt box (turn status, banner, gap rows;
    /// interactive controls there consume their presses first). While set,
    /// the first drag motion that lands on selectable text anchors an
    /// [`ActiveTextDrag`] THERE (the entry point, not the press point) and
    /// cancels any block-drag state; the conversion is one-way. A gesture
    /// that never enters text keeps whatever the press
    /// armed alongside (the in-pane whole-block drag; strips arm nothing
    /// else), and a release while still deferred falls back to the press's
    /// native semantics (in-pane: the plain click cascade; strips: a no-op —
    /// no click latch is set there). Btw presses never arm it, and no source
    /// arms while the block viewer is open.
    pub deferred_text_press: Option<(u16, u16)>,
    /// Persistent text selection (survives mouse-up). Set after drag
    /// completion, double-click, or triple-click. Cleared on next click
    /// elsewhere, Escape, or navigation.
    pub persistent_text_selection: Option<PersistentTextSelection>,
    /// Table geometry backing a table-shaped drag / persistent selection,
    /// keyed to that selection; ignored when the key doesn't match.
    pub table_selection_geometry: Option<TableSelectionGeometry>,
    /// Timestamp when the current persistent selection was created.
    /// Used for auto-dismissal after a configurable timeout.
    pub selection_created_at: Option<Instant>,
    /// Last mouse position during an active drag (for autoscroll re-hit-testing).
    pub last_drag_mouse: Option<(u16, u16)>,
    /// Active drag autoscroll state (scrolls on each tick while set).
    pub drag_autoscroll: Option<DragAutoScrollState>,
    /// Whether the primary mouse button is currently held down.
    pub(crate) left_mouse_down: bool,
    /// Whether a left-button drag that started in the feedback prompt is in
    /// progress while the plan preview (line viewer) is open. Used to keep
    /// forwarding `Drag`/`Up` events to the prompt so text selection in the
    /// input works even though the line viewer otherwise intercepts them.
    pub(crate) plan_prompt_mouse_drag: bool,
    /// Last resolved scrollback selection model from the most recent draw.
    pub last_scrollback_selection_model: ResolvedSelectionModel,
    /// Edit-only hidden source boundaries paired with the last scrollback model.
    pub(crate) last_scrollback_selection_boundaries: ResolvedSelectionBoundaries,
    /// Link overlay from the last frame (scrollback + optional `/btw` merge;
    /// for OSC 8 emission).
    pub last_link_overlay: crate::render::osc8::LinkOverlay,
    /// Rects of overlays drawn over the scrollback content this frame (dropdowns,
    /// goal-detail). Used to exclude covered links from both OSC 8 emission and
    /// mouse hover/click hit-testing. Rebuilt each `draw()`.
    pub frame_occluder_rects: Vec<Rect>,
    /// Per-frame map of clickable link regions (populated after scrollback render).
    pub visible_link_map: crate::scrollback::link_map::VisibleLinkMap,
    /// Number of links in `visible_link_map` that came from the scrollback
    /// rebuild (citations included). Overlay-only sources (e.g. `/btw`) are
    /// appended after this prefix each frame and truncated back on the next.
    scrollback_visible_link_count: usize,
    /// Index of the currently highlighted link in `visible_link_map` for
    /// keyboard link navigation (o/O cycling). `None` when not in link-nav mode.
    pub highlighted_link_idx: Option<usize>,
    /// Link index under the mouse cursor (for hover highlight).
    pub hovered_link_idx: Option<usize>,
    /// Last emitted OSC 22 pointer state (avoids re-emitting every frame).
    pub last_pointer_on_link: bool,
    /// Selection model for the /btw overlay panel (populated each frame).
    pub last_btw_selection_model: ResolvedSelectionModel,
    /// Cached screen rect of the /btw overlay panel from the last render.
    pub last_btw_area: Rect,
    /// Pending plain scrollback click that should dispatch on mouse-up if no drag starts.
    pub pending_scrollback_click: Option<(u16, u16)>,
    /// Pending link click: (col, row, url). Set on Down(Left) when a link is hit,
    /// consumed on Up(Left) at the same position, cleared on drag.
    pub pending_link_click: Option<(u16, u16, String)>,
    /// Absolute paths of media generated in this transcript, used to resolve the
    /// short relative paths the model prints (`images/1.jpg`) to clickable
    /// links. Rebuilt from scrollback only when its generation changes.
    pub media_link_paths: Vec<std::path::PathBuf>,
    /// Scrollback generation [`Self::media_link_paths`] was built for.
    pub media_link_paths_gen: Option<u64>,
    /// Last mouse position (column, row) for hover hit-testing.
    pub last_mouse_pos: (u16, u16),
    /// When the pointer last moved (any mouse event). Bounds the macOS
    /// Cmd-key link-hover poll: a pointer merely *resting* over content must
    /// not keep the ~30fps animation tick (and its per-tick CoreGraphics
    /// query) alive indefinitely — see [`Self::needs_link_modifier_poll`].
    pub last_mouse_moved_at: Option<Instant>,
    /// Last click info for multi-click detection: (timestamp, entry_index, click_count).
    pub last_click: Option<(Instant, usize, u8)>,
    /// Text-level multi-click state (finer-grained than block-level `last_click`).
    /// Mutually exclusive with `last_click`: one is cleared when the other is set.
    pub last_text_click: Option<TextClickState>,
    /// Timestamp of the last clipboard toast shown for word/line selection.
    /// Used to debounce rapid toasts; drag completions bypass this.
    pub last_clipboard_toast_at: Option<Instant>,
    /// Timestamp of the last accepted context-bar click. Used to debounce
    /// rapid clicks so we don't spawn a redundant `session/info` request
    /// per double-click.
    pub last_context_click_at: Option<Instant>,
    /// Whether the mouse is hovering over the prompt widget.
    pub hovered_prompt: bool,
    pub hit_badge: HitArea,
    pub hit_context: HitArea,
    pub hit_todo_close: HitArea,
    pub hit_bg_close: HitArea,
    pub hit_subagent_close: HitArea,
    pub hit_catalog_close: HitArea,
    pub hit_bg_status: HitArea,
    pub hit_goal_status: HitArea,
    pub hit_goal_close: HitArea,
    pub hit_bg_button: HitArea,
    #[allow(dead_code)]
    pub(crate) last_bg_click: Option<Instant>,
    pub hit_queue_close: HitArea,
    pub hit_queue_badge: HitArea,
    pub hit_plan_button: HitArea,
    pub hit_plan_approval_status: HitArea,
    pub hit_follow_indicator: HitArea,
    /// CWD / worktree path in the status bar (click to copy).
    pub hit_cwd: HitArea,
    /// Cancel button in turn status line (`[stop]`).
    pub hit_cancel_button: HitArea,
    /// Scrollbar track for the scrollback pane (for click-to-jump / drag).
    pub hit_scrollbar: HitArea,
    /// Whether a scrollbar drag is in progress on the scrollback scrollbar.
    pub scrollbar_dragging: bool,
    /// Cached screen area of the file search dropdown items (for mouse hit-testing).
    /// Excludes border rows — only the clickable item rows.
    pub(crate) dropdown_items_area: Option<Rect>,
    /// Cached screen area of the slash dropdown items (for mouse hit-testing).
    pub(crate) slash_dropdown_items_area: Option<Rect>,
    /// Slash dropdown hit-test state from the renderer (see
    /// [`crate::views::slash_dropdown::RenderedDropdown`]).
    pub(crate) slash_dropdown_hit: crate::views::slash_dropdown::RenderedDropdown,
    /// Cached screen area of the completion dropdown items (for mouse hit-testing).
    pub(crate) completion_dropdown_items_area: Option<Rect>,
    /// Cached screen area of the history search dropdown items (for mouse hit-testing).
    pub(crate) history_dropdown_area: Option<Rect>,
    /// Last prompt click timestamp (for double-click detection).
    pub(crate) last_prompt_click_ms: Option<Instant>,
    /// Active line viewer popup (Phase 3). When `Some`, the viewer intercepts
    /// all input and renders as a centered overlay.
    pub(crate) line_viewer: Option<LineViewerState>,
    /// Active image viewer popup. When `Some`, shows an image preview that
    /// intercepts input (Esc to close).
    pub(crate) image_viewer: Option<crate::prompt_images::ImageViewerState>,
    /// Receiver for background image loading. Set when a deferred image
    /// viewer spawns a load thread; polled each tick via `try_recv()`.
    pub(crate) image_load_rx:
        Option<std::sync::mpsc::Receiver<crate::prompt_images::ImageLoadResult>>,
    /// Active video viewer popup (Esc to close, Space to pause).
    pub(crate) video_viewer: Option<crate::prompt_images::VideoViewerState>,
    /// Active `/gboom` easter-egg game modal.
    pub(crate) gboom: Option<crate::gboom::GboomState>,
    /// Protocol-prepared image bytes keyed by file path. Used for dimension
    /// decoding and iTerm2 re-sends. Kitty transmits once and re-places.
    pub(crate) inline_media_cache: std::collections::HashMap<std::path::PathBuf, Vec<u8>>,
    /// Kitty GPU image IDs per media path. Each path gets a unique ID (2+)
    /// so switching between images is a cheap re-place (~80 bytes) instead
    /// of a full re-transmit. ID 1 is reserved for modal overlays.
    pub(crate) inline_media_ids: std::collections::HashMap<std::path::PathBuf, u32>,
    /// Paths whose iTerm2 inline data has already been emitted this placement
    /// cycle. Avoids re-sending full base64 image data every TUI frame.
    /// Last iTerm2 placement per path — re-emit when `screen_rect` changes.
    pub(crate) inline_media_iterm_emitted:
        std::collections::HashMap<std::path::PathBuf, ratatui::layout::Rect>,
    /// Counter for allocating the next Kitty image ID.
    pub(crate) next_inline_media_id: u32,
    /// Active inline video playback (user-initiated via click/Enter).
    pub(crate) inline_video: Option<InlineVideoState>,
    /// Receiver for background video frame extraction.
    pub(crate) video_load_rx: Option<std::sync::mpsc::Receiver<Option<InlineVideoState>>>,
    /// Off-thread Mermaid render runtime (worker channels + the on-click renders
    /// awaiting their result). Lazily created on the first *cache-missing*
    /// `[Open]`/`[Copy path]` click; `None` until then.
    pub(crate) mermaid: Option<crate::app::mermaid_worker::MermaidRuntime>,
    /// Off-thread edit-diff full-file syntax highlight upgrade. Lazily created
    /// on the first successful Edit tool completion that has hunks.
    pub(crate) edit_hl: Option<crate::app::edit_highlight_worker::EditHlRuntime>,
    /// Whether any inline media is currently placed on screen.
    pub(crate) inline_media_active: bool,
    /// Image IDs that were placed on screen last frame. Used to detect
    /// images that scrolled off and need their Kitty placements cleared.
    pub(crate) last_placed_ids: HashSet<u32>,
    /// Previous terminal dimensions — used to detect resize and invalidate
    /// Kitty IDs (terminals clear GPU data on resize).
    ///
    /// This is the size of the rect this view last painted into, which can
    /// be smaller than the terminal (dashboard overlay header band/popup,
    /// dev tracing split). Only `note_terminal_size` (draw) writes it.
    pub(crate) last_terminal_size: (u16, u16),
    /// Set on every `Event::Resize` (see `AppView::handle_input`), cleared
    /// by the next draw's re-measure. While set, `last_terminal_size` is
    /// known-invalidated and the ephemeral-tip show gate refuses, so a
    /// trigger racing the (debounced) resize draw can never burn a seen
    /// count against a height the new layout may not be able to paint.
    pub(crate) terminal_size_stale: bool,
    /// Hit areas for inline media buttons (cleared and rebuilt each frame).
    pub(crate) inline_media_hits: InlineMediaHitAreas,
    /// Active hooks/plugins modal popup. When `Some`, blocks all input and
    /// renders as a centered overlay. Opened by `/hooks`, `/plugins`, or `/mcps`.
    pub(crate) extensions_modal: Option<ExtensionsModalState>,
    /// Active agents modal popup. When `Some`, blocks all input and
    /// renders as a centered overlay. Opened by `/config-agents` or `/agents`.
    pub(crate) agents_modal: Option<crate::views::agents_modal::AgentsModalState>,
    pub(crate) persona_detail: Option<crate::views::persona_detail::PersonaDetailState>,
    /// Active /btw side question overlay. When `Some`, renders as a dismissible
    /// overlay and captures keyboard input (Esc/Enter/Space to dismiss).
    pub btw_state: Option<crate::views::btw_overlay::BtwOverlayState>,
    /// Whether the /btw panel holds keyboard focus. The panel is non-blocking,
    /// so Up/Down/PgUp/PgDn scroll it when focused and otherwise reach the
    /// prompt. Set on a `Done` answer; cleared when the user types in or clicks
    /// the prompt.
    pub(crate) btw_focused: bool,
    /// Hit area for the [Esc] close button in the /btw panel title.
    pub(crate) hit_btw_close: HitArea,
    /// Toast message to display briefly (e.g., "Copied!" after y).
    /// Tuple of (message, remaining_ticks). Decremented each tick, removed at 0.
    /// Does **not** carry sticky status banners — see [`Self::sticky_toast`].
    pub(crate) toast: Option<(String, u8)>,
    /// Single-slot ephemeral tip shown in the banner rect above the prompt.
    /// Unlike `toast`, survives typing; cleared by TTL, any prompt-box
    /// submit (prompt/interject/bash/feedback/remember), or explicit clear.
    /// Show via `show_ephemeral_tip` (renderability-gated), never `.show()`.
    pub(crate) ephemeral_tip: crate::tips::EphemeralTipState,
    /// Prompt text snapshot taken when the word-select tip was shown. Any
    /// divergence (typed, pasted, dropped — every edit path, no per-helper
    /// hooks) means the user moved past the double-click moment: the Ctrl+Y
    /// intercept refuses and the tick path retires the tip, so the long TTL
    /// can never shadow yank mid-edit. `None` while the tip is not showing.
    pub(crate) word_select_tip_prompt_snapshot: Option<String>,
    /// When the last fold/nav double-click landed on assistant text (a
    /// word-select probe). A second probe within the repeat window is the
    /// repeated-selection-attempt signal that fires the word-select tip —
    /// lone double-clicks (habitual folders) never tip.
    pub(crate) last_word_select_probe: Option<Instant>,
    /// Persistent status line (e.g. mouse reporting off). Survives transient
    /// toasts, keypress dismissal, and subagent open/close when propagated
    /// via [`Self::set_sticky_toast_recursive`].
    pub(crate) sticky_toast: Option<String>,
    /// Transient "Switched to mode: X" banner shown above the prompt after
    /// Shift+Tab. (message, remaining_ticks). Full brightness for 2 s, then
    /// fades out over the final 0.3 s.
    pub(crate) mode_switch_banner: Option<(String, u8)>,
    /// Fullscreen block viewer. When `Some`, replaces the scrollback area.
    pub(crate) block_viewer: Option<BlockViewerPane>,
    /// Active scrollback search session. When `Some`, vim `/` (or `/find`) is
    /// searching the scrollback. Inert until input wiring opens it.
    pub(crate) scrollback_search: Option<ScrollbackSearchState>,
    /// Hit area for scrollback selection box copy button.
    pub(crate) hit_sb_copy: HitArea,
    /// Hit area for scrollback selection box view button.
    pub(crate) hit_sb_view: HitArea,
    /// Active question view (from `AskUserQuestion` tool). When `Some`, the
    /// prompt area shows a structured question UI and input is modal.
    pub(crate) question_view: Option<QuestionViewState>,
    /// Scrollbar hit area for the question view (set during render).
    pub(crate) hit_question_scrollbar: HitArea,
    /// Hovered question item index (visual highlight only).
    pub(crate) hovered_question_item: Option<usize>,
    /// Whether a scrollbar drag is in progress on the question scrollbar.
    pub(crate) question_scrollbar_dragging: bool,
    /// Last question-view option click: (timestamp, item_index) for double-click detection.
    pub(crate) last_question_click: Option<(Instant, usize)>,
    /// Screen area of the inline prompt (label + textarea) in InputMode.
    /// Used for mouse scroll forwarding — scrolls over this region go to
    /// the textarea instead of the options list.
    pub(crate) inline_prompt_area: Option<Rect>,
    /// Clickable button regions on the question nav bar (key → rect).
    pub(crate) question_nav_buttons: Vec<(char, Rect)>,
    /// Currently hovered question nav button key (for highlight).
    pub(crate) hovered_question_button: Option<char>,
    /// Y-range of the scrollable options area (set during render).
    /// Scroll events outside this range are ignored.
    pub(crate) question_scroll_region: Option<(u16, u16)>,
    /// Whether plan mode is currently active. Set when `enter_plan_mode`
    /// tool completes, cleared when `exit_plan_mode` tool completes.
    /// Controls prompt accent color and shortcut bar hints.
    pub(crate) plan_mode_active: bool,
    /// Optimistic plan-mode state set immediately on Shift+Tab.
    /// Cleared to `None` when `detect_plan_mode_change()` confirms real state.
    /// The cycle logic uses `plan_mode_pending.unwrap_or(plan_mode_active)`
    /// so rapid Shift+Tab presses advance correctly without waiting for ACP.
    pub(crate) plan_mode_pending: Option<bool>,
    /// Session mode to apply once this agent's ACP session exists. Set when
    /// the agent is spawned from the dashboard with `/plan` active (the
    /// session does not exist yet, so the mode can't be sent immediately).
    /// Consumed in the `SessionCreated` / `WorktreeSessionCreated` handlers,
    /// mirroring `AgentSession.deferred_model_switch`.
    pub(crate) deferred_session_mode: Option<kigi_tools::types::SessionMode>,
    pub(crate) pending_extensions_fetch: bool,
    /// Whether this view was last rendered inside the dashboard's session
    /// overlay. Updated every frame by `draw`; read when building the
    /// shortcuts cheatsheet so the overlay-scoped shortcuts
    /// (`When::DashboardOverlay`) are lit in the overlay and dimmed elsewhere.
    pub(crate) in_dashboard_overlay: bool,
    /// MCP server init progress. Set when the shell starts connecting
    /// MCP servers, cleared when `kigi/mcp_initialized` arrives.
    /// Shown in the turn status line while the agent is idle.
    pub(crate) mcp_init_progress: Option<McpInitProgress>,
    /// Last synced ACP command generation. When this differs from
    /// `session.available_commands_generation`, `sync_acp_commands()`
    /// is called on the prompt. Starts at 0 so bootstrap (generation 1)
    /// triggers an initial sync.
    pub(crate) acp_synced_generation: u64,
    /// Hovered permission option index (visual highlight only, like question view).
    pub(crate) hovered_permission_item: Option<usize>,
    pub(crate) last_permission_click: Option<(Instant, usize)>,
    /// Queue of pending permission requests. Only the front request is rendered
    /// and interactive. Subsequent requests wait until the front is resolved.
    /// Matches the TUI's `VecDeque<PermissionRequest>` semantics.
    pub permission_queue: VecDeque<PermissionViewState>,
    /// Monotonic counter for permission request IDs.
    pub next_perm_req_id: usize,
    /// Original prompt text stashed when the permission queue became non-empty.
    /// Restored when the queue drains to empty. This is queue-level state, NOT
    /// per-request — stashing happens on the `empty -> non-empty` transition
    /// and restoring on the `non-empty -> empty` transition.
    pub permission_stashed_prompt: Option<StashedPrompt>,
    /// Active plan approval view (from `exit_plan_mode` ext_method). When `Some`,
    /// the prompt area shows the plan approval overlay and input is modal.
    pub(crate) plan_approval_view: Option<PlanApprovalViewState>,
    pub(crate) latest_inline_plan_content: Option<String>,
    pub(crate) plan_comments: Vec<PlanComment>,
    /// Monotonic counter for casual plan comment IDs.
    pub(crate) plan_next_comment_id: u64,
    /// Line range for the casual comment being composed (1-based).
    pub(crate) casual_commenting_range: Option<std::ops::Range<usize>>,
    /// Comment being edited in casual mode (if any).
    pub(crate) casual_editing_comment_id: Option<u64>,
    /// Prompt text stashed when entering casual commenting — restored
    /// on save/cancel. Mirrors `plan_approval_view.stashed_prompt` so
    /// the casual plan modal can share the same prompt-input UX.
    pub(crate) casual_stashed_prompt: Option<StashedPrompt>,
    /// Non-blocking cancel-turn panel (QA-style, shown when cancelling with running subagents).
    pub(crate) cancel_turn_view: Option<modal::CancelTurnViewState>,
    /// Clickable rects for cancel-turn option rows, populated by
    /// `render_cancel_turn_panel`.
    pub(crate) cancel_turn_buttons: Vec<Rect>,
    /// Per-agent mirror of cancel-subagents preference (`Some(true)` = always
    /// stop, `Some(false)` = always continue). Always choices set this on every
    /// agent and persist to `[ui].cancel_subagents_on_turn_cancel`; when unset,
    /// cancel falls back to that UI/config field, then the prompt panel.
    pub(crate) cancel_subagents_preference: Option<bool>,
    /// What gesture triggered the pending turn-cancel (Ctrl+C / mouse; Esc
    /// only via the cancel-retry path while TurnCancelling — a bare Esc no
    /// longer starts a cancel).
    /// Set by the key/mouse handler, consumed by `do_cancel_turn` / the
    /// cancel-retry path so `session/cancel` carries `_meta.cancelTrigger`.
    pub(crate) cancel_trigger_hint: Option<crate::app::actions::CancelTrigger>,
    pub(crate) rewind_state: Option<crate::views::rewind::RewindState>,
    pub(crate) rewind_points: Option<Vec<crate::views::rewind::RewindPointInfo>>,
    /// In-place edit of a previous user prompt. See `inline_edit.rs`.
    pub(crate) inline_edit: Option<crate::app::inline_edit::InlineEditState>,
    /// Edited text awaiting its rewind; `dispatch_rewind_success` resubmits it.
    /// Set only when the rewind flow emits `Effect::RewindExecute` while the
    /// inline editor is open (see `stash_inline_resubmit_if_editing`).
    pub(crate) pending_inline_resubmit: Option<String>,
    /// `/jump` picker overlay (pure client-side turn navigation).
    pub(crate) jump_state: Option<crate::views::jump::JumpState>,
    /// Timeline sidebar rail geometry for the current frame (`None` =
    /// hidden). Set by the renderer, consumed by mouse hit-testing.
    pub(crate) timeline_rail: Option<crate::views::timeline::TimelineRail>,
    /// Rail part under the mouse — drives hover styling + the tick
    /// preview popup.
    pub(crate) timeline_hover: Option<crate::views::timeline::TimelineHit>,
    /// Cached tick-hover preview `(turn_idx, text)`. Filled when hover
    /// lands on a tick; borrowed during render so streaming redraws don't
    /// rescan/allocate the prompt every frame.
    pub(crate) timeline_hover_preview: Option<(usize, String)>,
    /// Running agent definition for this session (`kigi/session/info` `agentName`).
    pub session_agent_name: Option<String>,
    /// Map of child session IDs to subagent metadata. Populated on
    /// `SubagentSpawned` notifications, used for permission routing
    /// (which agent owns a session) and provenance display.
    pub subagent_sessions: HashMap<String, SubagentInfo>,
    /// Child subagent views. Keyed by child_session_id.
    /// Created eagerly on SubagentSpawned so updates are tracked from the start.
    pub subagent_views: HashMap<String, Box<AgentView>>,
    /// Currently open subagent view (child_session_id). When Some, the
    /// scrollback area is replaced by the subagent's framed view.
    pub active_subagent: Option<String>,
    /// When true, this AgentView is rendering as a subagent (read-only):
    /// - Prompt is hidden
    /// - Cancel turn / demote to bg shortcuts are disabled
    /// - Shortcuts bar shows subagent-specific hints
    pub is_subagent_view: bool,
    /// Hit area for the [✗] close button in the subagent frame title bar.
    pub hit_subagent_frame_close: HitArea,
    /// Whether the `/share` slash command is available (mirrors
    /// Input flight recorder — rolling buffer of recent key events.
    /// Dumped to file via Esc→d combo for debugging.
    pub(crate) input_log: crate::input_log::InputRingBuffer,
    /// Timestamp of the last Esc press, used for Esc→d combo (dump input log).
    /// Cleared on any non-`d` key press, after 500ms expiry, or once
    /// `try_handle_esc_policy` consumes the Esc. `pub(crate)` for policy tests.
    pub(crate) esc_pressed_at: Option<std::time::Instant>,
    /// First prompt to enqueue once the session finishes loading replay.
    /// Set by `/fork` when a directive is provided; drained in the
    /// `TaskResult::SessionLoaded` arm via `enqueue_prompt_front` so the
    /// directive runs ahead of any prompts the user typed during the
    /// placeholder window.
    pub(crate) pending_first_prompt: Option<String>,
    /// Deferred fork banner to push at the bottom of the scrollback once
    /// the fork session finishes loading (in `TaskResult::SessionLoaded`).
    /// Set by `dispatch_fork_resolved`; stores the parent session id and
    /// worktree flag so the banner can be formatted with the child's
    /// session id (not known until `SessionLoaded`). `None` for non-fork
    /// sessions.
    ///
    /// Cleared on all failure paths: `SessionLoadFailed`,
    /// `WorktreeSessionFailed` (non-orphan branch), and
    /// `ForkSessionFailed`.
    pub(crate) pending_fork_banner: Option<PendingForkBanner>,
    /// Entry ID of the "Loading session ..." placeholder block pushed
    /// by `dispatch_load_session_inner`. Cleared by the `SessionLoaded`
    /// handler so the placeholder doesn't linger on screen when the
    /// loaded session has no replay content.
    pub(crate) loading_placeholder_id: Option<EntryId>,
    /// Entry ID of the in-flight manual `/recap` loading block (rendered with
    /// the animated "running" sidebar). Set when `/recap` is dispatched and
    /// taken by the `SessionRecap` handler, which fills the block with the
    /// summary and stops the animation. `None` when no manual recap is
    /// pending (auto recaps never show a loading block).
    pub(crate) pending_recap_entry: Option<EntryId>,
    /// The manually-chosen session title (`/rename` or the dashboard
    /// rename flow), as distinct from the auto-generated
    /// `generated_session_title` below. Set optimistically at dispatch,
    /// persisted by the shell as `Summary.title_is_manual`, and restored
    /// from disk on resume (`TaskResult::SessionTitleFromDisk`). Drives the
    /// prompt-border inline title and wins precedence for the dashboard
    /// modal label and the OSC terminal title. The on-disk write is
    /// best-effort (failure surfaces a toast through the existing
    /// `RenameSessionFailed` arm).
    pub display_name: Option<String>,
    /// Short title from shell `SessionSummaryGenerated` or `summary.json` on load/resume.
    /// Precedence in the dashboard title is below `display_name`, above first-prompt text.
    pub generated_session_title: Option<String>,
    /// Effects queued by input handlers that cannot return `InputOutcome::Action`.
    /// Drained by `AppView.handle_input` after each event.
    pub(crate) pending_effects: Vec<super::actions::Effect>,
    /// In-flight deferred clipboard attachment probes for this prompt. A send
    /// while `> 0` is stashed (see `deferred_send`) so a
    /// paste-then-immediate-send never builds content blocks before the image
    /// attaches.
    pub(crate) paste_probe_in_flight: usize,
    /// A prompt send / interject deferred until the in-flight paste probe(s)
    /// complete. Kind-only: the payload is re-derived from the widget on
    /// reissue so the freshly attached image chip travels with it.
    pub(crate) deferred_send: Option<AgentDeferredSend>,
    /// Armed when an `kigi/session/prompt_complete` broadcast arrives for the
    /// turn THIS client drives while it is still awaiting that turn's
    /// `session/prompt` RPC response. The RPC normally lands milliseconds
    /// later and disarms this; if it never does (lost in leader response
    /// routing / reconnect races), the event loop reconciles turn state from
    /// the broadcast after [`super::dispatch::TURN_END_RECONCILE_GRACE`] so
    /// the pane cannot stay latched in `TurnRunning`/`TurnCancelling` forever
    /// (a lost response would otherwise leave the TUI on "Cancelling…" with
    /// Esc and the input dead until a restart).
    pub(crate) pending_turn_end_reconcile: Option<PendingTurnEnd>,
    /// Send-now cancel expectation: the client-minted id of a cancel-and-send
    /// prompt this client dispatched into a running turn (send-now chord /
    /// queue-row "Send now" / a plain prompt sent into a held blocking wait,
    /// which the shell auto-routes onto send-now). The running turn's imminent
    /// cancel is the silent half of cancel-and-send, so the turn-end rails
    /// suppress the "Turn cancelled by user …" marker.
    ///
    /// Compat fallback only: a wire `_meta.cancelTrigger` on the turn end is
    /// trusted over this flag (`"send_now"` suppresses, anything else
    /// renders). Consumed at every driver turn end. Kept across the matching
    /// send-now prompt's turn start (so the outgoing turn's cancel
    /// PromptResponse can still suppress the marker when it races behind the
    /// adopt), but cleared on a non-matching turn start / interactive cancel /
    /// replay-window entry, so a stale expectation can never eat a later real
    /// Ctrl+C marker.
    pub(crate) expect_send_now_cancel: Option<String>,
    /// Send-now promote: skip `scroll_to_entry_top` on next matching adoption.
    /// Survives cancel-rail `take()` of [`Self::expect_send_now_cancel`].
    pub(crate) follow_without_jump_prompt_id: Option<String>,
    /// Ids of THIS client's server-queue rows that are still optimistic
    /// echoes — the `session/prompt` RPC is in flight and no
    /// `kigi/queue/changed` broadcast has confirmed the row yet. Inserted by
    /// the echo push, drained when a broadcast lists the id (queued or
    /// running) or the RPC resolves without the row landing.
    pub(crate) optimistic_queue_ids: std::collections::HashSet<String>,
    /// A queue-row send-now the user fired while the row was still an
    /// optimistic echo. Firing `kigi/queue/interject` then would race the
    /// row's own in-flight `session/prompt` and silently no-op shell-side
    /// (a rapid double-Enter on a queued bash command could "disappear" — the
    /// interject overtook the row, the no-op dropped the send-now, and the
    /// armed cancel expectation hid the still-queued row).
    /// Parked here and fired from the confirming `kigi/queue/changed`
    /// broadcast with the row's authoritative version.
    pub(crate) send_now_awaiting_confirm: Option<String>,
    /// User blocks painted at send-now dispatch, keyed by prompt id; the
    /// turn-start adoption consumes an entry to reuse its block. The flag
    /// marks an edit-interject override (fresher than the mirror text the
    /// adoption captures). Cleared on session reload.
    pub(crate) send_now_painted_blocks:
        std::collections::HashMap<String, (crate::scrollback::EntryId, bool)>,
    /// Follow-up suggestion chips for the latest assistant response
    /// (`kigi/follow_ups`). `None` when no chips are shown. Set by
    /// [`AgentView::apply_follow_ups`]; cleared at each turn start.
    pub(crate) follow_ups: Option<FollowUps>,
    /// `promptId` (turn identity) of the currently-shown `follow_ups`, when the
    /// delivery that displayed them carried one. Tracked separately because
    /// [`FollowUps`] is keyed by `response_id` and does not carry the turn id.
    /// Used by [`AgentView::reset_follow_ups_for_reload_preserving`] to tell
    /// whether the on-screen chips belong to the running turn a reload is about
    /// to adopt — so a reload preserves chips that RENDERED during replay, not
    /// only those still sitting in the pending buffer. `None` when no chips are
    /// shown or the delivery had no stamped `promptId` (legacy/newest-wins).
    pub(crate) follow_up_shown_prompt_id: Option<String>,
    /// Clickable screen rect of each rendered follow-up chip, index-aligned
    /// with the rendered prefix of `follow_ups.suggestions` (chips that do
    /// not fit the row are omitted). Rebuilt every frame by the renderer;
    /// hit-tested by [`AgentView::follow_up_chip_at`].
    pub(crate) follow_up_chips: Vec<Rect>,
    /// Chip under the mouse (hover highlight). Cleared when chips clear or
    /// the pointer leaves the follow-up row.
    pub(crate) hovered_follow_up_chip: Option<usize>,
    /// Assistant `response_id`s the pager has accepted follow-up chips for, in
    /// strictly-increasing acceptance order (`follow_up_next_gen`). Makes
    /// newest-wins correct on EVERY turn-boundary path without depending on a
    /// clear being wired: an id already accepted is, by construction, older than
    /// the current one, so a re-delivered (buffer-replay/duplicate) chunk for it
    /// is rejected, while a never-seen id is strictly newer and supersedes.
    /// Never evicted — eviction is what would let a stale id masquerade as new.
    /// Bounded in practice by the number of follow-up-bearing turns in a
    /// session. See [`AgentView::apply_follow_ups`].
    pub(crate) follow_up_seen: HashMap<String, u64>,
    /// Monotonic generation assigned to the next newly-accepted `response_id`.
    /// The ordering key for newest-wins: a fresh id takes the next value (the
    /// new high-water), so every previously-seen id is strictly lower.
    pub(crate) follow_up_next_gen: u64,
    /// Stamped `kigi/follow_ups` that arrived for a turn that is NOT yet the
    /// currently-adopted one, keyed by `promptId`. Ext notifications and
    /// `session/update` travel on separate channels, so a turn's follow_ups can
    /// land BEFORE the `session/update` that adopts it. Rather than drop such a
    /// delivery (chips would never appear if it was the only one), it is buffered
    /// here and flushed by [`AgentView::flush_pending_follow_ups`] when that
    /// `promptId` becomes current. A `promptId` that is already a prior turn and
    /// never becomes current again is never flushed (so stale chips are not
    /// revived); the buffer is FIFO-bounded by [`MAX_PENDING_FOLLOW_UPS`] via
    /// `follow_up_pending_order`.
    pub(crate) follow_up_pending: HashMap<String, FollowUps>,
    /// Insertion order of `follow_up_pending` keys, so an overflow evicts ONLY
    /// the OLDEST buffered entry (never the whole map).
    pub(crate) follow_up_pending_order: VecDeque<String>,
    /// Live `session/update`s buffered for the stashed pending running
    /// adoption: in the FIFO handoff window an instant turn emits its whole
    /// stream before the previous turn's PromptResponse applies the adoption.
    pub(crate) pending_adoption_updates: Vec<(
        String,
        agent_client_protocol::SessionUpdate,
        crate::acp::meta::NotificationMeta,
    )>,
}
/// Cap on [`AgentView::self_originated_prompt_ids`]. Only recent ids matter (a
/// stale post-rewind chunk arrives right after its turn ends), so a small
/// bounded ring is plenty and keeps a long-lived session from growing the set
/// without bound.
const SELF_ORIGINATED_PROMPT_CAP: usize = 64;
/// Cap on [`AgentView::follow_up_pending`]. Only a handful of turns can ever be
/// "buffered but not-yet-adopted" at once (the ext/`session/update` race window
/// is tiny), so a small bounded map is plenty; an overflow evicts the oldest
/// buffered entry (FIFO) rather than the whole map.
const MAX_PENDING_FOLLOW_UPS: usize = 16;
/// Cap on [`AgentView::pending_adoption_updates`]. Overflow drops the NEWEST
/// entry (unlike the follow-up buffer's oldest-first eviction): a coherent
/// prefix (user echo + tool-call start) renders sanely, a headless tail would not.
pub(crate) const MAX_PENDING_ADOPTION_UPDATES: usize = 128;
/// Outcome of [`AgentView::dashboard_answer_question`] — tells the
/// dashboard dispatcher whether the whole ask form was submitted (close
/// the peek), the form advanced to the next question (keep the peek open
/// but reset its per-question draft), or nothing happened.
pub(crate) enum PeekAnswerOutcome {
    Submitted,
    Advanced,
    NoOp,
}
/// Test-only re-export of [`translate_local_submit`] so dispatch tests
/// can verify the local-question -> Action mapping without spinning up
/// a full agent view.
#[cfg(test)]
pub(crate) fn translate_local_submit_for_test(
    qv: &crate::views::question_view::QuestionViewState,
    kind: crate::views::question_view::LocalQuestionKind,
    skipped: bool,
) -> InputOutcome {
    translate_local_submit(qv, kind, skipped)
}
/// Translate a local-question submission into an [`InputOutcome`].
///
/// Returns `InputOutcome::Action(...)` so the event loop dispatches the
/// action through the normal channel, mirroring the way ACP-driven
/// questions complete via `response_tx.send(..)`. Cancel / skip /
/// invalid-selection paths return `InputOutcome::Changed` and the
/// directive (if one was supplied) is silently dropped -- matching the
/// "no UI for cancellation" stance.
/// Map a worktree-question option index to `(use_worktree, persist_mode)`.
///
/// Indices 0-3 correspond to the four options presented in
/// `open_fork_question` / `open_new_session_question`. Returns `None`
/// for out-of-range indices.
fn worktree_choice_from_index(
    idx: usize,
) -> Option<(bool, Option<crate::app::app_view::WorktreeMode>)> {
    use crate::app::app_view::WorktreeMode;
    match idx {
        0 => Some((true, None)),
        1 => Some((false, None)),
        2 => Some((true, Some(WorktreeMode::Always))),
        3 => Some((false, Some(WorktreeMode::Never))),
        _ => None,
    }
}
fn translate_local_submit(
    qv: &crate::views::question_view::QuestionViewState,
    kind: crate::views::question_view::LocalQuestionKind,
    skipped: bool,
) -> InputOutcome {
    use crate::views::question_view::{LocalQuestionKind, QuestionSelection};
    if let LocalQuestionKind::ProjectSelect {
        resolved_paths,
        original_cwd,
        stashed_prompt,
        dont_ask_index,
    } = kind
    {
        return translate_project_select(
            qv,
            resolved_paths,
            original_cwd,
            stashed_prompt,
            dont_ask_index,
            skipped,
        );
    }
    if skipped {
        return InputOutcome::Changed;
    }
    let Some(QuestionSelection::Single(Some(idx))) = qv.selections.first() else {
        return InputOutcome::Changed;
    };
    match kind {
        LocalQuestionKind::Fork { directive } => {
            let Some((worktree, persist_mode)) = worktree_choice_from_index(*idx) else {
                return InputOutcome::Changed;
            };
            InputOutcome::Action(Action::ForkAnswered {
                worktree,
                directive,
                persist_mode,
            })
        }
        LocalQuestionKind::NewSession => {
            let Some((worktree, persist_mode)) = worktree_choice_from_index(*idx) else {
                return InputOutcome::Changed;
            };
            InputOutcome::Action(Action::NewSessionAnswered {
                worktree,
                persist_mode,
            })
        }
        LocalQuestionKind::AgentTypeMismatch { model_id, effort } => {
            let start_new = *idx == 0;
            InputOutcome::Action(Action::AgentTypeMismatchAnswered {
                start_new,
                model_id: model_id.clone(),
                effort,
            })
        }
        LocalQuestionKind::ProjectSelect { .. } => unreachable!(),
    }
}
fn translate_project_select(
    qv: &crate::views::question_view::QuestionViewState,
    resolved_paths: Vec<std::path::PathBuf>,
    original_cwd: std::path::PathBuf,
    stashed_prompt: String,
    dont_ask_index: usize,
    skipped: bool,
) -> InputOutcome {
    use crate::views::question_view::QuestionSelection;

    if skipped {
        return InputOutcome::Action(Action::ProjectSelected {
            path: original_cwd,
            stashed_prompt,
            disable_picker: false,
        });
    }
    let freeform_path = qv
        .per_question_freeform_selected
        .first()
        .copied()
        .unwrap_or(false)
        .then(|| qv.per_question_freeform.first())
        .flatten()
        .filter(|s| !s.trim().is_empty())
        .map(|s| {
            let expanded = shellexpand::tilde(s.trim());
            std::path::PathBuf::from(expanded.as_ref())
        });
    if let Some(path) = freeform_path {
        return InputOutcome::Action(Action::ProjectSelected {
            path,
            stashed_prompt,
            disable_picker: false,
        });
    }
    let selected_idx = qv.selections.first().and_then(|s| match s {
        QuestionSelection::Single(Some(idx)) => Some(*idx),
        _ => None,
    });
    if selected_idx == Some(dont_ask_index) {
        return InputOutcome::Action(Action::ProjectSelected {
            path: original_cwd,
            stashed_prompt,
            disable_picker: true,
        });
    }
    let path = selected_idx
        .and_then(|idx| resolved_paths.get(idx).cloned())
        .unwrap_or(original_cwd);
    InputOutcome::Action(Action::ProjectSelected {
        path,
        stashed_prompt,
        disable_picker: false,
    })
}
/// Convert an [`OverlayAction`] to an [`InputOutcome`].
fn overlay_action_to_outcome(action: crate::views::overlay::OverlayAction) -> InputOutcome {
    use crate::views::overlay::OverlayAction;
    match action {
        OverlayAction::Ignored => InputOutcome::Unchanged,
        OverlayAction::Changed => InputOutcome::Changed,
        OverlayAction::FocusScrollback => InputOutcome::Action(Action::FocusScrollback),
        OverlayAction::FocusPrompt => InputOutcome::Action(Action::FocusPrompt),
    }
}
/// Render dropdown chrome (borders, count hint) anchored to the prompt and
/// return the inner items area. Returns `None` when geometry doesn't fit.
///
/// `below = false` anchors the panel *above* the prompt (full-TUI default);
/// `below = true` anchors it *below* the prompt (minimal mode, common CLI
/// style, to reduce layout shift).
///
/// Shared by slash dropdown and completion dropdown to avoid duplicated chrome code.
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_dropdown_chrome(
    buf: &mut Buffer,
    item_count: usize,
    item_rows: u16,
    inline_prompt_area: Option<Rect>,
    layout_prompt: Rect,
    area: Rect,
    layout_cfg: &crate::appearance::LayoutConfig,
    compact: bool,
    below: bool,
    theme: &Theme,
) -> Option<DropdownChrome> {
    let mut panel_height = item_rows + 2;
    let (top_border_y, bottom_border_y) = if below {
        let anchor = inline_prompt_area.unwrap_or(layout_prompt);
        let top = anchor.y + anchor.height;
        (top, top + panel_height - 1)
    } else {
        let bottom = if let Some(ipa) = inline_prompt_area {
            ipa.y.saturating_sub(1)
        } else {
            layout_prompt.y.saturating_sub(1)
        };
        let avail = bottom.saturating_sub(area.y).saturating_add(1);
        panel_height = panel_height.min(avail);
        if panel_height < 3 {
            return None;
        }
        (bottom.saturating_sub(panel_height - 1), bottom)
    };
    let embedded = crate::views::modal_window::embedded();
    let (hpad_left, hpad_right) = if embedded {
        (0, 0)
    } else {
        (
            layout_cfg.eff_hpad_left(compact),
            layout_cfg.eff_hpad_right(compact),
        )
    };
    let panel_x = area.x + hpad_left;
    let panel_width = area.width.saturating_sub(hpad_left + hpad_right);
    if top_border_y >= bottom_border_y || panel_width <= 4 {
        return None;
    }
    if below && bottom_border_y > area.y + area.height.saturating_sub(1) {
        return None;
    }
    let panel_area = Rect {
        x: panel_x,
        y: top_border_y,
        width: panel_width,
        height: panel_height,
    };
    if panel_area.bottom() > buf.area.bottom()
        || panel_area.right() > buf.area.right()
        || panel_area.y < buf.area.y
    {
        return None;
    }
    ratatui::widgets::Clear.render(panel_area, buf);
    if embedded {
        let reset = ratatui::style::Color::Reset;
        let divider_style = Style::default().fg(theme.gray_dim).bg(reset);
        let divider = Line::styled("\u{2500}".repeat(panel_width as usize), divider_style);
        buf.set_line_safe(panel_x, top_border_y, &divider, panel_width);
        let footer = "\u{2191}/\u{2193} navigate \u{00b7} enter confirm \u{00b7} esc cancel";
        let footer_line = Line::styled(
            footer.to_string(),
            Style::default().fg(theme.gray_dim).bg(reset),
        );
        buf.set_line_safe(
            panel_x + 1,
            bottom_border_y,
            &footer_line,
            panel_width.saturating_sub(1),
        );
    } else {
        buf.set_style(
            panel_area,
            Style::default().fg(theme.text_primary).bg(theme.bg_light),
        );
        let border_style = Style::default().fg(theme.bg_highlight).bg(theme.bg_base);
        let border_line = Line::styled("\u{2500}".repeat(panel_width as usize), border_style);
        buf.set_line_safe(panel_x, top_border_y, &border_line, panel_width);
        buf.set_line_safe(panel_x, bottom_border_y, &border_line, panel_width);
        let hint = format!("{}", item_count);
        let hint_w = hint.len() as u16;
        if hint_w + 2 <= panel_width {
            let hint_x = panel_x + panel_width - hint_w - 1;
            let hint_line = Line::styled(hint, Style::default().fg(theme.gray).bg(theme.bg_base));
            buf.set_line_safe(hint_x, top_border_y, &hint_line, hint_w);
        }
    }
    let content_inset = dropdown_content_inset(layout_cfg, compact);
    let items_x = layout_prompt.x + content_inset;
    let items_width = layout_prompt.width.saturating_sub(content_inset);
    Some(DropdownChrome {
        items: Rect {
            x: items_x,
            y: top_border_y + 1,
            height: panel_height - 2,
            width: items_width,
        },
        panel: panel_area,
    })
}
/// Left inset of dropdown item rows inside the panel (see the comment in
/// [`render_dropdown_chrome`]).
fn dropdown_content_inset(layout_cfg: &crate::appearance::LayoutConfig, compact: bool) -> u16 {
    if crate::views::modal_window::embedded() {
        0
    } else {
        1 + layout_cfg.eff_hpad_left(compact)
    }
}
/// Width of the dropdown item rows [`render_dropdown_chrome`] will produce for
/// `layout_prompt` — for sizing the row count *before* drawing the chrome.
pub(crate) fn dropdown_items_width(
    layout_prompt: Rect,
    layout_cfg: &crate::appearance::LayoutConfig,
    compact: bool,
) -> u16 {
    layout_prompt
        .width
        .saturating_sub(dropdown_content_inset(layout_cfg, compact))
}
/// Geometry returned by [`render_dropdown_chrome`]: the inset `items` area for
/// rendering rows and the full `panel` rect (borders + padding) used as an
/// occluder for hyperlink hit-testing.
pub(crate) struct DropdownChrome {
    pub(crate) items: Rect,
    pub(crate) panel: Rect,
}
/// Render a row of 1-char buttons right-aligned, returning their hit-test rects.
///
/// Buttons are rendered right-to-left starting from `right_x`. Each button
/// is 1 cell wide, separated by `gap` cells. The `base_style` is used for
/// non-hovered buttons; `hover_style` for hovered ones.
///
/// Returns one `Rect` per button, in the same order as the input iterator.
fn render_char_buttons<const N: usize>(
    buf: &mut Buffer,
    right_x: u16,
    y: u16,
    buttons: [(&str, bool); N],
    base_style: Style,
    hover_style: Style,
    gap: u16,
) -> [Rect; N] {
    let mut areas = [Rect::default(); N];
    let mut x = right_x;
    for i in (0..N).rev() {
        let (sym, hovered) = buttons[i];
        let style = if hovered { hover_style } else { base_style };
        if let Some(cell) = buf.cell_mut((x, y)) {
            cell.set_symbol(sym);
            cell.set_style(style);
        }
        areas[i] = Rect::new(x, y, 1, 1);
        x = x.saturating_sub(1 + gap);
    }
    areas
}
/// Whether this key event represents `!` (bang).
///
/// Most terminals report `KeyCode::Char('!')` directly. Under the Kitty
/// keyboard protocol the terminal sends the base key `1` with SHIFT
/// modifier instead of the produced character (crossterm#968).
fn is_bang_key(key: &KeyEvent) -> bool {
    key.code == KeyCode::Char('!')
        || (key.code == KeyCode::Char('1') && key.modifiers.contains(KeyModifiers::SHIFT))
}
/// Translate a `SettingsKeyOutcome` into an `InputOutcome`.
pub(super) fn apply_settings_outcome(
    agent: &mut AgentView,
    outcome: crate::views::settings_modal::SettingsKeyOutcome,
) -> InputOutcome {
    use crate::views::settings_modal::SettingsKeyOutcome;
    match outcome {
        SettingsKeyOutcome::Close => {
            agent.active_modal = None;
            InputOutcome::Changed
        }
        SettingsKeyOutcome::Action(a) => InputOutcome::Action(a),
        SettingsKeyOutcome::ActionPair(a, b) => InputOutcome::ActionPair(a, b),
        SettingsKeyOutcome::Changed => InputOutcome::Changed,
        SettingsKeyOutcome::Unchanged => InputOutcome::Unchanged,
    }
}
/// Whether this key event represents `#` (hash).
///
/// Most terminals report `KeyCode::Char('#')` directly. Under the Kitty
/// keyboard protocol the terminal sends the base key `3` with SHIFT
/// modifier instead of the produced character (crossterm#968).
fn is_hash_key(key: &KeyEvent) -> bool {
    key.code == KeyCode::Char('#')
        || (key.code == KeyCode::Char('3') && key.modifiers.contains(KeyModifiers::SHIFT))
}
/// Check `[features] remember_mode` in config.toml. Defaults to `false`.
fn remember_mode_enabled() -> bool {
    let path = kigi_tools::util::kigi_home::kigi_home().join("config.toml");
    let Some(doc) = crate::config_toml_edit::read_config_document_for_edit(&path) else {
        return false;
    };
    doc.get("features")
        .and_then(|f| f.get("remember_mode"))
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
}
/// Mouse reporting toggle chord (Ctrl+R on scrollback), for unified-log diagnostics.
fn is_mouse_reporting_toggle_chord(key: &KeyEvent) -> bool {
    crate::key!('r', CONTROL).matches(key)
}
fn format_key_for_log(key: &KeyEvent) -> serde_json::Value {
    serde_json::json!(
        { "code" : format!("{:?}", key.code), "modifiers" : format!("{:?}", key
        .modifiers), "kind" : format!("{:?}", key.kind), }
    )
}
fn resolve_action(action_id: Option<ActionId>) -> Option<InputOutcome> {
    let action = match action_id? {
        ActionId::SendPrompt => return None,
        ActionId::SelectNext => Action::SelectNext,
        ActionId::SelectPrev => Action::SelectPrev,
        ActionId::NextTurn => Action::NextTurn,
        ActionId::PrevTurn => Action::PrevTurn,
        ActionId::NextResponse => Action::NextResponse,
        ActionId::PrevResponse => Action::PrevResponse,
        ActionId::GotoTop => Action::GotoTop,
        ActionId::GotoBottom => Action::GotoBottom,
        ActionId::ScrollUp => Action::ScrollUp(1),
        ActionId::ScrollDown => Action::ScrollDown(1),
        ActionId::HalfPageUp => Action::HalfPageUp,
        ActionId::HalfPageDown => Action::HalfPageDown,
        ActionId::PageUp => Action::PageUp,
        ActionId::PageDown => Action::PageDown,
        ActionId::Collapse => Action::Collapse,
        ActionId::Expand => Action::Expand,
        ActionId::ToggleFold => Action::ToggleFold,
        ActionId::ToggleExpandAll => Action::ToggleExpandAll,
        ActionId::ExpandAllThinking => Action::ExpandAllThinking,
        ActionId::ToggleRaw => Action::ToggleRaw,
        ActionId::ToggleMouseCapture => Action::ToggleMouseCapture,
        ActionId::CopyBlockContent => Action::CopyBlockContent,
        ActionId::CopyBlockMeta => Action::CopyBlockMeta,
        ActionId::OpenBlockViewer => Action::OpenBlockViewer,
        ActionId::OpenNextLink => Action::OpenNextLink,
        ActionId::OpenPrevLink => Action::OpenPrevLink,
        ActionId::FocusPrompt => Action::FocusPrompt,
        ActionId::FocusScrollback => Action::FocusScrollback,
        ActionId::NextModel => Action::NextModel,
        ActionId::CycleMode => Action::CycleMode,
        ActionId::CancelTurn
        | ActionId::Quit
        | ActionId::ExitSession
        | ActionId::NewSession
        | ActionId::NewSessionInWorktree
        | ActionId::CommandPalette
        | ActionId::ModelPicker => return None,
        ActionId::DumpInputLog => return None,
        ActionId::ToggleYolo => return None,
        ActionId::ToggleMultiline => return None,
        ActionId::InterjectPrompt => return None,
        ActionId::ShortcutsHelp => return None,
        ActionId::OpenSettings => return None,
        ActionId::ToggleTodos
        | ActionId::ToggleTasks
        | ActionId::ToggleQueue
        | ActionId::OpenSessions
        | ActionId::OpenExtensions
        | ActionId::SendToBackground
        | ActionId::BashMode
        | ActionId::Rewind
        | ActionId::KillBgTask
        | ActionId::OpenDashboard
        | ActionId::DashboardSelectNext
        | ActionId::DashboardSelectPrev
        | ActionId::DashboardTogglePin
        | ActionId::DashboardBeginRename
        | ActionId::DashboardStop
        | ActionId::DashboardCycleMode
        | ActionId::DashboardToggleGrouping
        | ActionId::DashboardReorderUp
        | ActionId::DashboardReorderDown
        | ActionId::DashboardShortcutsHelp
        | ActionId::DashboardExit
        | ActionId::DashboardOverlayExit
        | ActionId::DashboardOverlayPrev
        | ActionId::DashboardOverlayNext
        | ActionId::DashboardOverlayStop
        | ActionId::DashboardToggleAutoApprove
        | ActionId::DashboardOpenLocationPicker
        | ActionId::DashboardToggleWorktree => return None,
    };
    Some(InputOutcome::Action(action))
}
/// Visible height of the scrollable options area, from the render-computed
/// scroll region or a fallback estimate (footer=3, sticky freeform=1).
/// `sticky_freeform_h` is 0 for `no_freeform` questions (no sticky row is
/// rendered) and 1 otherwise.
#[allow(clippy::too_many_arguments)]
fn question_visible_h(
    scroll_region: Option<(u16, u16)>,
    prompt_height: u16,
    question: &kigi_tools::implementations::kigi::ask_user_question::Question,
    content_w: usize,
    preview: Option<&str>,
    fullscreen: bool,
    desc_cap: u16,
    preview_cap: u16,
    sticky_freeform_h: u16,
) -> u16 {
    if let Some((top, bottom)) = scroll_region {
        bottom.saturating_sub(top)
    } else {
        let question_area_h = prompt_height.saturating_sub(3);
        crate::views::question_view::visible_options_height(
            question,
            question_area_h,
            content_w,
            preview,
            fullscreen,
            desc_cap,
            preview_cap,
        )
        .saturating_sub(sticky_freeform_h)
    }
}
/// Collect citation URLs from visible WebSearch and WebFetch tool blocks.
///
/// Returns `VisibleLink` entries with the entry's rendered content area.
/// Only visible blocks (from the selection model) are scanned.
fn collect_citation_links(
    scrollback: &ScrollbackState,
    selection_model: &ResolvedSelectionModel,
) -> Vec<crate::scrollback::link_map::VisibleLink> {
    use crate::scrollback::block::RenderBlock;
    use crate::scrollback::blocks::tool::ToolCallBlock;
    use crate::scrollback::link_map::VisibleLink;
    use std::sync::Arc;
    let mut links = Vec::new();
    for block_geom in &selection_model.visible_blocks {
        let Some(entry) = scrollback.entry(block_geom.entry_idx) else {
            continue;
        };
        match &entry.block {
            RenderBlock::ToolCall(ToolCallBlock::WebSearch(ws)) => {
                for url in &ws.citations {
                    links.push(VisibleLink {
                        rects: vec![block_geom.content_area],
                        url: Arc::from(url.as_str()),
                        id: None,
                    });
                }
            }
            RenderBlock::ToolCall(ToolCallBlock::WebFetch(wf)) if !wf.url.is_empty() => {
                links.push(VisibleLink {
                    rects: vec![block_geom.content_area],
                    url: Arc::from(wf.url.as_str()),
                    id: None,
                });
            }
            _ => {}
        }
    }
    links
}
/// Shared fixtures for the queue-routing tests here, the queued-prompt
/// editing tests in `queue_edit.rs`, and the parked-wait tests in
/// `dispatch/queue.rs` / `acp_handler.rs`.
#[cfg(test)]
pub(super) mod test_fixtures {
    use super::{AgentPane, AgentView};
    use crate::acp::model_state::ModelState;
    use crate::actions::ActionRegistry;
    use crate::app::agent::{AgentId, AgentSession, AgentState};
    use crate::app::prompt_queue::QueueEntryWire;
    use crate::scrollback::state::ScrollbackState;
    use agent_client_protocol as acp;
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
    /// Drive the agent's tracker into a task-output wait via the real update
    /// path. `timeout_ms > 0` advertises a blocking (sendable/parked) wait;
    /// `0` is an instant poll that must NOT advertise one.
    pub fn simulate_task_output_wait_ms(agent: &mut AgentView, task_id: &str, timeout_ms: u64) {
        simulate_task_output_wait_call(agent, "wait-1", task_id, timeout_ms);
    }
    /// [`simulate_task_output_wait_ms`] with an explicit tool-call id.
    pub fn simulate_task_output_wait_call(
        agent: &mut AgentView,
        tool_call_id: &str,
        task_id: &str,
        timeout_ms: u64,
    ) {
        use crate::acp::meta::NotificationMeta;
        use crate::acp::tracker::{TurnActivity, WaitingReason};
        use std::sync::Arc;
        let meta = NotificationMeta::default();
        agent.session.handle_update(
            acp::SessionUpdate::ToolCall(
                acp::ToolCall::new(
                    acp::ToolCallId::new(Arc::from(tool_call_id)),
                    "get_command_or_subagent_output",
                )
                .kind(acp::ToolKind::Other)
                .status(acp::ToolCallStatus::Pending)
                .content(vec![])
                .locations(vec![]),
            ),
            &meta,
            &mut agent.scrollback,
        );
        agent.session.handle_update(
            acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
                acp::ToolCallId::new(Arc::from(tool_call_id)),
                acp::ToolCallUpdateFields::new().raw_input(Some(serde_json::json!(
                    { "task_ids" : [task_id], "timeout_ms" : timeout_ms, }
                ))),
            )),
            &meta,
            &mut agent.scrollback,
        );
        let activity = agent.resolve_turn_activity();
        if timeout_ms > 0 {
            assert!(
                matches!(
                    activity,
                    Some(TurnActivity::Waiting(WaitingReason::TaskOutput {
                        waits: true,
                        ..
                    }))
                ),
                "expected TaskOutput wait, got {activity:?}"
            );
        } else {
            assert!(
                !matches!(
                    activity,
                    Some(TurnActivity::Waiting(WaitingReason::TaskOutput { .. }))
                ),
                "poll must not advertise a task-output wait, got {activity:?}"
            );
        }
    }
    /// Complete a wait tool call registered by
    /// [`simulate_task_output_wait_call`], releasing its blocking-wait entry.
    pub fn complete_task_output_wait_call(agent: &mut AgentView, tool_call_id: &str) {
        use crate::acp::meta::NotificationMeta;
        use std::sync::Arc;
        let meta = NotificationMeta::default();
        agent.session.handle_update(
            acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
                acp::ToolCallId::new(Arc::from(tool_call_id)),
                acp::ToolCallUpdateFields::new().status(Some(acp::ToolCallStatus::Completed)),
            )),
            &meta,
            &mut agent.scrollback,
        );
    }
    /// Blocking-wait shorthand for [`simulate_task_output_wait_ms`].
    pub fn simulate_task_output_wait(agent: &mut AgentView, task_id: &str) {
        simulate_task_output_wait_ms(agent, task_id, 30_000);
    }
    /// Drive the agent's tracker into a wait-all
    /// (`WaitingReason::TasksComplete`) blocking wait via the real update
    /// path; the tracker classifies on the title alone.
    pub fn simulate_wait_all(agent: &mut AgentView) {
        use crate::acp::meta::NotificationMeta;
        use crate::acp::tracker::{TurnActivity, WaitingReason};
        use std::sync::Arc;
        let meta = NotificationMeta::default();
        agent.session.handle_update(
            acp::SessionUpdate::ToolCall(
                acp::ToolCall::new(
                    acp::ToolCallId::new(Arc::from("waitall-1")),
                    "wait_commands_or_subagents",
                )
                .kind(acp::ToolKind::Other)
                .status(acp::ToolCallStatus::Pending)
                .content(vec![])
                .locations(vec![]),
            ),
            &meta,
            &mut agent.scrollback,
        );
        let activity = agent.resolve_turn_activity();
        assert!(
            matches!(
                activity,
                Some(TurnActivity::Waiting(WaitingReason::TasksComplete))
            ),
            "expected TasksComplete wait, got {activity:?}"
        );
    }
    /// Drive the agent's tracker into a foreground-subagent wait via the real
    /// update path: a pending `task` tool call (no `run_in_background`)
    /// registers a blocking [`WaitingReason::Subagent`]
    /// (`crate::acp::tracker`). The shell aborts that await the moment the
    /// user sends (send-now), so it must read as a sendable/parked wait.
    pub fn simulate_subagent_wait(agent: &mut AgentView) {
        use crate::acp::meta::NotificationMeta;
        use crate::acp::tracker::{TurnActivity, WaitingReason};
        use std::sync::Arc;
        let meta = NotificationMeta::default();
        agent.session.handle_update(
            acp::SessionUpdate::ToolCall(
                acp::ToolCall::new(acp::ToolCallId::new(Arc::from("task-tc-1")), "task")
                    .kind(acp::ToolKind::Other)
                    .status(acp::ToolCallStatus::Pending)
                    .content(vec![])
                    .locations(vec![]),
            ),
            &meta,
            &mut agent.scrollback,
        );
        let activity = agent.resolve_turn_activity();
        assert!(
            matches!(
                activity,
                Some(TurnActivity::Waiting(WaitingReason::Subagent))
            ),
            "expected Subagent wait, got {activity:?}"
        );
    }
    /// A minimal running (foreground) subagent registry row, so tests can
    /// count it in `current_end_work` / park-marker snapshots.
    pub fn running_subagent_info(child_sid: &str) -> crate::app::subagent::SubagentInfo {
        use std::sync::Arc;
        use std::time::Instant;
        crate::app::subagent::SubagentInfo {
            subagent_id: Arc::from(format!("sa-{child_sid}")),
            child_session_id: Arc::from(child_sid),
            description: Arc::from("test"),
            subagent_type: Arc::from("general-purpose"),
            persona: None,
            role: None,
            model: None,
            context_source: None,
            resumed_from: None,
            capability_mode: None,
            context_normalized: false,
            parent_prompt_id: None,
            started_at: Instant::now(),
            last_progress_at: Instant::now(),
            finished: false,
            status: None,
            error: None,
            duration_ms: None,
            tool_calls: None,
            turns: None,
            turn_count: None,
            tool_call_count: None,
            tokens_used: None,
            context_window_tokens: None,
            context_usage_pct: None,
            tools_used: Vec::new(),
            error_count: None,
            activity_label: None,
            is_background: false,
            pending_kill: false,
            kill_requested_at: None,
            scrollback_entry_id: None,
            prompt: None,
            child_cwd: None,
            worktree_path: None,
            child_updates_replayed: false,
        }
    }
    /// Count of parked ("Worked for … still running") marker blocks in
    /// the agent's scrollback.
    pub fn count_parked(agent: &AgentView) -> usize {
        use crate::scrollback::block::RenderBlock;
        (0..agent.scrollback.len())
            .filter(|i| {
                matches!(
                    agent.scrollback.get(* i).map(| e | & e.block),
                    Some(RenderBlock::SessionEvent(b)) if b.parked
                )
            })
            .count()
    }
    pub fn make_running_agent() -> AgentView {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        let mut session = AgentSession {
            id: AgentId(0),
            acp_tx: tx,
            session_id: Some(acp::SessionId::new("test-session")),
            models: ModelState::default(),
            state: AgentState::TurnRunning,
            tracker: crate::acp::tracker::AcpUpdateTracker::new(),
            cwd: std::path::PathBuf::from("/tmp"),
            is_worktree: false,
            forked_from: None,
            pending_prompts: std::collections::VecDeque::new(),
            next_queue_id: 0,
            yolo_mode: false,
            auto_mode: false,
            prompt_history: Vec::new(),
            prompt_history_loading: false,
            loading_replay: false,
            restore_degree: None,
            rate_limited: false,
            model_incompatible: false,
            available_commands: Vec::new(),
            available_commands_generation: 0,
            available_tools: None,
            model_switch_pending: false,
            user_model_preference: None,
            deferred_model_switch: None,
            bg_tasks: std::collections::BTreeMap::new(),
            bg_tool_call_to_task: std::collections::HashMap::new(),
            scheduled_tasks: std::collections::HashMap::new(),
            in_flight_prompt: None,
            current_prompt_id: None,
            created_via_new: false,
        };
        session.enqueue_prompt("local one".to_string());
        let mut agent = AgentView::new(session, ScrollbackState::new());
        agent.shared_queue = vec![QueueEntryWire {
            id: "p1".into(),
            version: 2,
            owner: None,
            last_editor: None,
            kind: "prompt".into(),
            text: "server one".into(),
            position: 0,
        }];
        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.queue.overlay.visible = true;
        agent.queue.overlay.focused = true;
        agent
    }
    /// Minimal idle agent (no queue, no session id) shared by input tests.
    pub fn make_agent() -> AgentView {
        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
        AgentView::new(
            AgentSession {
                id: AgentId(0),
                acp_tx: tx,
                session_id: None,
                models: ModelState::default(),
                state: AgentState::Idle,
                tracker: crate::acp::tracker::AcpUpdateTracker::new(),
                cwd: std::path::PathBuf::from("/tmp"),
                is_worktree: false,
                forked_from: None,
                pending_prompts: std::collections::VecDeque::new(),
                next_queue_id: 0,
                yolo_mode: false,
                auto_mode: false,
                prompt_history: Vec::new(),
                prompt_history_loading: false,
                loading_replay: false,
                restore_degree: None,
                rate_limited: false,
                model_incompatible: false,
                available_commands: Vec::new(),
                available_commands_generation: 0,
                available_tools: None,
                model_switch_pending: false,
                user_model_preference: None,
                deferred_model_switch: None,
                bg_tasks: std::collections::BTreeMap::new(),
                bg_tool_call_to_task: std::collections::HashMap::new(),
                scheduled_tasks: std::collections::HashMap::new(),
                in_flight_prompt: None,
                current_prompt_id: None,
                created_via_new: false,
            },
            ScrollbackState::new(),
        )
    }
    /// Interject chord for non–VS Code family tests (`Ctrl+Enter`).
    pub fn force_interject_key() -> KeyEvent {
        KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)
    }
    /// Interject chord for VS Code family tests (`Ctrl+L`).
    pub fn vscode_interject_key() -> KeyEvent {
        KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)
    }
    /// Host-independent registry for queue/prompt interject tests (Ctrl+Enter).
    pub fn non_vscode_registry() -> ActionRegistry {
        ActionRegistry::non_vscode_for_test()
    }
    /// Host-independent VS family registry (Ctrl+L interject, OpenExtensions Null).
    pub fn vscode_family_registry() -> ActionRegistry {
        ActionRegistry::vscode_family_for_test()
    }
    #[test]
    fn apply_follow_ups_renders_chips_for_a_response() {
        let mut agent = make_agent();
        let changed =
            agent.apply_follow_ups("resp-1".into(), vec!["Tell me more".into(), "Sum".into()]);
        assert!(changed, "first chips for a response warrant a redraw");
        let fu = agent.follow_ups.as_ref().expect("chips must be set");
        assert_eq!(fu.response_id, "resp-1");
        assert_eq!(fu.suggestions, vec!["Tell me more", "Sum"]);
    }
    #[test]
    fn apply_follow_ups_newer_response_supersedes() {
        let mut agent = make_agent();
        agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
        let changed = agent.apply_follow_ups("resp-2".into(), vec!["b".into()]);
        assert!(changed, "a newer response must take over");
        let fu = agent.follow_ups.as_ref().unwrap();
        assert_eq!(fu.response_id, "resp-2");
        assert_eq!(fu.suggestions, vec!["b"]);
    }
    #[test]
    fn apply_follow_ups_ignores_superseded_redelivery() {
        let mut agent = make_agent();
        agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
        agent.apply_follow_ups("resp-2".into(), vec!["b".into()]);
        let changed = agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
        assert!(!changed, "a superseded response's re-delivery is ignored");
        let fu = agent.follow_ups.as_ref().unwrap();
        assert_eq!(fu.response_id, "resp-2");
        assert_eq!(fu.suggestions, vec!["b"]);
    }
    #[test]
    fn apply_follow_ups_same_response_is_idempotent() {
        let mut agent = make_agent();
        agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
        let changed = agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
        assert!(!changed);
        assert_eq!(agent.follow_ups.as_ref().unwrap().suggestions, vec!["a"]);
    }
    #[test]
    fn apply_follow_ups_empty_clears_current_response_chips() {
        let mut agent = make_agent();
        agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
        let changed = agent.apply_follow_ups("resp-1".into(), Vec::new());
        assert!(
            changed,
            "retracting the shown response's chips warrants redraw"
        );
        assert!(agent.follow_ups.is_none());
    }
    #[test]
    fn apply_follow_ups_empty_for_different_response_supersedes_and_clears() {
        let mut agent = make_agent();
        agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
        let changed = agent.apply_follow_ups("resp-2".into(), Vec::new());
        assert!(changed);
        assert!(agent.follow_ups.is_none());
    }
    #[test]
    fn apply_follow_ups_empty_for_unseen_id_does_not_poison_ring() {
        let mut agent = make_agent();
        assert!(!agent.apply_follow_ups("resp-1".into(), Vec::new()));
        assert!(agent.follow_ups.is_none());
        assert!(agent.apply_follow_ups("resp-1".into(), vec!["a".into()]));
        assert_eq!(agent.follow_ups.as_ref().unwrap().suggestions, vec!["a"]);
        let mut agent = make_agent();
        agent.apply_follow_ups("shown".into(), vec!["x".into()]);
        agent.apply_follow_ups("resp-2".into(), Vec::new());
        let changed = agent.apply_follow_ups("resp-2".into(), vec!["b".into()]);
        assert!(
            changed,
            "non-empty for the previously-empty resp-2 must render"
        );
        assert_eq!(agent.follow_ups.as_ref().unwrap().suggestions, vec!["b"]);
    }
    #[test]
    fn apply_follow_ups_empty_retract_allows_same_id_redelivery() {
        let mut agent = make_agent();
        assert!(agent.apply_follow_ups("resp-1".into(), vec!["a".into()]));
        assert_eq!(agent.follow_ups.as_ref().unwrap().suggestions, vec!["a"]);
        assert!(agent.apply_follow_ups("resp-1".into(), Vec::new()));
        assert!(agent.follow_ups.is_none());
        assert!(
            !agent.follow_up_seen.contains_key("resp-1"),
            "an empty retraction of the shown chips must drop the id from the seen-ring"
        );
        let changed = agent.apply_follow_ups("resp-1".into(), vec!["b".into()]);
        assert!(
            changed,
            "non-empty re-delivery for a retracted id must render"
        );
        assert_eq!(agent.follow_ups.as_ref().unwrap().suggestions, vec!["b"]);
        assert!(agent.apply_follow_ups("resp-2".into(), vec!["c".into()]));
        let changed_old = agent.apply_follow_ups("resp-1".into(), vec!["b".into()]);
        assert!(!changed_old, "a superseded older id stays rejected");
        assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-2");
    }
    #[test]
    fn clear_then_superseded_redelivery_is_ignored() {
        let mut agent = make_agent();
        agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
        agent.clear_follow_ups();
        let changed = agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
        assert!(!changed, "a cleared response's re-delivery is ignored");
        assert!(agent.follow_ups.is_none());
    }
    #[test]
    fn apply_follow_ups_old_response_rejected_after_many_newer() {
        let mut agent = make_agent();
        agent.apply_follow_ups("resp-0".into(), vec!["first".into()]);
        for i in 1..=200 {
            agent.apply_follow_ups(format!("resp-{i}"), vec![format!("s{i}")]);
        }
        assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-200");
        let changed = agent.apply_follow_ups("resp-0".into(), vec!["first".into()]);
        assert!(!changed, "a long-superseded response stays rejected");
        let fu = agent.follow_ups.as_ref().unwrap();
        assert_eq!(fu.response_id, "resp-200");
        assert_eq!(fu.suggestions, vec!["s200"]);
    }
    #[test]
    fn apply_follow_ups_generation_is_monotonic() {
        let mut agent = make_agent();
        agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
        assert_eq!(agent.follow_up_seen.get("resp-1"), Some(&0));
        assert_eq!(agent.follow_up_next_gen, 1);
        agent.apply_follow_ups("resp-1".into(), vec!["a2".into()]);
        assert_eq!(agent.follow_up_next_gen, 1);
        agent.apply_follow_ups("resp-2".into(), vec!["b".into()]);
        assert_eq!(agent.follow_up_seen.get("resp-2"), Some(&1));
        assert_eq!(agent.follow_up_next_gen, 2);
        assert!(!agent.apply_follow_ups("resp-1".into(), vec!["a".into()]));
        assert_eq!(agent.follow_up_next_gen, 2);
    }
    #[test]
    fn reconnect_reload_finalize_clears_follow_up_chips() {
        let mut agent = make_agent();
        assert!(agent.apply_follow_ups("resp-1".into(), vec!["a".into(), "b".into()]));
        assert!(agent.follow_ups.is_some(), "chips shown before the reload");
        assert!(agent.follow_up_seen.contains_key("resp-1"));
        assert!(agent.follow_up_next_gen > 0);
        agent.session.prompt_history_loading = true;
        agent.begin_session_reload(1);
        assert!(agent.finish_session_reload(1, true));
        assert!(!agent.session.prompt_history_loading);
        assert!(
            agent.follow_ups.is_none(),
            "reconnect reload must clear the shown chips"
        );
        assert!(agent.follow_up_chips.is_empty());
        assert!(
            agent.follow_up_seen.is_empty(),
            "the seen map must clear so chips streamed after the reload are not suppressed"
        );
        assert_eq!(
            agent.follow_up_next_gen, 0,
            "the acceptance generation resets with the reload"
        );
    }
    /// A model switch stuck across a reconnect must not jam the drain, but a
    /// switch started DURING the reload window must keep its model-switch hold. The
    /// reload START (`begin_session_reload`) releases the hold (the disconnect
    /// dropped the in-flight RPC); finalize (`apply_reload_outcome`) must NOT —
    /// a window switch is live on the reconnected link.
    #[test]
    fn reconnect_reload_clears_stuck_model_switch_pending() {
        for success in [false, true] {
            let mut agent = make_agent();
            agent.session.model_switch_pending = true;
            agent.begin_session_reload(1);
            assert!(
                !agent.session.model_switch_pending,
                "reload start must release the hold for the lost pre-outage switch"
            );
            agent.session.model_switch_pending = true;
            assert!(agent.finish_session_reload(1, success));
            assert!(
                agent.session.model_switch_pending,
                "finalize (success={success}) must NOT clear a switch started \
                 during the reload window"
            );
        }
    }
    #[test]
    fn reconnect_reload_failure_also_clears_follow_up_chips() {
        let mut agent = make_agent();
        assert!(agent.apply_follow_ups("resp-1".into(), vec!["a".into()]));
        assert!(agent.follow_ups.is_some());
        agent.session.prompt_history_loading = true;
        agent.begin_session_reload(1);
        assert!(agent.finish_session_reload(1, false));
        assert!(!agent.session.prompt_history_loading);
        assert!(
            agent.follow_ups.is_none(),
            "a failed reconnect reload must still clear stale chips"
        );
        assert!(agent.follow_up_seen.is_empty());
        assert_eq!(agent.follow_up_next_gen, 0);
    }
    /// Drives the production `finalize_reload_and_maybe_adopt` that the
    /// `event_loop.rs` reconnect loop also calls (so a future reorder of the
    /// finalize-before-adopt gate fails here). A synthetic non-scheduler running
    /// id leaves the agent `Idle` (reload still finalized), while a `/loop` or
    /// user id IS adopted.
    #[test]
    fn reconnect_reload_adopts_only_for_prompt_with_completion_exit() {
        let mut synthetic = make_agent();
        synthetic.begin_session_reload(1);
        assert!(
            synthetic.finalize_reload_and_maybe_adopt(
                1,
                true,
                Some("task-completed-abc-123".into())
            ),
            "the reload must finalize even when adoption is skipped"
        );
        assert!(synthetic.session_reload.is_none());
        assert!(synthetic.session.current_prompt_id.is_none());
        assert!(
            synthetic.session.state.is_idle(),
            "a synthetic non-scheduler running id must not strand the viewer in TurnRunning"
        );
        let mut cron = make_agent();
        cron.begin_session_reload(1);
        assert!(cron.finalize_reload_and_maybe_adopt(
            1,
            true,
            Some("scheduler-fired-019e51a3-abcd-1234".into()),
        ));
        assert_eq!(
            cron.session.current_prompt_id.as_deref(),
            Some("scheduler-fired-019e51a3-abcd-1234"),
            "a /loop fire has a prompt_complete exit, so it is adopted on reconnect"
        );
        assert!(cron.session.state.is_turn_running());
        let mut user = make_agent();
        user.begin_session_reload(1);
        assert!(user.finalize_reload_and_maybe_adopt(1, true, Some("p-user".into())));
        assert_eq!(user.session.current_prompt_id.as_deref(), Some("p-user"));
        assert!(user.session.state.is_turn_running());
    }
    /// Resolving a reload window purges iff a heavy transient dropped: the
    /// stash (success + full replay), or the staged partial replay (failure /
    /// abort / supersede). The common cursor-resolve outcome reuses the stash
    /// — nothing multi-MB drops — and must NOT purge. The counter is
    /// thread-local, so parallel tests cannot interfere with the deltas.
    #[test]
    fn reload_finalize_and_abort_release_retained_memory() {
        use crate::memory_release::test_support;
        test_support::install_counting_hook();
        let mut agent = make_agent();
        agent.begin_session_reload(1);
        agent.mark_reload_replay_seen();
        let before = test_support::calls();
        assert!(agent.finish_session_reload(1, true));
        assert_eq!(
            test_support::calls(),
            before + 1,
            "full-replay finalize must purge after the reload stash drops"
        );
        let mut cursor = make_agent();
        cursor.begin_session_reload(1);
        let before = test_support::calls();
        assert!(cursor.finish_session_reload(1, true));
        assert_eq!(
            test_support::calls(),
            before,
            "cursor-resolve finalize drops nothing heavy and must not purge"
        );
        let mut stale = make_agent();
        stale.begin_session_reload(2);
        let before = test_support::calls();
        assert!(!stale.finish_session_reload(1, true));
        assert_eq!(
            test_support::calls(),
            before,
            "a superseded finalize drops nothing and must not purge"
        );
        let before = test_support::calls();
        stale.begin_session_reload(3);
        assert_eq!(
            test_support::calls(),
            before + 1,
            "supersede must purge the discarded prior staging"
        );
        let before = test_support::calls();
        stale.abort_session_reload();
        assert_eq!(
            test_support::calls(),
            before + 1,
            "abort must purge after the discarded staging drops"
        );
        let before = test_support::calls();
        stale.abort_session_reload();
        assert_eq!(
            test_support::calls(),
            before,
            "abort without an open window must not purge"
        );
    }
    #[test]
    fn apply_follow_ups_empty_clears_hit_areas() {
        let mut agent = make_agent();
        agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
        agent.follow_up_chips = vec![ratatui::layout::Rect::new(0, 0, 5, 1)];
        agent.apply_follow_ups("resp-1".into(), Vec::new());
        assert!(agent.follow_ups.is_none());
        assert!(
            agent.follow_up_chips.is_empty(),
            "hit areas must be cleared"
        );
    }
    #[test]
    fn clear_follow_ups_drops_chips_and_hit_areas() {
        let mut agent = make_agent();
        agent.apply_follow_ups("resp-1".into(), vec!["a".into()]);
        agent.follow_up_chips = vec![ratatui::layout::Rect::new(0, 0, 5, 1)];
        agent.clear_follow_ups();
        assert!(agent.follow_ups.is_none());
        assert!(agent.follow_up_chips.is_empty());
    }
    /// FIX 4 (a): a re-delivery of the CURRENTLY-ADOPTED turn's follow_ups
    /// re-renders even after its chips were cleared by turn adoption — the
    /// stamped `promptId` matches the active `current_prompt_id`, so the
    /// (already-seen) response is re-rendered rather than rejected.
    #[test]
    fn apply_follow_ups_current_turn_redelivery_rerenders_after_clear() {
        let mut agent = make_agent();
        agent.session.current_prompt_id = Some("p1".into());
        assert!(agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p1"), vec!["a".into()]));
        assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-1");
        agent.clear_follow_ups();
        assert!(agent.follow_ups.is_none());
        assert!(
            agent.follow_up_seen.contains_key("resp-1"),
            "adoption keeps the seen ring (no un-record)"
        );
        let changed =
            agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p1"), vec!["a".into()]);
        assert!(
            changed,
            "re-delivery of the adopted turn's follow_ups must re-render"
        );
        assert_eq!(agent.follow_ups.as_ref().unwrap().suggestions, vec!["a"]);
    }
    /// FIX 4 (b): after adopting a NEW turn, a buffer-replayed `kigi/follow_ups`
    /// for a PRIOR turn's response_id must NOT revive stale chips — its
    /// `promptId` is not the active turn and it is already in the seen ring.
    #[test]
    fn apply_follow_ups_prior_turn_replay_does_not_revive() {
        let mut agent = make_agent();
        agent.session.current_prompt_id = Some("p1".into());
        assert!(agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p1"), vec!["a".into()]));
        agent.session.current_prompt_id = Some("p2".into());
        agent.clear_follow_ups();
        assert!(agent.follow_ups.is_none());
        let changed =
            agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p1"), vec!["a".into()]);
        assert!(
            !changed,
            "a prior turn's replay must not revive stale chips"
        );
        assert!(agent.follow_ups.is_none(), "no stale chips were revived");
        assert!(agent.apply_follow_ups_with_prompt("resp-2".into(), Some("p2"), vec!["b".into()]));
        assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-2");
    }
    /// FINDING B (stamped path): a LATE FIRST-TIME (never-seen) `kigi/follow_ups`
    /// for a PRIOR turn — arriving while a newer turn is active — must NOT
    /// render. Before the fix it slipped through the "strictly newer" branch
    /// (never recorded in `follow_up_seen`, so the seen-reject didn't catch it).
    #[test]
    fn apply_follow_ups_late_prior_turn_first_time_rejected() {
        let mut agent = make_agent();
        agent.session.current_prompt_id = Some("p2".into());
        assert!(agent.apply_follow_ups_with_prompt("resp-2".into(), Some("p2"), vec!["b".into()]));
        assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-2");
        let changed =
            agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p1"), vec!["a".into()]);
        assert!(
            !changed,
            "a never-seen prior-turn follow_ups must not render over the active turn"
        );
        assert_eq!(
            agent.follow_ups.as_ref().unwrap().response_id,
            "resp-2",
            "the active turn's chips must remain untouched"
        );
        assert!(
            !agent.follow_up_seen.contains_key("resp-1"),
            "a rejected non-current first-time arrival must not poison the seen ring"
        );
    }
    /// Regression guard for the FINDING B fix: a first-time follow_ups for the
    /// CURRENTLY-ADOPTED turn (promptId == current) still renders.
    #[test]
    fn apply_follow_ups_current_turn_first_time_renders() {
        let mut agent = make_agent();
        agent.session.current_prompt_id = Some("p1".into());
        assert!(
            agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p1"), vec!["a".into()]),
            "the active turn's first follow_ups must render"
        );
        assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-1");
    }
    /// Regression guard: trailing follow_ups that arrive AFTER the turn finished
    /// (`current_prompt_id` cleared to None) are NOT treated as a mismatch and
    /// still render — `None` current is not "another active turn".
    #[test]
    fn apply_follow_ups_trailing_after_turn_complete_still_renders() {
        let mut agent = make_agent();
        agent.session.current_prompt_id = None;
        assert!(
            agent.apply_follow_ups_with_prompt("resp-1".into(), Some("p1"), vec!["a".into()]),
            "a stamped follow_ups with no active turn must still render (turn just completed)"
        );
        assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-1");
    }
    /// None-fallback (older shells / no promptId): with no turn identity on the
    /// notification AND a newer turn active, a late first-time arrival cannot be
    /// distinguished from the new turn's first follow_ups, so it follows the
    /// legacy newest-wins (renders). This path is not reachable for current
    /// shells (which always stamp `promptId`) or for buffer-replays (suppressed
    /// upstream by the `_meta["kigi/replayed"]` gate); it is pinned here so the
    /// stamped-path fix above is understood to be the deterministic guard.
    #[test]
    fn apply_follow_ups_none_prompt_first_time_follows_legacy_newest_wins() {
        let mut agent = make_agent();
        agent.session.current_prompt_id = Some("p2".into());
        assert!(
            agent.apply_follow_ups_with_prompt("resp-x".into(), None, vec!["a".into()]),
            "a None-promptId first-time arrival follows legacy newest-wins"
        );
        assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-x");
    }
    /// FIX (buffer-before-adoption): a stamped `kigi/follow_ups` for a turn that
    /// is NOT yet current (its `session/update` adoption raced behind the ext
    /// channel) must be BUFFERED, not dropped — and then RENDER when that turn
    /// becomes current and is flushed.
    #[test]
    fn apply_follow_ups_buffered_before_adoption_flushes_on_adoption() {
        let mut agent = make_agent();
        agent.session.current_prompt_id = Some("p1".into());
        let changed =
            agent.apply_follow_ups_with_prompt("resp-2".into(), Some("p2"), vec!["b".into()]);
        assert!(
            !changed,
            "a not-yet-current turn's follow_ups must not render immediately"
        );
        assert!(
            agent.follow_ups.is_none(),
            "nothing rendered while buffered"
        );
        assert!(
            agent.follow_up_pending.contains_key("p2"),
            "the delivery is buffered keyed by its promptId"
        );
        agent.session.current_prompt_id = Some("p2".into());
        let flushed = agent.flush_pending_follow_ups("p2");
        assert!(flushed, "adoption flushes the buffered follow_ups");
        assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-2");
        assert_eq!(agent.follow_ups.as_ref().unwrap().suggestions, vec!["b"]);
        assert!(
            !agent.follow_up_pending.contains_key("p2"),
            "the buffered entry is consumed on flush"
        );
        assert!(agent.follow_up_pending_order.is_empty());
    }
    /// FIX (no stale revival): a buffered entry for a `promptId` that is
    /// SUPERSEDED by a newer turn (and never becomes current) must NOT revive.
    #[test]
    fn apply_follow_ups_buffered_superseded_turn_does_not_revive() {
        let mut agent = make_agent();
        agent.session.current_prompt_id = Some("p1".into());
        assert!(!agent.apply_follow_ups_with_prompt("resp-2".into(), Some("p2"), vec!["b".into()]));
        assert!(agent.follow_up_pending.contains_key("p2"));
        agent.session.current_prompt_id = Some("p3".into());
        assert!(
            !agent.flush_pending_follow_ups("p3"),
            "no buffered entry for the adopted turn p3"
        );
        assert!(
            agent.follow_ups.is_none(),
            "the never-adopted p2 buffer must not revive on a different adoption"
        );
        assert!(agent.apply_follow_ups_with_prompt("resp-3".into(), Some("p3"), vec!["c".into()]));
        assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-3");
        assert!(
            agent.follow_up_pending.contains_key("p2"),
            "p2 remains buffered-but-inert (bounded by the cap), never revived"
        );
    }
    /// The pending buffer is FIFO-bounded: an overflow evicts ONLY the oldest
    /// entry, never the whole map (so other not-yet-adopted turns survive).
    #[test]
    fn pending_follow_ups_buffer_evicts_oldest_on_overflow() {
        let mut agent = make_agent();
        agent.session.current_prompt_id = Some("cur".into());
        let total = super::MAX_PENDING_FOLLOW_UPS + 1;
        for i in 0..total {
            assert!(!agent.apply_follow_ups_with_prompt(
                format!("resp-{i}"),
                Some(&format!("p{i}")),
                vec!["x".into()],
            ));
        }
        assert_eq!(agent.follow_up_pending.len(), super::MAX_PENDING_FOLLOW_UPS);
        assert!(
            !agent.follow_up_pending.contains_key("p0"),
            "the OLDEST buffered entry is evicted on overflow"
        );
        assert!(
            agent.follow_up_pending.contains_key("p1"),
            "a still-buffered (non-oldest) entry survives the overflow"
        );
        agent.session.current_prompt_id = Some("p1".into());
        assert!(agent.flush_pending_follow_ups("p1"));
        assert_eq!(agent.follow_ups.as_ref().unwrap().response_id, "resp-1");
    }
    /// FIX (reload must not wipe adopted chips): follow_ups that arrive during
    /// `loading_replay` for the running turn are BUFFERED (the turn is not
    /// current yet). On `SessionLoaded` the reset must PRESERVE that buffer (drop
    /// only stale pre-reload state) so adoption flushes + renders them.
    #[test]
    fn reload_preserves_running_turn_follow_ups_and_renders_on_adoption() {
        let mut agent = make_agent();
        agent.session.current_prompt_id = Some("p-stale".into());
        agent.session.loading_replay = true;
        assert!(!agent.apply_follow_ups_with_prompt(
            "resp-run".into(),
            Some("p-run"),
            vec!["go".into()],
        ));
        assert!(!agent.apply_follow_ups_with_prompt(
            "resp-old".into(),
            Some("p-old"),
            vec!["stale".into()],
        ));
        assert!(agent.follow_up_pending.contains_key("p-run"));
        assert!(agent.follow_up_pending.contains_key("p-old"));
        agent.reset_follow_ups_for_reload_preserving(Some("p-run"));
        assert!(
            agent.follow_up_pending.contains_key("p-run"),
            "the running turn's buffered follow_ups survive the reload reset"
        );
        assert!(
            !agent.follow_up_pending.contains_key("p-old"),
            "stale pre-reload buffers are still dropped"
        );
        agent.adopt_running_prompt("p-run".into());
        assert_eq!(
            agent.follow_ups.as_ref().unwrap().response_id,
            "resp-run",
            "the running turn's follow_ups render after adoption"
        );
    }
    /// FIX (reload must not wipe DISPLAYED chips): when the running turn's
    /// follow_ups already RENDERED during `loading_replay` (because
    /// `current_prompt_id` was unset or already equalled the running turn, so the
    /// delivery took the render path, not the buffer), the reload reset must also
    /// preserve those on-screen chips — re-buffering them so adoption re-renders
    /// them WITHOUT the server resending. A stale OTHER turn is still dropped.
    #[test]
    fn reload_preserves_running_turn_displayed_chips_and_rerenders_on_adoption() {
        let mut agent = make_agent();
        agent.session.current_prompt_id = Some("p-run".into());
        agent.session.loading_replay = true;
        assert!(agent.apply_follow_ups_with_prompt(
            "resp-run".into(),
            Some("p-run"),
            vec!["go".into()],
        ));
        assert_eq!(
            agent.follow_ups.as_ref().unwrap().response_id,
            "resp-run",
            "the running turn's chips are on screen (displayed, not buffered)"
        );
        assert_eq!(agent.follow_up_shown_prompt_id.as_deref(), Some("p-run"));
        assert!(
            agent.follow_up_pending.is_empty(),
            "displayed chips are NOT in the pending buffer"
        );
        assert!(!agent.apply_follow_ups_with_prompt(
            "resp-old".into(),
            Some("p-old"),
            vec!["stale".into()],
        ));
        assert!(agent.follow_up_pending.contains_key("p-old"));
        agent.reset_follow_ups_for_reload_preserving(Some("p-run"));
        assert!(
            agent.follow_ups.is_none(),
            "the reset clears the on-screen chips"
        );
        assert!(
            agent.follow_up_pending.contains_key("p-run"),
            "the running turn's DISPLAYED chips are re-buffered so adoption can restore them"
        );
        assert!(
            !agent.follow_up_pending.contains_key("p-old"),
            "the stale OTHER turn is still dropped"
        );
        agent.adopt_running_prompt("p-run".into());
        assert_eq!(
            agent.follow_ups.as_ref().unwrap().response_id,
            "resp-run",
            "the running turn's chips re-render after adoption without a server resend"
        );
    }
    /// A full reload reset (no running turn to preserve) clears the pending
    /// buffer too — the reconnect-reload finalize path.
    #[test]
    fn reset_for_reload_clears_pending_buffer() {
        let mut agent = make_agent();
        agent.session.current_prompt_id = Some("cur".into());
        assert!(!agent.apply_follow_ups_with_prompt("r".into(), Some("future"), vec!["a".into()],));
        assert!(agent.follow_up_pending.contains_key("future"));
        agent.reset_follow_ups_for_reload();
        assert!(
            agent.follow_up_pending.is_empty(),
            "a full reload reset clears the pending buffer"
        );
        assert!(agent.follow_up_pending_order.is_empty());
    }
    #[test]
    fn follow_up_chip_click_maps_to_suggestion_text() {
        let mut agent = make_agent();
        agent.apply_follow_ups("resp-1".into(), vec!["First".into(), "Second".into()]);
        let area = ratatui::layout::Rect::new(0, 0, 60, 1);
        let mut buf = ratatui::buffer::Buffer::empty(area);
        let theme = crate::theme::Theme::current();
        let suggestions = agent.follow_ups.as_ref().unwrap().suggestions.clone();
        agent.follow_up_chips =
            crate::views::agent::render_follow_ups(area, &mut buf, &theme, &suggestions, None);
        assert_eq!(agent.follow_up_chips.len(), 2, "both chips fit");
        let r = agent.follow_up_chips[1];
        let idx = agent
            .follow_up_chip_at(r.x + 1, r.y)
            .expect("click inside a chip hits it");
        assert_eq!(idx, 1);
        assert_eq!(
            agent.follow_ups.as_ref().unwrap().suggestions[idx],
            "Second"
        );
        assert_eq!(agent.follow_up_chip_at(area.width - 1, 0), None);
    }
    /// `make_running_agent` reduced to a single focused local row: empty server
    /// mirror, no in-flight prompt. The shared setup for the pane-hide paths.
    pub fn running_agent_local_only() -> AgentView {
        let mut agent = make_running_agent();
        agent.active_pane = AgentPane::Queue;
        agent.shared_queue.clear();
        agent.session.current_prompt_id = None;
        agent.queue.sync_from_merged(
            &agent.session.pending_prompts,
            &agent.shared_queue,
            None,
            None,
            &agent.send_now_painted_blocks,
        );
        agent.queue.overlay.visible = true;
        agent.queue.overlay.focused = true;
        agent
    }
    /// Test image record attached to queued-prompt rows in the carry tests.
    pub fn test_pasted_image() -> crate::prompt_images::PastedImage {
        crate::prompt_images::from_clipboard_data(&crate::clipboard::ImageData {
            data: vec![1, 2, 3],
            mime_type: "image/png".into(),
        })
    }
}
/// Build a minimal [`AgentView`] for tests with an explicit session identity, so
/// the lazy Mermaid glue (which needs a session dir) can be exercised from the
/// `mermaid_worker` test module without duplicating the large `AgentSession`
/// literal.
#[cfg(any(test, feature = "test-support"))]
pub(crate) fn test_agent_view(session_id: Option<&str>, cwd: std::path::PathBuf) -> AgentView {
    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
    AgentView::new(
        crate::app::agent::AgentSession {
            id: crate::app::agent::AgentId(0),
            acp_tx: tx,
            session_id: session_id.map(agent_client_protocol::SessionId::new),
            models: crate::acp::model_state::ModelState::default(),
            state: crate::app::agent::AgentState::Idle,
            tracker: crate::acp::tracker::AcpUpdateTracker::new(),
            cwd,
            is_worktree: false,
            forked_from: None,
            pending_prompts: std::collections::VecDeque::new(),
            next_queue_id: 0,
            yolo_mode: false,
            auto_mode: false,
            prompt_history: Vec::new(),
            prompt_history_loading: false,
            loading_replay: false,
            restore_degree: None,
            rate_limited: false,
            model_incompatible: false,
            available_commands: Vec::new(),
            available_commands_generation: 0,
            available_tools: None,
            model_switch_pending: false,
            user_model_preference: None,
            deferred_model_switch: None,
            bg_tasks: std::collections::BTreeMap::new(),
            bg_tool_call_to_task: std::collections::HashMap::new(),
            scheduled_tasks: std::collections::HashMap::new(),
            in_flight_prompt: None,
            current_prompt_id: None,
            created_via_new: false,
        },
        crate::scrollback::state::ScrollbackState::new(),
    )
}
#[cfg(test)]
mod dropdown_chrome_tests {
    use super::*;
    use ratatui::buffer::Buffer;
    /// A panel taller than the space above the prompt (wrapped rows on a
    /// short screen) must clamp, not hang past the buffer: an unclamped
    /// `top` saturates at 0 and ratatui's `Clear` panics on the overhang.
    #[test]
    fn above_anchor_clamps_to_short_screen() {
        let theme = crate::theme::Theme::current();
        let layout_cfg = crate::appearance::LayoutConfig::default();
        let area = Rect::new(0, 0, 100, 6);
        let prompt = Rect::new(0, 4, 100, 2);
        let mut buf = Buffer::empty(area);
        let chrome = render_dropdown_chrome(
            &mut buf,
            2,
            6,
            None,
            prompt,
            area,
            &layout_cfg,
            false,
            false,
            &theme,
        );
        if let Some(chrome) = chrome {
            assert!(chrome.panel.bottom() <= area.bottom());
            assert!(chrome.items.height >= 1);
            assert_eq!(chrome.items.height, chrome.panel.height - 2);
        }
        let prompt_top = Rect::new(0, 0, 100, 2);
        let chrome = render_dropdown_chrome(
            &mut buf,
            2,
            6,
            None,
            prompt_top,
            area,
            &layout_cfg,
            false,
            false,
            &theme,
        );
        assert!(chrome.is_none());
        for h in 1..=8u16 {
            for prompt_y in 0..h {
                let area = Rect::new(0, 0, 40, h);
                let mut buf = Buffer::empty(area);
                let prompt = Rect::new(0, prompt_y, 40, 1);
                let _ = render_dropdown_chrome(
                    &mut buf,
                    2,
                    6,
                    None,
                    prompt,
                    area,
                    &layout_cfg,
                    false,
                    false,
                    &theme,
                );
            }
        }
    }
}
#[cfg(test)]
mod prompt_input_mode_tests {
    use super::*;
    use crate::app::actions::Action;
    use crate::theme::Theme;
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
    #[test]
    fn default_is_normal() {
        assert_eq!(PromptInputMode::default(), PromptInputMode::Normal);
    }
    #[test]
    fn accent_color_returns_expected_for_each_variant() {
        let theme = Theme::current();
        assert_eq!(PromptInputMode::Normal.accent_color(&theme), None);
        assert_eq!(
            PromptInputMode::Bash.accent_color(&theme),
            Some(theme.command)
        );
        assert_eq!(
            PromptInputMode::Remember.accent_color(&theme),
            Some(theme.accent_remember)
        );
    }
    #[test]
    fn prefix_override_returns_expected_for_each_variant() {
        let theme = Theme::current();
        assert_eq!(PromptInputMode::Normal.prefix_override(&theme), None);
        assert_eq!(
            PromptInputMode::Bash.prefix_override(&theme),
            Some(("! ", theme.command))
        );
        assert_eq!(
            PromptInputMode::Remember.prefix_override(&theme),
            Some(("# ", theme.accent_remember))
        );
    }
    #[test]
    fn placeholder_override_returns_expected_for_each_variant() {
        assert_eq!(PromptInputMode::Normal.placeholder_override(false), None);
        assert_eq!(PromptInputMode::Normal.placeholder_override(true), None);
        assert_eq!(PromptInputMode::Bash.placeholder_override(false), None);
        assert_eq!(PromptInputMode::Bash.placeholder_override(true), None);
        assert_eq!(
            PromptInputMode::Remember.placeholder_override(false),
            Some("Save a memory note... (Shift+Enter for multiline)")
        );
        assert_eq!(
            PromptInputMode::Remember.placeholder_override(true),
            Some("Save a memory note... (Enter for newline, Shift+Enter to save)")
        );
    }
    #[test]
    fn prompt_info_override_returns_expected_for_each_variant() {
        assert_eq!(PromptInputMode::Normal.prompt_info_override(), None);
        assert_eq!(
            PromptInputMode::Bash.prompt_info_override(),
            Some("Run shell command")
        );
        assert_eq!(
            PromptInputMode::Remember.prompt_info_override(),
            Some("Save memory note")
        );
    }
    #[test]
    fn send_action_maps_to_correct_action_variant() {
        let t1 = "hello world".to_string();
        assert!(matches!(PromptInputMode::Normal.send_action(t1.clone()),
            Action::SendPrompt(t) if t == t1));
        let t2 = "ls -l".to_string();
        assert!(matches!(PromptInputMode::Bash.send_action(t2.clone()),
            Action::SendBashCommand(t) if t == t2));
        let t4 = "remember this".to_string();
        assert!(matches!(PromptInputMode::Remember.send_action(t4.clone()),
            Action::SendRememberNote(t) if t == t4));
    }
    #[test]
    fn is_exit_key_normal_never_exits() {
        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
        let back = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
        let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
        let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
        assert!(!PromptInputMode::Normal.is_exit_key(&esc));
        assert!(!PromptInputMode::Normal.is_exit_key(&back));
        assert!(!PromptInputMode::Normal.is_exit_key(&ctrl_c));
        assert!(!PromptInputMode::Normal.is_exit_key(&enter));
    }
    #[test]
    fn is_exit_key_bash_and_remember_share_full_exit_set() {
        for mode in [PromptInputMode::Bash, PromptInputMode::Remember] {
            assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)));
            assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
            assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)));
            assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)));
            assert!(mode.is_exit_key(&KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)));
            assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
            assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)));
            assert!(!mode.is_exit_key(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)));
        }
    }
}
