//! ScrollbackState - unified state for v3 scrollback pane.
//!
//! This combines entries, scroll position, selection, and turn-based navigation
//! into a single clean state object.

pub mod groups;
mod layout;
mod nav;
mod selection;
mod timeline;
mod types;
pub mod verb_group;

pub(crate) use layout::ScrollAnchor;
pub use layout::compute_paint_window;
pub use timeline::TimelineEntry;
pub use types::*;

use layout::LayoutCache;

use std::collections::{HashSet, VecDeque};
use std::ops::Range;
use std::time::Instant;

use indexmap::IndexMap;
use ratatui::layout::Rect;

use super::block::{BlockContent, RenderBlock};
use super::blocks::tool::{EditToolCallBlock, ToolCallBlock};
use super::entry::{EntryId, ScrollbackEntry};
use super::layout::HorizontalLayout;
use super::selection::SelectionBox;
use super::sticky::{PromptDescriptor, StickyHeaderLayout, compute_sticky_layout};
use super::types::DisplayMode;
use super::wrappers::EntryRenderer;
use crate::appearance::AppearanceConfig;
use crate::render::Renderable;
use crate::theme::Theme;

/// Unified scrollback state for the v3 pager.
#[derive(Debug)]
pub struct ScrollbackState {
    // Content
    /// All entries in the scrollback, keyed by EntryId for O(1) lookup.
    /// IndexMap preserves insertion order for rendering.
    entries: IndexMap<EntryId, ScrollbackEntry>,

    /// Next entry ID to assign.
    next_id: u64,

    /// Set of currently running entry IDs.
    /// Used for O(running) iteration in tick_running().
    running: HashSet<EntryId>,

    /// Entry IDs whose finish-flash (accent stays bright for
    /// [`FINISH_FLASH_DURATION_MS`] after completion) may still be active.
    /// Lets `tick()` check O(flashing) recently-finished entries instead of
    /// scanning every entry's `finished_at` on every animation tick.
    /// Pushed by `finish_running_with_time`, drained by `tick()` on expiry.
    flashing: Vec<EntryId>,

    /// Set of entry IDs with potentially stale cached heights.
    /// Used for incremental layout updates - only these entries need height recomputation.
    dirty_heights: HashSet<EntryId>,

    /// Minimal mode only: entry IDs already emitted into the terminal's native
    /// scrollback. Keyed by `EntryId` (not a per-entry flag) so it survives
    /// `shift_remove` / `remove_from` reordering for free — a positional index
    /// would be stranded by a below-cursor removal. Pruned when
    /// an entry is removed and merged by `append_entries_from`, exactly like the
    /// sibling `running` / `dirty_heights` id-sets. Empty in the alt-screen /
    /// inline modes, which never commit. Driven by `crate::minimal` via
    /// `minimal_api::{is_committed, mark_committed}`.
    committed: HashSet<EntryId>,

    /// Minimal mode only: lowest entry index that *might* be uncommitted (not
    /// yet printed into native scrollback). A lower-bound perf hint so the
    /// per-frame commit pass is O(new) rather than O(history); the authoritative
    /// state is the `committed` id-set above. Clamped to `entries.len()` on
    /// every removal so a `shift_remove` / `remove_from` can never strand it
    /// past the end. Unused (always 0) in the alt-screen / inline modes.
    commit_scan_cursor: usize,

    /// Minimal mode only: a bounded ring of entry IDs that were committed to
    /// native scrollback in a folded display mode (collapsed reasoning,
    /// truncated tool output). `Ctrl+E` / `/expand` pops the most-recent one
    /// and re-prints it fully below (committed terminal
    /// text can't be mutated, so expansion is a re-print). Bounded so a long
    /// session never grows it without limit; reset by `clear()`.
    commit_expand_ring: VecDeque<EntryId>,
    // Scroll
    /// Scroll offset in rows from top.
    ///
    /// `usize` (not `u16`): a long session can render well past 65 535 rows, so
    /// the cumulative scroll position must match `virtual_y` (`Vec<usize>`).
    /// A `u16` here stranded the bottom of very long sessions.
    scroll_offset: usize,

    /// Total content height (cached, updated on render).
    ///
    /// `usize` for the same reason as `scroll_offset`: the summed height of a
    /// long session can exceed `u16::MAX`.
    total_height: usize,

    /// Viewport height (set on render). Stays `u16` — a terminal is never
    /// 65 535 rows tall.
    viewport_height: u16,

    /// Whether auto-scroll is enabled (follow new content).
    follow_mode: bool,

    /// When true, handle_follow_mode skips the scroll-to-bottom on the first
    /// call, preserving a scroll position set by scroll_to_entry_top.
    /// Cleared after one use. This lets dispatch_send_prompt position the
    /// prompt at the viewport top while still enabling follow for new content.
    follow_preserve_scroll: bool,

    // Selection
    /// Currently selected entry index.
    selected: Option<usize>,

    /// Selection box to be rendered by the frame (computed during render).
    /// This is stored here so the frame can render it after the scrollback pane.
    selection_box: Option<SelectionBox>,

    // Turns
    /// Detected turns in the conversation.
    turns: Vec<Turn>,

    /// Index of the currently viewed turn.
    current_turn: Option<usize>,

    /// View mode: all turns or single turn.
    view_mode: ViewMode,

    // Cache
    /// Last width used for rendering (to detect resize).
    // Width change invalidates the entire layout cache and triggers a full
    // recompute of entry heights. Resize events are debounced at the event-loop
    // level so only the final width triggers a rebuild.
    last_width: u16,

    /// Layout cache for navigation (entry heights, prompt descriptors).
    layout_cache: Option<LayoutCache>,

    // Sticky modes
    /// Display mode applied to thinking blocks when they finish running.
    /// Defaults to `Collapsed` (auto-collapse on finish).
    /// Toggled by `expand_all_thinking()` (Ctrl+E) between `Expanded` and `Collapsed`.
    thinking_display_mode: DisplayMode,

    // Animation
    /// Frame tick counter for animations (increments each render tick).
    tick: u64,

    // Appearance
    /// Current appearance configuration (hot-reloadable).
    appearance: AppearanceConfig,

    // Batching
    /// When > 0, `push()` skips `rebuild_turns()` and `invalidate_layout_cache()`.
    /// Call `begin_batch()` before bulk insertions and `end_batch()` after.
    batch_depth: u32,

    /// True when gap_after values may need recomputation (display_mode changed,
    /// entries added/removed). Streaming content mutations (`push_chunk_to_*`)
    /// leave this false, enabling an O(1) incremental virtual_y patch instead
    /// of an O(n) full rebuild.
    gaps_may_be_dirty: bool,

    /// Last observed [`ffmpeg_available`](crate::inline_media_ffmpeg::ffmpeg_available).
    /// A false→true flip (user installs ffmpeg mid-session) must rebuild the
    /// layout so reserved heights match the now-full-size posters — otherwise a
    /// poster paints over the text below its (still banner-sized) reservation.
    ffmpeg_available_snapshot: bool,

    /// Set of group-start EntryIds whose group has been manually expanded
    /// by the user (pressing l/Enter on the group header). When a group's
    /// first entry ID is in this set, the fold pass (`groups::apply`) marks
    /// its span expanded instead of hiding entries.
    expanded_groups: HashSet<EntryId>,

    // Link map
    /// Monotonically increasing counter, bumped on scroll, viewport, or
    /// content changes. Used by `VisibleLinkMap::is_stale()` to skip rebuilds.
    generation: u64,

    /// Bumped only when entries are added/removed or an entry's content changes
    /// — never on display toggles (fold/raw/group), appearance, scroll, or
    /// viewport. A stable invalidation key for content-derived caches that must
    /// survive view changes, unlike `generation`.
    content_generation: u64,

    /// Test-only count of full `rebuild_layout` calls, so reveal tests can
    /// assert the fast path skips the O(history) rebuild for visible matches.
    #[cfg(test)]
    layout_rebuilds: usize,

    /// Height override for the inline-edited entry: measurement reports this
    /// instead of the block's natural height so the layout reserves room for
    /// the live edit textarea. Cleared when editing ends.
    pub(super) inline_edit_height: Option<(EntryId, u16)>,

    /// Session/worktree cwd (`AgentSession.cwd`) for Expanded tool paths.
    cwd: Option<std::path::PathBuf>,
}

impl Default for ScrollbackState {
    fn default() -> Self {
        Self::new()
    }
}

impl ScrollbackState {
    /// Create a new empty state.
    pub fn new() -> Self {
        Self {
            entries: IndexMap::new(),
            next_id: 1, // Start at 1 so 0 can be a sentinel
            running: HashSet::new(),
            flashing: Vec::new(),
            dirty_heights: HashSet::new(),
            committed: HashSet::new(),
            commit_scan_cursor: 0,
            commit_expand_ring: VecDeque::new(),
            scroll_offset: 0,
            total_height: 0,
            viewport_height: 0,
            follow_mode: true,
            follow_preserve_scroll: false,
            selected: None,
            selection_box: None,
            turns: Vec::new(),
            current_turn: None,
            view_mode: ViewMode::AllTurns,
            last_width: 0,
            layout_cache: None,
            thinking_display_mode: DisplayMode::Collapsed,
            tick: 0,
            appearance: AppearanceConfig::default(),
            batch_depth: 0,
            gaps_may_be_dirty: false,
            ffmpeg_available_snapshot: false,
            expanded_groups: HashSet::new(),
            generation: 0,
            content_generation: 0,
            #[cfg(test)]
            layout_rebuilds: 0,
            inline_edit_height: None,
            cwd: None,
        }
    }

    pub fn cwd(&self) -> Option<&std::path::Path> {
        self.cwd.as_deref()
    }

    /// Update session cwd; invalidates entry paint caches when it changes.
    pub fn set_cwd(&mut self, cwd: Option<std::path::PathBuf>) {
        if self.cwd == cwd {
            return;
        }
        self.cwd = cwd;
        for entry in self.entries.values_mut() {
            entry.invalidate_cache();
        }
        self.dirty_heights = self.entries.keys().copied().collect();
        self.layout_cache = None;
        self.gaps_may_be_dirty = true;
    }

    /// Create an empty state that continues this one's identity: same
    /// appearance and user view preferences, `EntryId` allocation resumes from
    /// this state's counter, and the invalidation generations continue (one
    /// past this state's, since the content visibly changes at the swap).
    /// Scroll position and selection deliberately reset with the content.
    ///
    /// Used by the reconnect session-reload to stage replay into fresh state
    /// while the pre-outage content is stashed for a possible restore: keeping
    /// the id space shared means `EntryId`s referenced across the swap (e.g.
    /// `SubagentInfo::scrollback_entry_id`) dangle harmlessly instead of
    /// aliasing an unrelated entry, and lets
    /// [`Self::append_entries_from`] merge without collisions. The generation
    /// continuity keeps equality-cached consumers (`VisibleLinkMap::is_stale`,
    /// the search index) from mistaking the swapped-in state for the one they
    /// indexed.
    pub fn fresh_continuation(&self) -> Self {
        let mut fresh = Self::new();
        fresh.next_id = self.next_id;
        fresh.appearance = self.appearance.clone();
        fresh.thinking_display_mode = self.thinking_display_mode;
        fresh.view_mode = self.view_mode;
        fresh.follow_mode = self.follow_mode;
        fresh.cwd = self.cwd.clone();
        fresh.generation = self.generation.wrapping_add(1);
        fresh.content_generation = self.content_generation.wrapping_add(1);
        fresh
    }

    /// Lowest `EntryId` value a future [`push`](Self::push) may assign.
    pub(crate) fn id_floor(&self) -> u64 {
        self.next_id
    }

    /// Ensure future `EntryId`s are allocated at or above `floor`.
    ///
    /// Called when a stashed state is swapped back in after a
    /// [`fresh_continuation`](Self::fresh_continuation) sibling allocated ids,
    /// so ids handed out by the discarded sibling are never reused.
    pub(crate) fn raise_id_floor(&mut self, floor: u64) {
        self.next_id = self.next_id.max(floor);
    }

    /// Advance the invalidation generations strictly past a discarded
    /// [`fresh_continuation`](Self::fresh_continuation) sibling's, so caches
    /// keyed on counter equality (link map, search index) that last saw the
    /// sibling cannot mistake this state for it after a restore swap.
    pub(crate) fn raise_invalidation_floor(&mut self, sibling: (u64, u64)) {
        self.generation = self.generation.max(sibling.0);
        self.content_generation = self.content_generation.max(sibling.1);
        self.bump_content_generation();
    }

    /// The invalidation-generation pair, for [`Self::raise_invalidation_floor`].
    pub(crate) fn invalidation_generations(&self) -> (u64, u64) {
        (self.generation, self.content_generation)
    }

    /// Whether a [`begin_batch`](Self::begin_batch) is currently open.
    pub(crate) fn in_batch(&self) -> bool {
        self.batch_depth > 0
    }

    /// Append all entries from `tail` (a
    /// [`fresh_continuation`](Self::fresh_continuation) sibling of this state)
    /// after the existing content, preserving their `EntryId`s so tracker
    /// references into the tail stay valid.
    ///
    /// Used by the cursor-found reconnect reload: nothing was replayed, so the
    /// pre-outage transcript is kept and only the post-cursor live tail that
    /// accumulated in the staging state is attached below it.
    pub(crate) fn append_entries_from(&mut self, tail: ScrollbackState) {
        debug_assert!(
            tail.next_id >= self.next_id,
            "append_entries_from requires a fresh_continuation sibling (shared id space)"
        );
        self.entries.extend(tail.entries);
        self.running.extend(tail.running);
        self.dirty_heights.extend(tail.dirty_heights);
        // Carry the tail's committed frontier: with a per-entry flag this
        // traveled with the entry; as an id-set it must be merged explicitly so
        // already-committed tail blocks are not re-emitted after the reload.
        self.committed.extend(tail.committed);
        self.expanded_groups.extend(tail.expanded_groups);
        self.next_id = self.next_id.max(tail.next_id);
        // The tail (live during the window) is what equality-cached consumers
        // last saw — the merged state must read as newer than both halves.
        self.generation = self.generation.max(tail.generation);
        self.content_generation = self.content_generation.max(tail.content_generation);
        self.rebuild_turns();
        self.gaps_may_be_dirty = true;
        self.invalidate_layout_cache();
        self.bump_content_generation();
    }

    /// Update the appearance configuration.
    pub fn set_appearance(&mut self, appearance: AppearanceConfig) {
        self.appearance = appearance;
        // Invalidate caches since appearance affects rendering
        self.layout_cache = None;
        self.gaps_may_be_dirty = true;
        // Mark all entries as having dirty heights
        self.dirty_heights = self.entries.keys().copied().collect();
        for entry in self.entries.values_mut() {
            entry.invalidate_cache();
        }
        self.bump_generation();
    }

    /// Remeasure every entry when a process-wide visibility flag flips
    /// (e.g. show thinking blocks) without changing `AppearanceConfig`.
    /// Eagerly rebuilds when `last_width` is known; clears dirty markers so
    /// the next `prepare_layout` does not re-enter the incremental path.
    pub fn invalidate_heights(&mut self) {
        for entry in self.entries.values_mut() {
            entry.invalidate_cache();
        }
        self.rebuild_layout();
        // rebuild_layout already remeasured; leave Case 2 empty so follow
        // mode is not re-run as if heights were still streaming-dirty.
        self.dirty_heights.clear();
        self.gaps_may_be_dirty = false;
        self.bump_generation();
    }

    /// Mark entry `id` structurally dirty: height re-measure plus gap/fold
    /// recompute on the next `prepare_layout`. For in-place block swaps that
    /// can change fold membership (e.g. a tool refinement changing its
    /// verb-group kind), where a plain cache invalidation is not enough.
    pub fn mark_structurally_dirty(&mut self, id: EntryId) {
        self.dirty_heights.insert(id);
        self.gaps_may_be_dirty = true;
    }

    /// Get current appearance config.
    pub fn appearance(&self) -> &AppearanceConfig {
        &self.appearance
    }

    /// Current animation tick value (for spinner frame selection, etc.).
    pub fn animation_tick(&self) -> u64 {
        self.tick
    }

    /// Advance the animation tick counter.
    /// Returns `true` if a redraw is needed (there are running/animated entries).
    ///
    /// Call this at a fixed rate (e.g., 30fps) from the main loop.
    /// The return value tells you whether to keep ticking and whether to redraw.
    ///
    /// A redraw is requested only when an animated entry (running wave accent
    /// or unexpired finish-flash) is actually inside the viewport window. An
    /// off-screen running entry — a background task scrolled far away, or a
    /// running entry in another tab's scrollback — must not force ~30fps full
    /// redraws of an otherwise static screen (the frame diff would be empty;
    /// all the layout/render work would be pure waste).
    pub fn tick(&mut self) -> bool {
        self.tick = self.tick.wrapping_add(1);

        let mut needs_redraw = !self.running.is_empty() && self.any_running_in_viewport();

        // Finish-flash: O(flashing) over recently-finished entries, not
        // O(entries) over the whole scrollback. Emit one final redraw when a
        // flash expires so the accent repaints in its static state (otherwise
        // the last-painted bright frame would linger until the next event).
        if !self.flashing.is_empty() {
            let flash_dur = FINISH_FLASH_DURATION_MS as u128;
            let mut still_flashing = std::mem::take(&mut self.flashing);
            still_flashing.retain(|id| {
                let Some(idx) = self.entries.get_index_of(id) else {
                    // Entry was removed (rewind/clear) — nothing to repaint.
                    return false;
                };
                let active = self
                    .entries
                    .get_index(idx)
                    .and_then(|(_, e)| e.finished_at)
                    .is_some_and(|t| t.elapsed().as_millis() < flash_dur);
                // Redraw while the flash animates and once when it expires,
                // but only if the entry can be seen.
                if self.entry_index_in_viewport(idx) {
                    needs_redraw = true;
                }
                active
            });
            self.flashing = still_flashing;
        }

        needs_redraw
    }

    /// Whether any entry is still marked running (visible or not).
    pub fn has_running_entries(&self) -> bool {
        !self.running.is_empty()
    }

    /// Whether any running entry is inside the current viewport window.
    /// Conservative: with no layout yet (before the first draw), every
    /// entry counts as visible.
    fn any_running_in_viewport(&self) -> bool {
        self.running.iter().any(|id| {
            self.entries
                .get_index_of(id)
                .is_some_and(|idx| self.entry_index_in_viewport(idx))
        })
    }

    /// Get the current tick counter (for animation synchronization).
    pub fn tick_count(&self) -> u64 {
        self.tick
    }

    /// Check if animation ticks are needed.
    /// Returns `true` if there is a running entry inside the viewport window.
    /// Off-screen running entries don't need ticks — the wave phase simply
    /// resumes when they scroll back in (any scroll input re-arms the tick
    /// via `schedule_tick`).
    ///
    /// Finish-flashes deliberately do NOT demand ticks (matching long-standing
    /// behavior): they animate opportunistically while ticks flow for other
    /// reasons — `tick()` tracks them in O(flashing) and repaints once on
    /// expiry when possible.
    /// Use this to decide whether to start the animation timer.
    pub fn needs_animation(&self) -> bool {
        !self.running.is_empty() && self.any_running_in_viewport()
    }

    /// Get the current animation tick value.
    pub fn current_tick(&self) -> u64 {
        self.tick
    }

    // Link map generation

    /// Current link-map generation. Incremented whenever content, scroll,
    /// or viewport changes invalidate the visible link positions.
    pub fn generation(&self) -> u64 {
        self.generation
    }

    /// Bump the generation counter (marks the current `VisibleLinkMap` stale).
    fn bump_generation(&mut self) {
        self.generation = self.generation.wrapping_add(1);
    }

    /// Generation that moves only when the entry set or an entry's content
    /// changes — not on fold/raw/group display toggles, appearance, scroll, or
    /// viewport. Stable invalidation key for content-derived caches (e.g. a
    /// search index) that must not rebuild on view changes, unlike `generation`.
    pub fn content_generation(&self) -> u64 {
        self.content_generation
    }

    /// Bump `content_generation` (and `generation` with it, since content
    /// changes also move visible links). Display-only changes (fold/raw/group),
    /// appearance, scroll, and viewport call `bump_generation` directly so
    /// `content_generation` stays put.
    fn bump_content_generation(&mut self) {
        self.content_generation = self.content_generation.wrapping_add(1);
        self.bump_generation();
    }
    // Batching

    /// Begin a batch of insertions. While batching, `push()` skips
    /// `rebuild_turns()` and `invalidate_layout_cache()`. Call `end_batch()`
    /// when done to run them once.
    pub fn begin_batch(&mut self) {
        self.batch_depth += 1;
    }

    /// End a batch. Runs the deferred `rebuild_turns()` and
    /// `invalidate_layout_cache()` once for all insertions.
    pub fn end_batch(&mut self) {
        self.batch_depth = self.batch_depth.saturating_sub(1);
        if self.batch_depth == 0 {
            self.rebuild_turns();
            self.invalidate_layout_cache();
        }
    }

    // Content Management

    /// Add an entry, assigning it a unique ID.
    ///
    /// Returns the assigned EntryId which can be used to access this entry later.
    pub fn push(&mut self, entry: ScrollbackEntry) -> EntryId {
        let id = EntryId::new(self.next_id);
        self.next_id += 1;

        let mut entry = entry;
        entry.id = id;

        // Fresh Edit entries at the block's Collapsed default adopt the
        // state-owned materialize policy. An explicit non-Collapsed mode
        // survives; an explicit Collapsed is indistinguishable from the
        // default and may be upgraded by the effective expanded default.
        if let RenderBlock::ToolCall(ToolCallBlock::Edit(edit)) = &entry.block
            && entry.display_mode == DisplayMode::Collapsed
        {
            entry.display_mode = edit_default_display_mode(
                self.appearance
                    .scrollback
                    .blocks
                    .edit
                    .effective_expanded(crate::appearance::cache::load_collapsed_edit_blocks()),
                edit,
            );
        }

        // Track if this entry is running
        if entry.is_running {
            self.running.insert(id);
        }

        self.entries.insert(id, entry);
        if self.batch_depth == 0 {
            self.rebuild_turns();
            // Try to extend the cache incrementally. The new entry's
            // height/gap/virtual_y are computed and appended in O(1) (plus
            // one cheap pairwise recompute for the previous entry's gap).
            // If extension isn't possible (no cache yet, or cache out of
            // sync), fall back to the full invalidation that the next
            // prepare_layout would handle in Case 1.
            let new_idx = self.entries.len() - 1;
            if !self.extend_layout_cache_with_new_entry(new_idx) {
                self.gaps_may_be_dirty = true;
                self.invalidate_layout_cache();
            } else if self.appearance.scrollback.display.group_max_visible > 0
                || crate::appearance::cache::load_group_tool_verbs()
            {
                // Check if the new entry is groupable+collapsed — it may extend
                // a group past the truncation threshold, or start or grow a
                // foldable verb-group run (the verb fold is gated on
                // `group_tool_verbs`, independent of the truncation threshold).
                // Mark both dirty so prepare_layout Case 2 fires (Case 3
                // doesn't check gaps_may_be_dirty).
                if let Some((_, new_e)) = self.entries.get_index(new_idx)
                    && new_e.block.is_groupable()
                    && new_e.display_mode == DisplayMode::Collapsed
                {
                    self.gaps_may_be_dirty = true;
                    self.dirty_heights.insert(id);
                }
            }
            // Successful extend: gaps were updated inline, so we deliberately
            // do NOT set gaps_may_be_dirty -- that would force the next
            // streaming chunk's Case 2 path to do a full virtual_y rebuild.
        } else {
            // In batch mode: defer to end_batch's full rebuild for safety.
            // (The cache is bulk-rebuilt once when the batch ends.)
            self.gaps_may_be_dirty = true;
            self.layout_cache = None;
        }
        self.bump_content_generation();
        id
    }

    /// Add a block (convenience wrapper).
    ///
    /// Returns the assigned EntryId.
    pub fn push_block(&mut self, block: RenderBlock) -> EntryId {
        self.push(ScrollbackEntry::new(block))
    }

    /// Remove an entry by EntryId. No-op if the id is not present.
    ///
    /// Used by the cancel-with-restore flow to undo the user prompt block
    /// that was pushed at turn start. Returns `true` if an entry was removed.
    pub fn remove_entry(&mut self, id: EntryId) -> bool {
        // Capture the index before the removal shifts everything after it down.
        let removed_index = self.entries.get_index_of(&id);
        if self.entries.shift_remove(&id).is_none() {
            return false;
        }
        self.running.remove(&id);
        self.dirty_heights.remove(&id);
        self.committed.remove(&id);
        self.expanded_groups.remove(&id);
        if let Some(sel) = self.selected
            && sel >= self.entries.len()
        {
            self.selected = self.entries.len().checked_sub(1);
        }
        // Keep the minimal-mode commit cursor pointing at the same *entry*: a
        // mid-list `shift_remove` below the cursor shifts every later entry
        // down one, so the cursor must move down with them. Clamping alone is
        // NOT enough — with entries past the cursor, `min(cursor, len)` leaves
        // the cursor unchanged and the first uncommitted entry slides below it,
        // where no commit/tail walk ever looks again (it would silently vanish
        // from minimal mode's scrollback AND live tail). A decremented cursor
        // can only be *low*, which is safe: the walk re-skips already-committed
        // entries via the authoritative `committed` id-set.
        if let Some(idx) = removed_index
            && idx < self.commit_scan_cursor
        {
            self.commit_scan_cursor -= 1;
        }
        self.commit_scan_cursor = self.commit_scan_cursor.min(self.entries.len());
        self.rebuild_turns();
        self.gaps_may_be_dirty = true;
        self.invalidate_layout_cache();
        self.bump_content_generation();
        true
    }

    pub fn remove_from(&mut self, index: usize) -> Vec<ScrollbackEntry> {
        let mut removed = Vec::new();
        while self.entries.len() > index {
            if let Some((id, entry)) = self.entries.pop() {
                self.running.remove(&id);
                self.dirty_heights.remove(&id);
                self.committed.remove(&id);
                self.expanded_groups.remove(&id);
                removed.push(entry);
            }
        }
        removed.reverse();
        if let Some(sel) = self.selected
            && sel >= self.entries.len()
        {
            self.selected = self.entries.len().checked_sub(1);
        }
        // Clamp the minimal-mode commit cursor: `remove_from` (rewind / trailing
        // auth-error strip) pops the tail, which could otherwise leave the
        // cursor past the end and silently skip future commits.
        self.commit_scan_cursor = self.commit_scan_cursor.min(self.entries.len());
        self.rebuild_turns();
        self.gaps_may_be_dirty = true;
        self.invalidate_layout_cache();
        self.bump_content_generation();
        removed
    }

    /// Find the EntryId of the last real tool call block in the scrollback.
    ///
    /// Skips `ToolCallBlock::Lifecycle` entries (e.g. `user_prompt_submit`)
    /// so that tool-associated hooks only attach to actual tool calls.
    pub fn last_tool_call_entry_id(&self) -> Option<EntryId> {
        self.entries.iter().rev().find_map(|(id, entry)| {
            if let RenderBlock::ToolCall(ref tcb) = entry.block {
                // Skip lifecycle event blocks (e.g. user_prompt_submit) — they
                // are not real tool calls and shouldn't receive tool hooks.
                if matches!(tcb, ToolCallBlock::Lifecycle(_)) {
                    return None;
                }
                Some(*id)
            } else {
                None
            }
        })
    }

    /// Attach hook data to a tool call entry.
    pub fn attach_hooks(
        &mut self,
        id: EntryId,
        phase: super::blocks::tool::HookPhase,
        hook_entries: Vec<super::blocks::tool::HookRunEntry>,
    ) {
        if let Some(entry) = self.entries.get_mut(&id) {
            let data = entry.hook_data.get_or_insert_with(Default::default);
            match phase {
                super::blocks::tool::HookPhase::Pre => data.pre_hooks = hook_entries,
                super::blocks::tool::HookPhase::Post => data.post_hooks = hook_entries,
            }
            entry.invalidate_cache();
            // Structural, not just a height change: hook chrome removes the
            // row from verb-group membership (`run_step`), so a folded run
            // must re-run its folds to surface the `[hooks: N/M]` row.
            self.mark_structurally_dirty(id);
        }
    }

    /// Push a standalone lifecycle hook block (`session_start`, replayed
    /// `stop`, …): a collapsed tool-like row with the event name as header
    /// and the runs as fold-out detail.
    pub fn push_lifecycle_hooks(
        &mut self,
        event_name: String,
        hook_entries: Vec<super::blocks::tool::HookRunEntry>,
    ) -> EntryId {
        use super::blocks::tool::{LifecycleEventBlock, ToolCallHookData};
        let block = LifecycleEventBlock::new(&event_name);
        let mut entry = super::entry::ScrollbackEntry::new(RenderBlock::ToolCall(
            ToolCallBlock::Lifecycle(block),
        ));
        entry.hook_data = Some(ToolCallHookData {
            pre_hooks: Vec::new(),
            post_hooks: Vec::new(),
            lifecycle: vec![(event_name, hook_entries)],
        });
        self.push(entry)
    }

    /// The most recent turn-terminal marker ("Turn completed/cancelled/
    /// failed") that can accept a live `stop`/`stop_failure` batch arriving
    /// after the marker (viewer order). The walk skips blocks appended after
    /// the marker. A stamped batch needs the marker to carry the same prompt
    /// id — and treats parked markers as transparent: they never
    /// accept hooks themselves (their turn is still running), and pid-exact
    /// attribution cannot misattach, so a late prior-turn batch may cross
    /// the current turn's not-yet-settled boundary into its own turn's
    /// marker. An unstamped batch is positional (tail only) and stops at ANY
    /// terminal-event marker — without a pid there is no proof it belongs
    /// further back. A same-name repeat (e.g. the session-end `stop`) is
    /// always refused.
    pub fn latest_turn_marker_accepting(
        &self,
        event_name: &str,
        batch_prompt_id: Option<&str>,
    ) -> Option<EntryId> {
        for (position_from_tail, (id, entry)) in self.entries.iter().rev().enumerate() {
            let RenderBlock::SessionEvent(b) = &entry.block else {
                continue;
            };
            if !b.event.is_turn_terminal() {
                continue;
            }
            if !b.accepts_stop_hooks() {
                // Parked: transparent to a stamped batch, a hard stop for
                // a positional one.
                if batch_prompt_id.is_some() {
                    continue;
                }
                return None;
            }
            if b.stop_hooks.iter().any(|(name, _)| name == event_name) {
                return None;
            }
            let accept = match (batch_prompt_id, b.prompt_id.as_deref()) {
                (Some(batch), Some(marker)) => batch == marker,
                (Some(_), None) => false,
                (None, _) => position_from_tail == 0,
            };
            return accept.then_some(*id);
        }
        None
    }

    /// Whether a turn-terminal marker stamped with `prompt_id` is in scrollback.
    pub fn has_turn_terminal_marker_with_pid(&self, prompt_id: &str) -> bool {
        self.entries.iter().any(|(_, entry)| {
            matches!(&entry.block, RenderBlock::SessionEvent(b)
                if b.event.is_turn_terminal() && b.prompt_id.as_deref() == Some(prompt_id))
        })
    }

    /// Merge a stop/stop_failure hook batch into a turn-terminal marker
    /// entry and collapse it so the right-justified summary — not the
    /// fold-out detail — is the resting state. Returns `false` unless the
    /// entry is a turn-terminal session event the batch can be attributed to
    /// (see [`Self::latest_turn_marker_accepting`]) — never a parked
    /// marker; re-checked here so a stray caller can't attach hooks to the
    /// wrong entry.
    pub fn attach_stop_hooks_to_marker(
        &mut self,
        id: EntryId,
        event_name: String,
        hook_entries: Vec<super::blocks::tool::HookRunEntry>,
        batch_prompt_id: Option<&str>,
    ) -> bool {
        let Some(entry) = self.entries.get_mut(&id) else {
            return false;
        };
        let RenderBlock::SessionEvent(ref mut block) = entry.block else {
            return false;
        };
        if !block.accepts_stop_hooks() {
            return false;
        }
        let attributable = match (batch_prompt_id, block.prompt_id.as_deref()) {
            (Some(batch), Some(marker)) => batch == marker,
            (Some(_), None) => false,
            (None, _) => true,
        };
        if !attributable {
            return false;
        }
        block.stop_hooks.push((event_name, hook_entries));
        if !entry.display_mode_pinned {
            entry.display_mode = DisplayMode::Collapsed;
        }
        entry.invalidate_cache();
        self.mark_structurally_dirty(id);
        true
    }

    /// Refresh an uncommitted parked marker after a subagent completion.
    pub(crate) fn refresh_parked_subagent_marker(
        &mut self,
        id: EntryId,
        prompt_id: &str,
        elapsed: std::time::Duration,
        end_work: super::blocks::EndWork,
    ) -> bool {
        if self.is_committed(id) {
            return false;
        }
        let Some(end_work) = end_work.nonzero() else {
            return false;
        };
        let Some(entry) = self.entries.get_mut(&id) else {
            return false;
        };
        let RenderBlock::SessionEvent(block) = &mut entry.block else {
            return false;
        };
        if !block.parked
            || block.prompt_id.as_deref() != Some(prompt_id)
            || !matches!(
                &block.event,
                super::blocks::SessionEvent::TurnCompleted { .. }
            )
        {
            return false;
        }
        block.event = super::blocks::SessionEvent::TurnCompleted {
            elapsed: Some(elapsed),
        };
        block.end_work = Some(end_work);
        entry.invalidate_cache();
        self.mark_height_dirty(id);
        true
    }

    /// Push a text chunk to an agent message entry.
    ///
    /// This is the preferred way to append streaming content because it:
    /// 1. Appends the chunk to the agent message
    /// 2. Invalidates the entry's render cache
    /// 3. Marks the entry's height as dirty for incremental layout updates
    ///
    /// Returns true if the chunk was successfully appended, false if the entry
    /// doesn't exist or isn't an agent message.
    ///
    /// # Example
    /// ```ignore
    /// let id = state.push_block(RenderBlock::agent_message(""));
    /// state.set_last_running(true);
    ///
    /// // In streaming loop:
    /// state.push_chunk_to_agent(id, "Hello ");
    /// state.push_chunk_to_agent(id, "world!");
    /// ```
    pub fn push_chunk_to_agent(&mut self, id: EntryId, chunk: &str) -> bool {
        if let Some(entry) = self.entries.get_mut(&id)
            && let RenderBlock::AgentMessage(ref mut msg) = entry.block
        {
            msg.push_chunk(chunk);
            entry.invalidate_cache();
            self.dirty_heights.insert(id);
            self.bump_content_generation();
            return true;
        }
        false
    }

    /// Push a chunk to an agent message entry without rendering markdown yet.
    pub fn push_chunk_to_agent_deferred(&mut self, id: EntryId, chunk: &str) -> bool {
        if let Some(entry) = self.entries.get_mut(&id)
            && let RenderBlock::AgentMessage(ref mut msg) = entry.block
        {
            msg.push_chunk_deferred(chunk);
            entry.invalidate_cache();
            self.dirty_heights.insert(id);
            self.bump_content_generation();
            return true;
        }
        false
    }

    /// Push streaming output to an execute block entry.
    ///
    /// `output` is the full accumulated output bytes (not a delta).
    /// We replace the block's output entirely on each call (the shell sends
    /// the full buffer each tick, not incremental deltas).
    ///
    /// Returns true if successful, false if the entry doesn't exist or isn't an execute block.
    pub fn set_execute_output(&mut self, id: EntryId, output: &str) -> bool {
        if let Some(entry) = self.entries.get_mut(&id)
            && let RenderBlock::ToolCall(ToolCallBlock::Execute(ref mut exec)) = entry.block
        {
            // Replace output entirely — kigi-shell sends full accumulated buffer each tick.
            // The shell now sends clean output (no ANSI codes) when the client sets
            // kigi/bashOutputNoColor: true, so no stripping is needed.
            exec.output = Some(output.to_string());
            entry.invalidate_cache();
            self.dirty_heights.insert(id);
            self.bump_content_generation();
            return true;
        }
        false
    }

    /// Push a text chunk to a thinking block entry.
    ///
    /// Similar to `push_chunk_to_agent()`, this handles all necessary cache
    /// invalidation for streaming thinking content.
    ///
    /// Returns true if successful, false if the entry doesn't exist or isn't a thinking block.
    pub fn push_chunk_to_thinking(&mut self, id: EntryId, chunk: &str) -> bool {
        if let Some(entry) = self.entries.get_mut(&id)
            && let RenderBlock::Thinking(ref mut block) = entry.block
        {
            block.push_chunk(chunk);
            entry.invalidate_cache();
            self.dirty_heights.insert(id);
            self.bump_content_generation();
            return true;
        }
        false
    }

    /// Push a chunk to a thinking entry without rendering markdown yet.
    pub fn push_chunk_to_thinking_deferred(&mut self, id: EntryId, chunk: &str) -> bool {
        if let Some(entry) = self.entries.get_mut(&id)
            && let RenderBlock::Thinking(ref mut block) = entry.block
        {
            block.push_chunk_deferred(chunk);
            entry.invalidate_cache();
            self.dirty_heights.insert(id);
            self.bump_content_generation();
            return true;
        }
        false
    }

    /// Append incremental output delta to an execute tool call entry.
    ///
    /// Used when the shell sends incremental `output_delta` instead of full buffers.
    /// Delegates to `push_chunk_to_execute` which handles cache invalidation.
    ///
    /// Returns true if successful, false if the entry doesn't exist or isn't an execute block.
    pub fn append_execute_output(&mut self, id: EntryId, delta: &str) -> bool {
        self.push_chunk_to_execute(id, delta)
    }

    /// Push an output chunk to an execute tool call entry.
    ///
    /// Similar to `push_chunk_to_agent()`, this handles all necessary cache
    /// invalidation for streaming command output.
    ///
    /// Returns true if successful, false if the entry doesn't exist or isn't an execute block.
    pub fn push_chunk_to_execute(&mut self, id: EntryId, chunk: &str) -> bool {
        if let Some(entry) = self.entries.get_mut(&id)
            && let RenderBlock::ToolCall(ToolCallBlock::Execute(ref mut block)) = entry.block
        {
            block.push_output(chunk);
            entry.invalidate_cache();
            // Always mark dirty - word wrap may change line count even for same-line appends.
            // HashSet dedup makes this cheap when called repeatedly.
            self.dirty_heights.insert(id);
            self.bump_content_generation();
            return true;
        }
        false
    }

    /// Mark an entry's height as dirty, requiring recomputation on next prepare_layout().
    ///
    /// Use this when you modify an entry's content directly (e.g., via get_by_id_mut())
    /// and the change might affect its rendered height.
    pub fn mark_height_dirty(&mut self, id: EntryId) {
        self.dirty_heights.insert(id);
        self.gaps_may_be_dirty = true;
        self.layout_cache = None;
        self.bump_content_generation();
    }

    /// Set (or clear) the inline-edit height override for an entry. Marks
    /// affected entries height-dirty; no-op when unchanged (called per frame).
    pub fn set_inline_edit_height(&mut self, override_h: Option<(EntryId, u16)>) {
        if self.inline_edit_height == override_h {
            return;
        }
        if let Some((old_id, _)) = self.inline_edit_height {
            self.mark_height_dirty(old_id);
        }
        if let Some((new_id, _)) = override_h {
            self.mark_height_dirty(new_id);
        }
        self.inline_edit_height = override_h;
    }

    /// Current inline-edit height override, if any.
    pub fn inline_edit_height(&self) -> Option<(EntryId, u16)> {
        self.inline_edit_height
    }

    /// Get number of entries.
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// Check if empty.
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Clear all entries.
    pub fn clear(&mut self) {
        self.entries.clear();
        self.running.clear();
        self.flashing.clear();
        self.dirty_heights.clear();
        self.committed.clear();
        self.expanded_groups.clear();
        // Note: we don't reset next_id to avoid ID reuse
        self.selected = None;
        self.turns.clear();
        self.current_turn = None;
        self.scroll_offset = 0;
        self.commit_scan_cursor = 0;
        self.commit_expand_ring.clear();
        self.invalidate_layout_cache();
        self.bump_content_generation();
    }

    // ── Minimal-mode committed frontier ──────────────────────────────────
    //
    // These support `crate::minimal`'s commit pipeline (print finalized blocks
    // into native scrollback). The authoritative state is the `committed`
    // id-set; `commit_scan_cursor` is only a lower-bound hint to keep the
    // per-frame scan O(new). Reached from the minimal crate via
    // `minimal_api::{is_committed, mark_committed, commit_scan_cursor, …}`.

    /// Lowest entry index that may still be uncommitted (minimal-mode hint).
    pub(crate) fn commit_scan_cursor(&self) -> usize {
        self.commit_scan_cursor
    }

    /// Advance the commit scan cursor, clamped to the current entry count.
    pub(crate) fn set_commit_scan_cursor(&mut self, cursor: usize) {
        self.commit_scan_cursor = cursor.min(self.entries.len());
    }

    /// Whether the entry `id` was already emitted into native scrollback.
    pub(crate) fn is_committed(&self, id: EntryId) -> bool {
        self.committed.contains(&id)
    }

    /// Mark the entry at `index` as committed to native scrollback.
    /// No-op if the index is out of range.
    pub(crate) fn mark_committed(&mut self, index: usize) {
        if let Some((&id, _)) = self.entries.get_index(index) {
            self.committed.insert(id);
        }
    }

    /// Maximum number of folded-commit IDs retained for `Ctrl+E` / `/expand`.
    const EXPAND_RING_CAP: usize = 256;

    /// Record that the entry `id` was committed to native scrollback in a folded
    /// display mode (collapsed reasoning / truncated tool output), so `Ctrl+E` /
    /// `/expand` can later re-print it in full. Bounded:
    /// the oldest entry is dropped once the ring is full.
    pub(crate) fn record_committed_for_expand(&mut self, id: EntryId) {
        self.commit_expand_ring.push_back(id);
        while self.commit_expand_ring.len() > Self::EXPAND_RING_CAP {
            self.commit_expand_ring.pop_front();
        }
    }

    /// Pop the most-recently committed folded entry whose entry still exists,
    /// for `Ctrl+E` / `/expand` to re-print fully. Returns `None` when nothing
    /// folded remains to expand. Stale IDs (entries removed by rewind / clear)
    /// are skipped.
    pub(crate) fn take_expandable_committed(&mut self) -> Option<EntryId> {
        while let Some(id) = self.commit_expand_ring.pop_back() {
            if self.entries.contains_key(&id) {
                return Some(id);
            }
        }
        None
    }

    /// Get entry by index.
    pub fn get(&self, index: usize) -> Option<&ScrollbackEntry> {
        self.entries.get_index(index).map(|(_, v)| v)
    }

    /// Get entry by index mutably.
    pub fn get_mut(&mut self, index: usize) -> Option<&mut ScrollbackEntry> {
        self.entries.get_index_mut(index).map(|(_, v)| v)
    }

    /// Get the last entry.
    pub fn last(&self) -> Option<&ScrollbackEntry> {
        self.entries.last().map(|(_, v)| v)
    }

    /// Get the last entry mutably.
    pub fn last_mut(&mut self) -> Option<&mut ScrollbackEntry> {
        self.entries.last_mut().map(|(_, v)| v)
    }

    /// Mark the last entry as running.
    ///
    /// When entering running state (`running = true`), also starts timing
    /// on tool call blocks via `ToolCallBlock::start_timing()`. This is
    /// the single point where block timing begins — constructors default
    /// to `started_at = None`.
    pub fn set_last_running(&mut self, running: bool) {
        if let Some((_, entry)) = self.entries.last_mut() {
            let was_running = entry.is_running;
            entry.is_running = running;
            entry.invalidate_cache();

            // Start timing when a tool block enters running state.
            if running && !was_running {
                if let RenderBlock::ToolCall(ref mut tc) = entry.block {
                    tc.start_timing();
                }
                self.running.insert(entry.id);
            } else if !running && was_running {
                self.running.remove(&entry.id);
            }
        }
    }

    /// Get entry by ID. O(1) average via IndexMap.
    ///
    /// Returns None if the entry doesn't exist (was removed or ID is invalid).
    pub fn get_by_id(&self, id: EntryId) -> Option<&ScrollbackEntry> {
        self.entries.get(&id)
    }

    /// Get entry by ID mutably. O(1) average via IndexMap.
    ///
    /// Returns None if the entry doesn't exist (was removed or ID is invalid).
    pub fn get_by_id_mut(&mut self, id: EntryId) -> Option<&mut ScrollbackEntry> {
        self.entries.get_mut(&id)
    }

    /// Replace `entry_id`'s tool-call block in place — the single owner of
    /// the display-mode policy for lifecycle swaps (tracker refinement and
    /// completion):
    ///
    /// - Edit-to-Edit swaps keep the entry's current mode, so a manual
    ///   expand of the one-liner survives later refinements and completion.
    ///   Exception: when the swap first turns the summary untrusted (a later
    ///   Diff revealed a multi-file call), the entry escalates to Expanded —
    ///   the one-liner it was collapsed to no longer tells the truth. The
    ///   escalation fires only on that rising edge, so a user's collapse of
    ///   an already-untrusted block sticks.
    /// - Pinned (user-folded) entries keep their mode under
    ///   `respect_manual_folds`.
    /// - Any other swap (e.g. the eager `Other` placeholder refining into
    ///   its real kind) resets to the new block's default, with Edits routed
    ///   through the same materialize policy as `push`.
    ///
    /// Stamps `started_at` on the new block, invalidates the entry's render
    /// cache, and marks the entry structurally dirty when its verb-group
    /// kind changed. Returns false when the entry no longer exists.
    pub(crate) fn replace_tool_block(
        &mut self,
        entry_id: EntryId,
        mut block: RenderBlock,
        started_at: Option<Instant>,
    ) -> bool {
        let respect_manual_folds = self.appearance.scrollback.scroll.respect_manual_folds;
        let expanded_by_default = self
            .appearance
            .scrollback
            .blocks
            .edit
            .effective_expanded(crate::appearance::cache::load_collapsed_edit_blocks());
        let Some(entry) = self.entries.get_mut(&entry_id) else {
            return false;
        };
        if let RenderBlock::ToolCall(new_tc) = &mut block
            && let Some(t) = started_at
        {
            new_tc.set_started_at(t);
        }
        let kind_changed = verb_group::verb_group_kind_changed(&entry.block, &block);
        let (edit_to_edit, untrusted_rising) = match (&entry.block, &block) {
            (
                RenderBlock::ToolCall(ToolCallBlock::Edit(old)),
                RenderBlock::ToolCall(ToolCallBlock::Edit(new)),
            ) => (
                true,
                new.is_success() && new.summary_untrusted && !old.summary_untrusted,
            ),
            _ => (false, false),
        };
        entry.block = block;
        if edit_to_edit {
            // Keep the current mode: the entry was already an Edit, so any
            // change since materialization is a user gesture — except on the
            // untrusted rising edge, where the collapsed one-liner became a
            // lie and Expanded is the only truthful default. Pins still win.
            if untrusted_rising && !(respect_manual_folds && entry.display_mode_pinned) {
                entry.display_mode = DisplayMode::Expanded;
            }
        } else if respect_manual_folds && entry.display_mode_pinned {
            tracing::debug!(
                entry_id = entry_id.value(),
                would_be_mode = ?entry.block.default_display_mode(),
                "scrollback.finish.pin_suppressed_override"
            );
        } else {
            entry.display_mode = match &entry.block {
                RenderBlock::ToolCall(ToolCallBlock::Edit(edit)) => {
                    edit_default_display_mode(expanded_by_default, edit)
                }
                block => block.default_display_mode(),
            };
        }
        entry.invalidate_cache();
        if kind_changed {
            self.mark_structurally_dirty(entry_id);
        }
        true
    }

    /// Apply a live `collapsed_edit_blocks` flag flip (settings toggle or
    /// remote settings update; the cache is already set to the new value).
    ///
    /// Entries still sitting on their old policy default re-materialize under
    /// the new one, so the toggle is visible on the existing transcript; any
    /// other mode is a user gesture and survives (an explicit fold back to
    /// the old default is indistinguishable and flips too — same caveat as
    /// `push`'s materialize gate). Pins win under `respect_manual_folds`. An
    /// explicit pager.toml `expanded_by_default` makes both defaults equal,
    /// so the walk naturally no-ops. Heights are rebuilt afterwards: Edit
    /// rows change height on a mode flip, and the collapsed header's `+N/-M`
    /// suffix (read live via `effective_line_summary`) needs a repaint even
    /// without one. Stale group-expansion ids describe the old dense-run
    /// shape (collapsed Edits participate), so they are dropped like the
    /// `group_tool_verbs` flip does.
    pub fn apply_collapsed_edit_blocks_flip(&mut self, old_flag: bool, new_flag: bool) {
        let edit_cfg = &self.appearance.scrollback.blocks.edit;
        let old_expanded = edit_cfg.effective_expanded(old_flag);
        let new_expanded = edit_cfg.effective_expanded(new_flag);
        let respect_manual_folds = self.appearance.scrollback.scroll.respect_manual_folds;
        if old_expanded != new_expanded {
            for entry in self.entries.values_mut() {
                let RenderBlock::ToolCall(ToolCallBlock::Edit(edit)) = &entry.block else {
                    continue;
                };
                if respect_manual_folds && entry.display_mode_pinned {
                    continue;
                }
                if entry.display_mode == edit_default_display_mode(old_expanded, edit) {
                    entry.display_mode = edit_default_display_mode(new_expanded, edit);
                }
            }
        }
        self.clear_group_expansion();
        self.invalidate_heights();
    }

    /// Get the index of an entry by its ID. O(1) average via IndexMap.
    pub fn index_of_id(&self, id: EntryId) -> Option<usize> {
        self.entries.get_index_of(&id)
    }

    /// Capture a width-stable bookmark of the viewport-top content, to re-pin
    /// it after a resize/re-wrap (the `/jump` capture-and-restore). `None` when
    /// there's no layout to anchor to.
    pub(crate) fn capture_scroll_bookmark(&self) -> Option<ScrollAnchor> {
        self.capture_scroll_anchor()
    }

    /// Re-pin the viewport to a bookmark from [`Self::capture_scroll_bookmark`].
    pub(crate) fn restore_scroll_bookmark(&mut self, bookmark: ScrollAnchor) {
        self.restore_scroll_anchor(bookmark);
    }

    /// Mark an entry as finished (no longer running).
    ///
    /// If the entry has been running for less than `MIN_RUNNING_DURATION_MS`,
    /// the finish is deferred so the animated "running" state is visible.
    pub fn finish_running(&mut self, id: EntryId) {
        self.finish_running_with_time(id, None);
    }

    /// Mark every running entry finished.
    ///
    /// Used when a transcript is restored/merged after a reconnect reload:
    /// entries left running by the pre-outage turn are unknown to the fresh
    /// tracker, so `finish_turn` alone would leave them animating forever.
    pub(crate) fn finish_all_running(&mut self) {
        let ids: Vec<EntryId> = self.running.iter().copied().collect();
        for id in ids {
            self.finish_running(id);
        }
    }

    /// Mark an entry as no longer running, with optional thinking time.
    ///
    /// For thinking blocks, the thinking_time_ms will be displayed in collapsed mode.
    pub fn finish_running_with_time(&mut self, id: EntryId, thinking_time_ms: Option<i64>) {
        self.running.remove(&id);
        // Track the finish-flash window so `tick()` checks O(flashing)
        // entries instead of scanning the whole scrollback per tick.
        if self.entries.contains_key(&id) && !self.flashing.contains(&id) {
            self.flashing.push(id);
        }
        let thinking_mode = self.thinking_display_mode;
        let respect_manual_folds = self.appearance.scrollback.scroll.respect_manual_folds;
        if let Some(entry) = self.get_by_id_mut(id) {
            entry.is_running = false;
            entry.finished_at = Some(Instant::now());
            // Finish streaming renderers (final safety re-render)
            match &mut entry.block {
                RenderBlock::AgentMessage(msg) => msg.finish(),
                RenderBlock::Thinking(thinking) => {
                    // finish() freezes the local started_at timer into
                    // elapsed_time_ms. Only use server time as a fallback
                    // when no local timer exists (e.g., during replay).
                    thinking.finish();
                    if thinking.elapsed_time_ms().is_none()
                        && let Some(time_ms) = thinking_time_ms
                    {
                        thinking.set_elapsed_time_ms(Some(time_ms));
                    }
                }
                RenderBlock::ToolCall(ToolCallBlock::Execute(b)) => b.finish(),
                RenderBlock::ToolCall(ToolCallBlock::Read(b)) => b.finish(),
                RenderBlock::ToolCall(ToolCallBlock::Edit(b)) => b.finish(),
                RenderBlock::ToolCall(ToolCallBlock::Search(b)) => b.finish(),
                RenderBlock::ToolCall(ToolCallBlock::ListDir(b)) => b.finish(),
                RenderBlock::ToolCall(ToolCallBlock::WebFetch(b)) => b.finish(),
                RenderBlock::ToolCall(ToolCallBlock::WebSearch(b)) => b.finish(),
                RenderBlock::ToolCall(ToolCallBlock::MemorySearch(b)) => b.finish(),
                RenderBlock::ToolCall(ToolCallBlock::Other(b)) => b.finish(),
                _ => {}
            }
            // Let the block decide what display mode to adopt on finish.
            // For thinking blocks, use the sticky `thinking_display_mode`
            // so Ctrl+E is respected across the session — except an
            // already-Expanded thinking block keeps its mode. Entries the
            // user manually folded (pinned) keep their mode.
            if respect_manual_folds && entry.display_mode_pinned {
                let would_be_mode = if matches!(entry.block, RenderBlock::Thinking(_)) {
                    (entry.display_mode != DisplayMode::Expanded).then_some(thinking_mode)
                } else {
                    entry.block.finished_display_mode()
                };
                if let Some(would_be_mode) = would_be_mode {
                    tracing::debug!(
                        entry_id = id.value(),
                        ?would_be_mode,
                        "scrollback.finish.pin_suppressed_override"
                    );
                }
            } else if matches!(entry.block, RenderBlock::Thinking(_)) {
                if entry.display_mode != DisplayMode::Expanded {
                    entry.display_mode = thinking_mode;
                }
            } else if let Some(mode) = entry.block.finished_display_mode() {
                // A Collapsed entry keeps its fold (no snap-open). Best
                // effort: the tracker's Completed refinement may reset the
                // mode to the block default before this runs; surviving a
                // reset is the fold-pin system's job (respect_manual_folds).
                if entry.display_mode != DisplayMode::Collapsed {
                    entry.display_mode = mode;
                }
            }
            entry.invalidate_cache();
        }
        // Mark height dirty since collapsed mode has different height.
        // display_mode may have changed, so gaps need recomputation.
        self.dirty_heights.insert(id);
        self.gaps_may_be_dirty = true;

        self.bump_content_generation();
    }

    /// Mark an entry as awaiting (or no longer awaiting) user input.
    ///
    /// Used by `AgentView` to flag tool-call entries that are blocked on a
    /// permission prompt or `ask_user_question`. The renderer swaps the wave
    /// "loading" animation for a pulsing-circle bullet on flagged entries.
    ///
    /// Returns `true` if the flag actually changed (caller may want to
    /// trigger a redraw). Returns `false` if the entry doesn't exist or
    /// already had the requested value.
    pub fn set_pending_user_input(&mut self, id: EntryId, pending: bool) -> bool {
        let Some(entry) = self.get_by_id_mut(id) else {
            return false;
        };
        if entry.is_pending_user_input == pending {
            return false;
        }
        entry.is_pending_user_input = pending;
        // No `invalidate_cache()` — the cached output is identical, only
        // the post-pass bullet styling differs frame to frame.
        // The flip IS structural though: `run_step` keeps pending rows out
        // of verb-group runs, so an already-folded run must re-run its folds
        // to surface the prompt row (and refold once it resolves).
        self.mark_structurally_dirty(id);
        true
    }

    /// Clear the pending-user-input flag from every entry.
    ///
    /// Called by `AgentView` before re-syncing flags from the current
    /// permission/question queues so stale marks don't linger.
    pub fn clear_all_pending_user_input(&mut self) {
        // Real transitions are structural (mirrors `set_pending_user_input`):
        // un-flagging returns a row to verb-group membership, and this clear
        // is the only false-ward path when a resolved permission simply stops
        // being re-marked by the next sync. Steady-state flags re-cycle
        // through clear+re-mark each frame while a prompt is open; that costs
        // one gap/fold pass per frame, the same order as this walk.
        let flagged: Vec<EntryId> = self
            .entries
            .iter()
            .filter_map(|(id, e)| e.is_pending_user_input.then_some(*id))
            .collect();
        for id in flagged {
            if let Some(entry) = self.entries.get_mut(&id) {
                entry.is_pending_user_input = false;
            }
            self.mark_structurally_dirty(id);
        }
    }

    /// Whether any entry is currently flagged as awaiting user input.
    ///
    /// Used by the animation driver: a flagged entry needs ticks even when
    /// nothing is `running` (the tool is paused on the user, not the model).
    pub fn has_pending_user_input(&self) -> bool {
        self.entries.values().any(|e| e.is_pending_user_input)
    }

    /// Invalidate all running entries for re-render.
    ///
    /// Call this periodically (e.g., every second) to update dynamic content
    /// like "[Running for Xs]" timers. This is O(running_count), not O(total).
    pub fn tick_running(&mut self) {
        let running_ids: Vec<EntryId> = self.running.iter().copied().collect();
        for id in running_ids {
            if let Some(entry) = self.get_by_id_mut(id) {
                entry.invalidate_cache();
            }
        }
    }

    /// Start a new streaming agent message.
    ///
    /// Creates an empty AgentMessageBlock in streaming mode and returns its EntryId.
    /// Use `get_by_id_mut()` to access the entry and push chunks.
    pub fn start_streaming_agent(&mut self) -> EntryId {
        let block = RenderBlock::agent_message_streaming();
        let entry = ScrollbackEntry::running(block);
        self.push(entry)
    }

    /// Get an entry by index (immutable).
    pub fn entry(&self, index: usize) -> Option<&ScrollbackEntry> {
        self.entries.get_index(index).map(|(_, v)| v)
    }

    /// Iterate over all entries mutably.
    pub fn entries_mut(&mut self) -> indexmap::map::ValuesMut<'_, EntryId, ScrollbackEntry> {
        self.entries.values_mut()
    }

    // Widget Helpers (used by ScrollbackPane widget)

    /// Prepare layout for rendering.
    ///
    /// Call this BEFORE rendering when the viewport might have changed.
    /// This is the ONE place where pre-render layout mutations happen:
    /// - Updates viewport dimensions
    /// - Recomputes layout cache if needed (heights, prompt descriptors)
    /// - Computes total_height from cache
    /// - Handles follow mode (auto-scroll to bottom)
    /// - Settles lazy viewport measurements (may further adjust heights/scroll)
    ///
    /// Returns true if the cache was rebuilt (Case 1/2) — not counting the lazy
    /// settle pass, which can also adjust heights/scroll. The sole caller ignores it.
    ///
    /// # Example
    /// ```ignore
    /// // In render loop:
    /// state.prepare_layout(area.width, area.height);
    /// pane.render_with_scratch(area, buf, &state, &mut scratch);
    /// ```
    pub fn prepare_layout(&mut self, width: u16, height: u16) -> bool {
        // Mid-session ffmpeg install: invalidate cached banner-sized reservations.
        if !self.ffmpeg_available_snapshot && crate::inline_media_ffmpeg::ffmpeg_available() {
            self.ffmpeg_available_snapshot = true;
            self.gaps_may_be_dirty = true;
            self.invalidate_layout_cache();
        }

        // Bump generation when viewport dimensions change — screen coordinates
        // of visible links shift, so the VisibleLinkMap must be rebuilt.
        if height != self.viewport_height || width != self.last_width {
            self.bump_generation();
        }

        // Update viewport height
        self.viewport_height = height;

        // Case 1: Cache missing or width changed - full rebuild
        if self.layout_cache.is_none() || width != self.last_width {
            // A width change re-wraps every entry, so the absolute wrapped-row
            // scroll_offset would point at different content after the rebuild
            // (the resize jump). While the old cache is still valid, anchor the
            // viewport-top content; restore it below. Anchoring is intentionally
            // limited to the not-following path: follow mode (including the
            // follow_preserve_scroll page-flip) re-pins each frame, so it needs
            // no anchor.
            let scroll_anchor =
                if width != self.last_width && !self.follow_mode && self.scroll_offset > 0 {
                    self.capture_scroll_anchor()
                } else {
                    None
                };

            if width != self.last_width {
                for entry in self.entries.values_mut() {
                    entry.invalidate_cache();
                }
                self.last_width = width;
            }
            // Full rebuild produces cheap height ESTIMATES for every entry.
            self.ensure_layout_cache(width);
            self.compute_total_height_from_cache();
            // Re-pin the anchored content to the viewport top now that virtual_y
            // is rebuilt at the new width (before settle clamps / re-pins to it).
            if let Some(anchor) = scroll_anchor {
                self.restore_scroll_anchor(anchor);
            }
            self.fixup_hidden_selection();
            self.handle_follow_mode();
            // Upgrade the on-screen entries to EXACT heights (O(viewport), not
            // O(history)) and re-pin the viewport to the measured content.
            self.settle_visible_measurements(width);
            // Pre-measure a few pages above the bottom so the first scroll-up is
            // glitch-free (no-op unless bottom-pinned).
            self.warm_measure_pages_above(width);
            self.dirty_heights.clear();
            self.gaps_may_be_dirty = false;
            return true;
        }

        // Case 2: Some entries have dirty heights - incremental update
        if !self.dirty_heights.is_empty() {
            let changes = self.update_dirty_entry_heights(width);
            self.dirty_heights.clear();

            if !changes.is_empty() {
                if self.gaps_may_be_dirty {
                    // Structural change (fold/expand/add/remove): full rebuild
                    self.rebuild_virtual_y_from_heights();
                    self.gaps_may_be_dirty = false;
                    self.compute_total_height_from_cache();
                    self.fixup_hidden_selection();
                } else {
                    // Fast path (streaming): only heights changed, gaps are stable.
                    // Patch virtual_y in O(n-k) where k is the earliest dirty index.
                    // For streaming (dirty entry at end), this is O(1).
                    let total_delta = self.patch_virtual_y_for_dirty(&changes);
                    // Apply delta directly instead of re-summing entire visible range.
                    // Clamp at 0 to avoid underflow; no upper cap (total_height is
                    // usize, so tall sessions are not truncated).
                    let new_total = (self.total_height as i64 + total_delta as i64).max(0);
                    self.total_height = new_total as usize;
                }
            } else if self.gaps_may_be_dirty {
                // Heights didn't change, but structural state is dirty (e.g.,
                // a new entry was pushed that extends a group needing truncation).
                // Must rebuild to apply group truncation even though heights are stable.
                self.rebuild_virtual_y_from_heights();
                self.gaps_may_be_dirty = false;
                self.compute_total_height_from_cache();
                self.fixup_hidden_selection();
            } else {
                // No heights changed, but visible range may have shifted
                self.compute_total_height_from_cache();
            }

            self.handle_follow_mode();
            // A scroll/content change may have brought estimated entries into
            // view (e.g. streaming while scrolled up); measure them exactly.
            self.settle_visible_measurements(width);
            return !changes.is_empty();
        }

        // Case 3: Nothing structurally changed, but total_height still depends on
        // visible_entry_range() which can change between renders (view mode switch,
        // turn navigation). Recompute unconditionally — it's just summing a slice.
        self.compute_total_height_from_cache();
        // Handle follow mode even when nothing changed structurally.
        // Needed after fold_selected_impl clears dirty_heights — the fold leaves
        // the cache clean, but follow/preserve state may need to react to the new
        // total_height (e.g., consume preserve on overflow).
        if self.follow_mode {
            self.handle_follow_mode();
        }
        // Scroll-up (no dirty heights) reveals estimated off-screen entries —
        // this is the on-demand measurement path for plain scrolling.
        self.settle_visible_measurements(width);
        false
    }

    /// Invalidate caches if width changed.
    pub fn invalidate_if_width_changed(&mut self, width: u16) {
        if width != self.last_width {
            for entry in self.entries.values_mut() {
                entry.invalidate_cache();
            }
            self.last_width = width;
            self.layout_cache = None;
            self.gaps_may_be_dirty = true;
            // Mark all heights as dirty
            self.dirty_heights = self.entries.keys().copied().collect();
        }
    }

    /// Get current scroll offset.
    pub fn scroll_offset(&self) -> usize {
        self.scroll_offset
    }

    /// Set viewport height.
    pub fn set_viewport_height(&mut self, height: u16) {
        self.viewport_height = height;
    }

    /// Set total content height.
    pub fn set_total_height(&mut self, height: usize) {
        self.total_height = height;
    }

    /// Set the scroll offset directly (e.g., from a scrollbar click).
    ///
    /// Clamps to `[0, max_offset]` and disables follow mode since the user
    /// is explicitly positioning the viewport.
    pub fn set_scroll_offset(&mut self, offset: usize) {
        let max_offset = self
            .total_height
            .saturating_sub(self.viewport_height as usize);
        self.scroll_offset = offset.min(max_offset);
        self.follow_mode = false;
        self.bump_generation();
    }

    /// Get mutable reference to an entry.
    pub fn entry_mut(&mut self, index: usize) -> Option<&mut ScrollbackEntry> {
        self.entries.get_index_mut(index).map(|(_, v)| v)
    }

    /// Whether group truncation replaces or hides this entry's content at the
    /// current layout (a "N more" header, or a hidden member at height 0) —
    /// i.e. the entry's own content is not what's on screen.
    pub fn entry_content_hidden_by_group(&self, idx: usize) -> bool {
        let Some(cache) = self.layout_cache.as_ref() else {
            return false;
        };
        let Some(info) = cache.entries.get(idx) else {
            return false;
        };
        info.is_group_header() || info.height == 0
    }

    /// Whether entry `idx` overlaps the current viewport (cached offsets + the
    /// current scroll). A visible entry is already exact (the prior settle covers
    /// the visible window), so callers can skip re-measuring it.
    fn entry_overlaps_viewport(&self, idx: usize) -> bool {
        if !self.visible_entry_range().contains(&idx) {
            return false;
        }
        let Some((top, bottom)) = self.viewport_virtual_bounds() else {
            return false;
        };
        let Some(cache) = self.layout_cache.as_ref() else {
            return false;
        };
        let Some(&entry_top) = cache.virtual_y.get(idx) else {
            return false;
        };
        let Some(info) = cache.entries.get(idx) else {
            return false;
        };
        let entry_bottom = entry_top + info.height as usize;
        entry_top < bottom && entry_bottom > top
    }
}

/// Display mode a freshly materialized Edit block adopts (fresh `push`, or a
/// kind upgrade in `replace_tool_block`): failed edits collapse; summaries
/// the one-liner can't truthfully compress expand; otherwise the effective
/// expanded default decides (`EditBlockConfig::effective_expanded`: explicit
/// pager.toml shape > the shell's `collapsed_edit_blocks` flag).
fn edit_default_display_mode(expanded_by_default: bool, edit: &EditToolCallBlock) -> DisplayMode {
    if edit.is_success() && (edit.summary_untrusted || expanded_by_default) {
        DisplayMode::Expanded
    } else {
        DisplayMode::Collapsed
    }
}

#[cfg(test)]
pub(super) mod test_util {
    use super::*;
    use ratatui::style::Color;

    pub(super) fn stub_block(text: &str) -> RenderBlock {
        RenderBlock::stub(text, Color::Blue)
    }

    pub(super) fn user_block(text: &str) -> RenderBlock {
        RenderBlock::user_prompt(text)
    }

    pub(super) fn tool_block(summary: &str) -> RenderBlock {
        RenderBlock::tool_call("Execute", summary, true)
    }

    pub(super) fn agent_block(text: &str) -> RenderBlock {
        RenderBlock::agent_message(text)
    }

    pub(super) fn tall_agent_block() -> RenderBlock {
        let text = (1..=10)
            .map(|i| format!("paragraph {i}"))
            .collect::<Vec<_>>()
            .join("\n\n");
        RenderBlock::agent_message(text)
    }

    /// Helper: create a collapsed groupable stub block.
    pub(super) fn collapsed_groupable(text: &str) -> ScrollbackEntry {
        ScrollbackEntry::new(RenderBlock::stub(text, Color::Blue))
            .with_display_mode(DisplayMode::Collapsed)
    }

    /// Helper: push N collapsed tool calls and return their IDs.
    pub(super) fn push_tool_calls(state: &mut ScrollbackState, n: usize) -> Vec<EntryId> {
        (0..n)
            .map(|i| state.push_block(RenderBlock::tool_call(format!("Tool{i}"), "info", true)))
            .collect()
    }

    /// Helper: get the group_header_count for entry at index `idx`.
    pub(super) fn header_count_at(state: &mut ScrollbackState, idx: usize) -> u16 {
        state.prepare_layout(80, 40);
        state
            .layout_cache
            .as_ref()
            .and_then(|c| c.entries.get(idx))
            .map(|e| e.group_header_count)
            .unwrap_or(0)
    }

    /// Helper: get cached height for entry at index `idx`.
    pub(super) fn cached_height_at(state: &ScrollbackState, idx: usize) -> u16 {
        state
            .layout_cache
            .as_ref()
            .and_then(|c| c.entries.get(idx))
            .map(|e| e.height)
            .unwrap_or(u16::MAX)
    }

    pub(super) struct ScrollTestHarness {
        pub(super) state: ScrollbackState,
        pub(super) width: u16,
        pub(super) height: u16,
    }

    impl ScrollTestHarness {
        pub(super) fn new(width: u16, height: u16) -> Self {
            let mut state = ScrollbackState::new();
            let mut appearance = crate::appearance::AppearanceConfig::default();
            appearance.scrollback.blocks.prompt.vpad = false;
            state.set_appearance(appearance);
            Self {
                state,
                width,
                height,
            }
        }

        pub(super) fn frame(&mut self) {
            self.state.prepare_layout(self.width, self.height);
        }

        pub(super) fn push_prompt(&mut self, text: &str) -> EntryId {
            let id = self.state.push_block(RenderBlock::user_prompt(text));
            self.frame();
            id
        }

        pub(super) fn push_thinking(&mut self, initial_text: &str) -> EntryId {
            let id = self.state.push_block(RenderBlock::thinking(initial_text));
            if let Some(entry) = self.state.entries.get_mut(&id) {
                entry.is_running = true;
                entry.set_display_mode(DisplayMode::Truncated);
            }
            self.state.running.insert(id);
            self.frame();
            id
        }

        pub(super) fn push_tool(&mut self, text: &str) -> EntryId {
            let id = self.state.push(collapsed_groupable(text));
            self.frame();
            id
        }

        pub(super) fn push_agent(&mut self, text: &str) -> EntryId {
            let id = self.state.push_block(RenderBlock::agent_message(text));
            self.frame();
            id
        }

        pub(super) fn stream_thinking(&mut self, id: EntryId, chunk: &str) {
            self.state.push_chunk_to_thinking(id, chunk);
            self.frame();
        }

        pub(super) fn select(&mut self, idx: usize) {
            self.state.set_selected(Some(idx));
        }

        pub(super) fn send_prompt(&mut self, text: &str) -> EntryId {
            let id = self.state.push_block(RenderBlock::user_prompt(text));
            let prompt_idx = self.state.len().saturating_sub(1);
            self.state.set_selected(Some(prompt_idx));
            self.state.scroll_to_entry_top(prompt_idx);
            self.state.enable_follow_with_preserve();
            self.frame();
            id
        }

        pub(super) fn toggle_fold(&mut self) {
            self.state.toggle_fold_selected();
        }

        #[allow(dead_code)]
        pub(super) fn scroll_offset(&self) -> usize {
            self.state.scroll_offset
        }
        pub(super) fn max_offset(&self) -> usize {
            self.state
                .total_height
                .saturating_sub(self.state.viewport_height as usize)
        }
        pub(super) fn is_follow(&self) -> bool {
            self.state.follow_mode
        }
        pub(super) fn is_preserve(&self) -> bool {
            self.state.follow_preserve_scroll
        }

        pub(super) fn assert_entry_at_top(&self, idx: usize, msg: &str) {
            let cache = self
                .state
                .layout_cache
                .as_ref()
                .expect("cache must be valid");
            let range = self.state.visible_entry_range();
            let base_y = cache.virtual_y[range.start];
            let entry_y = cache.virtual_y[idx] - base_y;
            assert_eq!(
                entry_y, self.state.scroll_offset,
                "{msg}: entry {idx} at vy={entry_y} should be at scroll_offset={}",
                self.state.scroll_offset
            );
        }

        pub(super) fn assert_at_bottom(&self, msg: &str) {
            let max = self.max_offset();
            assert_eq!(
                self.state.scroll_offset, max,
                "{msg}: scroll_offset={} should be at max_offset={max}",
                self.state.scroll_offset
            );
        }
    }
}

#[cfg(test)]
mod tests {
    use super::test_util::*;
    use super::*;
    use pretty_assertions::assert_eq;

    #[test]
    fn test_empty_state() {
        let state = ScrollbackState::new();
        assert!(state.is_empty());
        assert_eq!(state.len(), 0);
        assert_eq!(state.turn_count(), 0);
    }

    /// State with an explicit pager.toml-shaped `expanded_by_default`
    /// override — flag-independent (the `Some` wins over the cache).
    fn edit_state(expanded_by_default: bool) -> ScrollbackState {
        let mut state = ScrollbackState::new();
        let mut appearance = AppearanceConfig::default();
        appearance.scrollback.blocks.edit.expanded_by_default = Some(expanded_by_default);
        state.set_appearance(appearance);
        state
    }

    fn edit_block(block: EditToolCallBlock) -> RenderBlock {
        RenderBlock::ToolCall(ToolCallBlock::Edit(block))
    }

    /// `push` owns the Edit materialize policy: the explicit
    /// `expanded_by_default` shape override, the untrusted-summary escape,
    /// error collapse, and survival of an explicitly set mode.
    #[test]
    fn push_applies_edit_materialize_policy() {
        let ok = || EditToolCallBlock::new("f.rs", vec![]);

        let mut state = edit_state(false);
        let id = state.push_block(edit_block(ok()));
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Collapsed
        );
        let id = state.push_block(edit_block(ok().with_untrusted_summary()));
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Expanded,
            "untrusted summaries expand even with an explicit collapse override"
        );

        let mut state = edit_state(true);
        let id = state.push_block(edit_block(ok()));
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Expanded
        );
        let id = state.push_block(edit_block(ok().with_error("boom")));
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Collapsed,
            "failed edits collapse regardless of the expanded override"
        );

        let mut state = edit_state(false);
        let id = state
            .push(ScrollbackEntry::new(edit_block(ok())).with_display_mode(DisplayMode::Expanded));
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Expanded,
            "an explicitly set mode survives push"
        );
    }

    /// `replace_tool_block` applies the materialize policy on a genuine kind
    /// transition and preserves the current mode on Edit-to-Edit swaps (a
    /// user's manual expand survives refinement/completion).
    #[test]
    fn replace_tool_block_edit_policy() {
        let mut state = edit_state(false);
        let id = state.push_block(RenderBlock::tool_call("Other", "pending", true));
        assert!(state.replace_tool_block(
            id,
            edit_block(EditToolCallBlock::new("f.rs", vec![])),
            None
        ));
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Collapsed,
            "kind transition adopts the policy default"
        );

        // User opens the one-liner; the Edit-to-Edit completion swap keeps it.
        state
            .get_by_id_mut(id)
            .unwrap()
            .set_display_mode(DisplayMode::Expanded);
        assert!(state.replace_tool_block(
            id,
            edit_block(EditToolCallBlock::new("f.rs", vec![])),
            None
        ));
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Expanded,
            "Edit-to-Edit swap must preserve the user's mode"
        );

        // Kind transition with the opt-in on lands Expanded.
        let mut state = edit_state(true);
        let id = state.push_block(RenderBlock::tool_call("Other", "pending", true));
        assert!(state.replace_tool_block(
            id,
            edit_block(EditToolCallBlock::new("f.rs", vec![])),
            None
        ));
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Expanded
        );

        assert!(
            !state.replace_tool_block(
                EntryId::new(9999),
                edit_block(EditToolCallBlock::new("f.rs", vec![])),
                None
            ),
            "missing entry reports false"
        );
    }

    /// The untrusted rising edge overrides the Edit-to-Edit preserve rule:
    /// once a later Diff reveals a multi-file call, the collapsed one-liner
    /// lies and the entry must open. Steady-state untrusted swaps keep a
    /// user's collapse.
    #[test]
    fn replace_tool_block_untrusted_rising_edge_expands() {
        let untrusted = || EditToolCallBlock::new("f.rs", vec![]).with_untrusted_summary();

        let mut state = edit_state(false);
        let id = state.push_block(edit_block(EditToolCallBlock::new("f.rs", vec![])));
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Collapsed
        );
        assert!(state.replace_tool_block(id, edit_block(untrusted()), None));
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Expanded,
            "trusted-to-untrusted swap must escalate to Expanded"
        );

        // User collapses the untrusted block; untrusted-to-untrusted is not a
        // rising edge, so the gesture sticks.
        state
            .get_by_id_mut(id)
            .unwrap()
            .set_display_mode(DisplayMode::Collapsed);
        assert!(state.replace_tool_block(id, edit_block(untrusted()), None));
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Collapsed,
            "untrusted-to-untrusted swap must preserve the user's collapse"
        );
    }

    /// With the pager.toml shape keys unset (the shipped default), the
    /// shell-owned `collapsed_edit_blocks` flag decides materialization:
    /// on = collapsed one-liner, off = legacy expanded diff. Untrusted
    /// summaries still escape the collapse. Spawned thread: the cache is a
    /// sticky thread-local seeded explicitly here.
    #[test]
    fn push_defaults_follow_collapsed_edit_blocks_flag_when_shape_unset() {
        std::thread::spawn(|| {
            let ok = || EditToolCallBlock::new("f.rs", vec![]);

            crate::appearance::cache::set_collapsed_edit_blocks(true);
            let mut state = ScrollbackState::new();
            let id = state.push_block(edit_block(ok()));
            assert_eq!(
                state.get_by_id(id).unwrap().display_mode,
                DisplayMode::Collapsed,
                "flag on collapses fresh Edits"
            );
            let id = state.push_block(edit_block(ok().with_untrusted_summary()));
            assert_eq!(
                state.get_by_id(id).unwrap().display_mode,
                DisplayMode::Expanded,
                "untrusted summaries expand even with the flag on"
            );

            crate::appearance::cache::set_collapsed_edit_blocks(false);
            let mut state = ScrollbackState::new();
            let id = state.push_block(edit_block(ok()));
            assert_eq!(
                state.get_by_id(id).unwrap().display_mode,
                DisplayMode::Expanded,
                "flag off keeps the legacy expanded-diff default"
            );

            // An explicit pager.toml shape beats the flag in both directions.
            crate::appearance::cache::set_collapsed_edit_blocks(true);
            let mut state = edit_state(true);
            let id = state.push_block(edit_block(ok()));
            assert_eq!(
                state.get_by_id(id).unwrap().display_mode,
                DisplayMode::Expanded,
                "explicit expanded_by_default = true must beat the flag"
            );
        })
        .join()
        .unwrap();
    }

    /// A live flag flip re-materializes only entries still on their old
    /// policy default; a user gesture away from that default survives, and
    /// an explicit pager.toml shape makes the walk a no-op.
    #[test]
    fn collapsed_edit_blocks_flip_rematerializes_only_default_entries() {
        std::thread::spawn(|| {
            crate::appearance::cache::set_collapsed_edit_blocks(false);
            let mut state = ScrollbackState::new();
            let plain = state.push_block(edit_block(EditToolCallBlock::new("a.rs", vec![])));
            let failed = state.push_block(edit_block(
                EditToolCallBlock::new("b.rs", vec![]).with_error("boom"),
            ));
            assert_eq!(
                state.get_by_id(plain).unwrap().display_mode,
                DisplayMode::Expanded
            );
            // User opens the failed edit — a gesture away from its
            // flag-independent Collapsed default.
            state
                .get_by_id_mut(failed)
                .unwrap()
                .set_display_mode(DisplayMode::Expanded);

            crate::appearance::cache::set_collapsed_edit_blocks(true);
            state.apply_collapsed_edit_blocks_flip(false, true);
            assert_eq!(
                state.get_by_id(plain).unwrap().display_mode,
                DisplayMode::Collapsed,
                "entry on the old default must follow the flip"
            );
            assert_eq!(
                state.get_by_id(failed).unwrap().display_mode,
                DisplayMode::Expanded,
                "user gesture must survive the flip"
            );

            // Explicit shape override: both effective defaults are equal, so
            // the flip leaves the entry alone.
            let mut state = edit_state(true);
            let id = state.push_block(edit_block(EditToolCallBlock::new("c.rs", vec![])));
            state.apply_collapsed_edit_blocks_flip(true, false);
            assert_eq!(
                state.get_by_id(id).unwrap().display_mode,
                DisplayMode::Expanded,
                "explicit expanded_by_default pins the default across flips"
            );
        })
        .join()
        .unwrap();
    }

    #[test]
    fn stop_hooks_attach_only_to_turn_terminal_markers() {
        use crate::scrollback::blocks::SessionEvent;
        use crate::scrollback::blocks::tool::{HookRunEntry, HookRunStatus};
        let entries = || {
            vec![HookRunEntry {
                name: "h".into(),
                status: HookRunStatus::Success {
                    elapsed: std::time::Duration::from_millis(1),
                },
                output: None,
            }]
        };

        let mut state = ScrollbackState::new();
        // A parked marker renders mid-turn — it must never accept hooks, at
        // the lookup and at the mutation site alike.
        let mut parked_block =
            crate::scrollback::blocks::SessionEventBlock::new(SessionEvent::TurnCompleted {
                elapsed: Some(std::time::Duration::from_secs(1)),
            });
        parked_block.parked = true;
        let parked = state.push_block(RenderBlock::SessionEvent(parked_block));
        assert_eq!(state.latest_turn_marker_accepting("stop", None), None);
        assert!(!state.attach_stop_hooks_to_marker(parked, "stop".into(), entries(), None));

        let marker = state.push_block(RenderBlock::session_event(SessionEvent::TurnCompleted {
            elapsed: Some(std::time::Duration::from_secs(2)),
        }));
        // An unstamped marker can't confirm a stamped batch — refused; an
        // unstamped batch keeps the tail-only heuristic.
        assert_eq!(
            state.latest_turn_marker_accepting("stop", Some("pid-a")),
            None
        );
        assert!(!state.attach_stop_hooks_to_marker(
            marker,
            "stop".into(),
            entries(),
            Some("pid-a")
        ));
        assert_eq!(
            state.latest_turn_marker_accepting("stop", None),
            Some(marker)
        );
        assert!(state.attach_stop_hooks_to_marker(marker, "stop".into(), entries(), None));

        // Same-name repeat is refused; a new event name is accepted.
        assert_eq!(state.latest_turn_marker_accepting("stop", None), None);
        assert_eq!(
            state.latest_turn_marker_accepting("stop_failure", None),
            Some(marker)
        );
    }

    #[test]
    fn subagent_marker_refresh_does_not_mutate_terminal_marker() {
        use crate::scrollback::blocks::{EndWork, SessionEvent, SessionEventBlock};

        let mut state = ScrollbackState::new();
        let mut block = SessionEventBlock::new(SessionEvent::TurnCompleted {
            elapsed: Some(std::time::Duration::from_secs(1)),
        });
        block.prompt_id = Some("p1".into());
        let marker = state.push_block(RenderBlock::SessionEvent(block));

        assert!(!state.refresh_parked_subagent_marker(
            marker,
            "p1",
            std::time::Duration::from_secs(2),
            EndWork {
                running_subagents: 1,
                ..EndWork::default()
            },
        ));

        let RenderBlock::SessionEvent(block) = &state.get_by_id(marker).unwrap().block else {
            panic!("expected terminal marker");
        };
        assert_eq!(block.marker_text(), "Worked for 1.0s.");
        assert!(block.end_work.is_none());
    }

    #[test]
    fn stop_hooks_respect_marker_prompt_id() {
        use crate::scrollback::blocks::tool::{HookRunEntry, HookRunStatus};
        use crate::scrollback::blocks::{SessionEvent, SessionEventBlock};
        let entries = || {
            vec![HookRunEntry {
                name: "h".into(),
                status: HookRunStatus::Success {
                    elapsed: std::time::Duration::from_millis(1),
                },
                output: None,
            }]
        };

        let mut state = ScrollbackState::new();
        let marker = state.push_block(RenderBlock::SessionEvent(
            SessionEventBlock::with_stop_hooks(
                SessionEvent::TurnCompleted {
                    elapsed: Some(std::time::Duration::from_secs(2)),
                },
                Vec::new(),
                Some("pid-new".into()),
            ),
        ));

        // A batch stamped with another turn's pid is refused even though the
        // marker has no same-name group; an unstamped (legacy) batch and a
        // matching pid are accepted.
        assert_eq!(
            state.latest_turn_marker_accepting("stop", Some("pid-old")),
            None
        );
        assert!(!state.attach_stop_hooks_to_marker(
            marker,
            "stop".into(),
            entries(),
            Some("pid-old")
        ));
        assert_eq!(
            state.latest_turn_marker_accepting("stop", None),
            Some(marker)
        );
        assert_eq!(
            state.latest_turn_marker_accepting("stop", Some("pid-new")),
            Some(marker)
        );
        assert!(state.attach_stop_hooks_to_marker(
            marker,
            "stop".into(),
            entries(),
            Some("pid-new")
        ));
    }

    #[test]
    fn stop_hooks_merge_walks_past_interleaved_tail_blocks() {
        use crate::scrollback::blocks::{SessionEvent, SessionEventBlock};

        let mut state = ScrollbackState::new();
        let marker = state.push_block(RenderBlock::SessionEvent(
            SessionEventBlock::with_stop_hooks(
                SessionEvent::TurnCompleted {
                    elapsed: Some(std::time::Duration::from_secs(2)),
                },
                Vec::new(),
                Some("pid-new".into()),
            ),
        ));
        // A block lands between the marker and the batch (compaction, recap,
        // a previous batch's standalone fallback, …).
        state.push_block(RenderBlock::session_event(
            SessionEvent::CompactionCompleted {
                tokens_before: Some(100),
                tokens_after: 10,
                elapsed_ms: Some(5),
            },
        ));

        // An exact pid match merges across the interleaved block; an
        // unstamped batch can't be attributed off-tail and a foreign pid is
        // refused outright.
        assert_eq!(
            state.latest_turn_marker_accepting("stop", Some("pid-new")),
            Some(marker)
        );
        assert_eq!(state.latest_turn_marker_accepting("stop", None), None);
        assert_eq!(
            state.latest_turn_marker_accepting("stop", Some("pid-old")),
            None
        );

        // The walk never skips past a newer turn-terminal marker: the batch
        // belongs to the latest turn or to nothing.
        state.push_block(RenderBlock::SessionEvent(
            SessionEventBlock::with_stop_hooks(
                SessionEvent::TurnCompleted {
                    elapsed: Some(std::time::Duration::from_secs(3)),
                },
                Vec::new(),
                Some("pid-newer".into()),
            ),
        ));
        assert_eq!(
            state.latest_turn_marker_accepting("stop", Some("pid-new")),
            None
        );
    }

    #[test]
    fn stamped_stop_hooks_cross_parked_marker_to_their_turns_marker() {
        use crate::scrollback::blocks::tool::{HookRunEntry, HookRunStatus};
        use crate::scrollback::blocks::{SessionEvent, SessionEventBlock};
        let entries = || {
            vec![HookRunEntry {
                name: "h".into(),
                status: HookRunStatus::Success {
                    elapsed: std::time::Duration::from_millis(1),
                },
                output: None,
            }]
        };

        // A prior turn's settled marker, then the current turn's parked
        // EndLine at the tail (viewer/reattach shape).
        let mut state = ScrollbackState::new();
        let prior = state.push_block(RenderBlock::SessionEvent(
            SessionEventBlock::with_stop_hooks(
                SessionEvent::TurnCompleted {
                    elapsed: Some(std::time::Duration::from_secs(2)),
                },
                Vec::new(),
                Some("pid-a".into()),
            ),
        ));
        let mut parked_block = SessionEventBlock::new(SessionEvent::TurnCompleted {
            elapsed: Some(std::time::Duration::from_secs(1)),
        });
        parked_block.parked = true;
        parked_block.prompt_id = Some("pid-b".into());
        let parked = state.push_block(RenderBlock::SessionEvent(parked_block));

        // A late batch stamped for the PRIOR turn crosses the parked marker
        // (pid-exact attribution cannot misattach) and merges into its own
        // turn's marker; the parked marker itself is untouched.
        assert_eq!(
            state.latest_turn_marker_accepting("stop", Some("pid-a")),
            Some(prior)
        );
        assert!(state.attach_stop_hooks_to_marker(prior, "stop".into(), entries(), Some("pid-a")));
        match &state.get_by_id(parked).unwrap().block {
            RenderBlock::SessionEvent(b) => {
                assert!(b.parked);
                assert!(b.stop_hooks.is_empty(), "the parked marker stays clean");
            }
            other => panic!("expected the parked marker, got {other:?}"),
        }

        // The parked turn's own pid still never accepts (its Stop hooks
        // cannot have fired yet), and an unstamped positional batch stops at
        // the parked tail marker as before.
        assert_eq!(
            state.latest_turn_marker_accepting("stop_failure", Some("pid-b")),
            None
        );
        assert_eq!(
            state.latest_turn_marker_accepting("stop_failure", None),
            None
        );
    }

    /// A finished user `!` command expands to its full output; a Collapsed
    /// entry keeps its fold (no snap-open at completion).
    #[test]
    fn bash_execute_expands_on_finish_unless_user_collapsed() {
        use crate::scrollback::blocks::tool::{ExecuteToolCallBlock, ToolCallBlock};

        let bash_block = || {
            let mut b = ExecuteToolCallBlock::new("pytest");
            b.bash_mode = true;
            RenderBlock::ToolCall(ToolCallBlock::Execute(
                b.with_output("lots\nof\ntest\noutput\nlines\nhere\n"),
            ))
        };

        // Untouched streaming block: Truncated -> Expanded on finish.
        let mut state = ScrollbackState::new();
        let id = state.push_block(bash_block());
        state.set_last_running(true);
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Truncated,
            "user bash streams truncated"
        );
        state.finish_running(id);
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Expanded,
            "finished user bash must show full output"
        );

        // User collapsed mid-run: stays collapsed at completion.
        let id2 = state.push_block(bash_block());
        state.set_last_running(true);
        if let Some(e) = state.get_by_id_mut(id2) {
            e.display_mode = DisplayMode::Collapsed; // manual fold
        }
        state.finish_running(id2);
        assert_eq!(
            state.get_by_id(id2).unwrap().display_mode,
            DisplayMode::Collapsed,
            "a mid-run manual collapse must not snap open on finish"
        );
    }

    // ── Animation gating: off-screen running entries must not redraw ────

    /// A running entry scrolled far out of the viewport must not demand
    /// animation ticks or redraws — the wave accent can't be seen, and
    /// 30fps redraws of a static screen were the dominant idle-CPU cost
    /// (e.g. a background task entry left running while reading elsewhere).
    #[test]
    fn offscreen_running_entry_needs_no_animation() {
        let mut state = ScrollbackState::new();
        let first = state.push_block(stub_block("running-entry"));
        state.set_last_running(true);
        assert_eq!(state.index_of_id(first), Some(0));
        for i in 0..200 {
            state.push_block(stub_block(&format!("filler {i}")));
        }

        // Before any layout exists, stay conservative: animate.
        assert!(
            state.needs_animation(),
            "no layout yet — must conservatively animate"
        );

        // Bottom-pinned viewport (follow mode default): entry 0 is far above.
        state.prepare_layout(80, 10);
        assert!(
            state.scroll_offset() > 0,
            "follow mode pins the viewport to the bottom"
        );
        assert!(
            !state.needs_animation(),
            "running entry far above the viewport must not demand ticks"
        );
        assert!(
            !state.tick(),
            "tick with only off-screen running entries must not redraw"
        );

        // Scrolled back to the top: the running entry is visible again.
        state.scroll_up(u16::MAX);
        state.prepare_layout(80, 10);
        assert_eq!(state.scroll_offset(), 0, "scrolled to the very top");
        assert!(
            state.needs_animation(),
            "running entry inside the viewport demands ticks again"
        );
        assert!(state.tick(), "visible running entry redraws per tick");
    }

    /// `finish_running` tracks the finish-flash in the O(flashing) list:
    /// ticks keep flowing (and redraw) while the flash is active, emit one
    /// final repaint on expiry, then the list drains and animation stops.
    #[test]
    fn finish_flash_is_tracked_and_drained() {
        let mut state = ScrollbackState::new();
        let id = state.push_block(stub_block("tool"));
        state.set_last_running(true);
        state.prepare_layout(80, 10);
        assert!(state.needs_animation());

        state.finish_running(id);
        assert!(state.running.is_empty());
        assert_eq!(state.flashing.len(), 1, "finish tracked for the flash");
        assert!(
            !state.needs_animation(),
            "flash alone must not demand ticks (compat: no self-driven metronome)"
        );
        assert!(state.tick(), "flash animates while ticks flow anyway");

        // Sleep past FINISH_FLASH_DURATION_MS: the next tick repaints once
        // (restoring the static accent) and drains the tracking list.
        std::thread::sleep(std::time::Duration::from_millis(
            FINISH_FLASH_DURATION_MS + 50,
        ));
        assert!(state.tick(), "one final repaint when the flash expires");
        assert!(state.flashing.is_empty(), "expired flash is drained");
        assert!(!state.needs_animation(), "nothing left to animate");
        assert!(!state.tick(), "and ticks stop redrawing");
    }

    /// Rewound/removed entries can't strand ids in the flash list.
    #[test]
    fn finish_flash_drops_removed_entries() {
        let mut state = ScrollbackState::new();
        let id = state.push_block(stub_block("tool"));
        state.set_last_running(true);
        state.finish_running(id);
        assert_eq!(state.flashing.len(), 1);
        state.remove_entry(id);
        state.tick();
        assert!(
            state.flashing.is_empty(),
            "removed entry dropped from flash"
        );
    }

    // ── Off-screen render-cache eviction ─────────────────────────────────

    /// Entries far outside the viewport lose their cached render output
    /// (the dominant long-session allocation); entries in the keep zone and
    /// the layout (heights/scroll) are untouched. Evicted entries re-render
    /// on demand, so a follow-up sweep after re-caching finds them again.
    #[test]
    fn evict_offscreen_render_caches_sweeps_far_entries_only() {
        let mut state = ScrollbackState::new();
        let n = 400usize;
        for i in 0..n {
            state.push_block(stub_block(&format!("entry {i}")));
        }
        // Bottom-pinned viewport: the measurement window is near the tail.
        state.prepare_layout(80, 10);
        let max_offset_before = state.max_scroll_offset();

        // Populate every entry's render cache (as a fully-scrolled-through
        // live session would).
        let appearance = state.appearance().clone();
        for (_, entry) in state.entries.iter() {
            entry.ensure_cached(78, &appearance, false, None);
        }

        let evicted = state.evict_offscreen_render_caches();
        assert!(
            evicted > 0,
            "far-above entries must be swept (got {evicted})"
        );
        // The keep zone is the measurement window + EVICT_KEEP_MARGIN_ENTRIES
        // on each side — everything else (the bulk of 400 entries) is swept.
        assert!(
            evicted >= n - (EVICT_KEEP_MARGIN_ENTRIES + MEASURE_MARGIN_ENTRIES + 20),
            "sweep must reclaim the bulk of off-screen entries (got {evicted})"
        );

        // Layout untouched: same heights, same scroll geometry.
        state.prepare_layout(80, 10);
        assert_eq!(
            state.max_scroll_offset(),
            max_offset_before,
            "eviction must not change layout geometry"
        );

        // A second sweep with nothing re-rendered finds nothing to do.
        assert_eq!(state.evict_offscreen_render_caches(), 0);
    }

    #[test]
    fn test_push_and_selection() {
        let mut state = ScrollbackState::new();
        state.push_block(stub_block("one"));
        state.push_block(stub_block("two"));
        state.push_block(stub_block("three"));

        assert_eq!(state.len(), 3);
        assert_eq!(state.selected(), None);

        state.select_next();
        assert_eq!(state.selected(), Some(0));

        state.select_next();
        assert_eq!(state.selected(), Some(1));

        state.select_prev();
        assert_eq!(state.selected(), Some(0));
    }

    /// `fresh_continuation` shares the id space with its source, and
    /// `append_entries_from` merges a sibling's entries below the existing
    /// content with ids (and the running set) intact.
    #[test]
    fn fresh_continuation_and_append_share_id_space() {
        let mut original = ScrollbackState::new();
        let kept = original.push_block(stub_block("kept"));

        let mut staging = original.fresh_continuation();
        assert!(staging.is_empty());
        let tail_id = staging.push_block(stub_block("tail"));
        assert_ne!(tail_id, kept, "continuation must not reuse existing ids");
        staging.set_last_running(true);

        original.append_entries_from(staging);
        assert_eq!(original.len(), 2);
        assert_eq!(original.index_of_id(kept), Some(0));
        assert_eq!(original.index_of_id(tail_id), Some(1));
        assert!(
            original.get_by_id(tail_id).unwrap().is_running,
            "running state survives the merge"
        );
        let next = original.push_block(stub_block("after"));
        assert!(
            next != kept && next != tail_id,
            "post-merge allocation continues past both id ranges"
        );
    }

    /// `raise_id_floor` prevents a restored stash from re-issuing ids a
    /// discarded continuation sibling already handed out.
    #[test]
    fn raise_id_floor_skips_ids_allocated_by_discarded_sibling() {
        let mut stash = ScrollbackState::new();
        stash.push_block(stub_block("old"));

        let mut discarded = stash.fresh_continuation();
        let sibling_id = discarded.push_block(stub_block("partial replay"));

        stash.raise_id_floor(discarded.id_floor());
        let new_id = stash.push_block(stub_block("new"));
        assert_ne!(
            new_id, sibling_id,
            "restored state must not alias ids the sibling allocated"
        );
    }

    /// The invalidation generations never regress across a continuation swap,
    /// a failure restore, or a merge — consumers (link map, search index)
    /// cache them and compare by EQUALITY, so a regressed-equal counter would
    /// read stale state as fresh.
    #[test]
    fn continuation_swaps_never_regress_invalidation_generations() {
        let mut original = ScrollbackState::new();
        original.push_block(stub_block("kept"));
        let orig = original.invalidation_generations();

        // Swap-in (begin window): staging reads as newer than the source.
        let staging = original.fresh_continuation();
        let staged = staging.invalidation_generations();
        assert!(staged.0 > orig.0 && staged.1 > orig.1);

        // Failure restore: the stash advances past the discarded staging.
        original.raise_invalidation_floor(staged);
        let restored = original.invalidation_generations();
        assert!(restored.0 > staged.0 && restored.1 > staged.1);

        // Merge: the kept stash advances past the consumed tail.
        let mut base = ScrollbackState::new();
        base.push_block(stub_block("kept"));
        let mut tail = base.fresh_continuation();
        tail.push_block(stub_block("tail"));
        let tail_gens = tail.invalidation_generations();
        base.append_entries_from(tail);
        let merged = base.invalidation_generations();
        assert!(merged.0 > tail_gens.0 && merged.1 > tail_gens.1);
    }

    /// User view preferences survive a continuation swap, matching
    /// [`clear`](ScrollbackState::clear) — a reload must not reset them.
    #[test]
    fn fresh_continuation_preserves_view_preferences() {
        let mut original = ScrollbackState::new();
        original.thinking_display_mode = DisplayMode::Expanded;
        original.view_mode = ViewMode::SingleTurn;
        original.follow_mode = false;

        let fresh = original.fresh_continuation();
        assert_eq!(fresh.thinking_display_mode, DisplayMode::Expanded);
        assert_eq!(fresh.view_mode, ViewMode::SingleTurn);
        assert!(!fresh.follow_mode);
    }

    #[test]
    fn test_turn_detection() {
        let mut state = ScrollbackState::new();
        state.push_block(user_block("Hello"));
        state.push_block(stub_block("Response 1"));
        state.push_block(stub_block("Response 2"));
        state.push_block(user_block("Next question"));
        state.push_block(stub_block("Response 3"));

        assert_eq!(state.turn_count(), 2);

        let turn0 = state.turn(0).unwrap();
        assert_eq!(turn0.prompt_index, 0);
        assert_eq!(turn0.end_index, 3);
        assert_eq!(turn0.len(), 3);

        let turn1 = state.turn(1).unwrap();
        assert_eq!(turn1.prompt_index, 3);
        assert_eq!(turn1.end_index, 5);
        assert_eq!(turn1.len(), 2);
    }

    #[test]
    fn test_turn_navigation() {
        let mut state = ScrollbackState::new();
        state.push_block(user_block("Q1"));
        state.push_block(stub_block("A1"));
        state.push_block(user_block("Q2"));
        state.push_block(stub_block("A2"));

        assert_eq!(state.current_turn(), Some(1)); // Last turn by default

        assert!(state.prev_turn());
        assert_eq!(state.current_turn(), Some(0));
        assert_eq!(state.selected(), Some(0)); // Jumped to turn 0's prompt

        assert!(state.next_turn());
        assert_eq!(state.current_turn(), Some(1));
        assert_eq!(state.selected(), Some(2)); // Jumped to turn 1's prompt

        // At last turn, l re-activates it (scrolls prompt to top)
        assert!(state.next_turn());
        assert_eq!(state.current_turn(), Some(1));
        assert_eq!(state.selected(), Some(2)); // Still on turn 1's prompt
    }

    #[test]
    fn test_pinned_prompt_index() {
        let mut state = ScrollbackState::new();
        state.push_block(user_block("Question 1"));
        state.push_block(stub_block("Answer 1"));
        state.push_block(user_block("Question 2"));
        state.push_block(stub_block("Answer 2"));

        state.set_viewport_height(20);
        state.set_total_height(50);

        // Select first prompt (turn 0)
        state.set_selected(Some(0));
        assert_eq!(state.current_turn(), Some(0));

        // At scroll_offset = 0, no pinning
        assert_eq!(state.scroll_offset, 0);
        assert_eq!(state.pinned_prompt_index(), None);

        // At scroll_offset = 1, pinning starts
        state.scroll_down(1);
        assert_eq!(state.scroll_offset, 1);
        assert_eq!(state.pinned_prompt_index(), Some(0));

        // Scroll more, still pinned
        state.scroll_down(10);
        assert_eq!(state.pinned_prompt_index(), Some(0));

        // Go back to top, no pinning
        state.goto_top();
        assert_eq!(state.pinned_prompt_index(), None);
    }

    #[test]
    fn test_cached_height_respects_appearance_vpad() {
        use crate::appearance::AppearanceConfig;

        // Create state with vpad=false for prompt blocks
        let mut state = ScrollbackState::new();
        let mut appearance = AppearanceConfig::default();
        appearance.scrollback.blocks.prompt.vpad = false;
        state.set_appearance(appearance);

        // Push a user prompt (1 line of content)
        state.push_block(user_block("Hello"));

        // Prepare layout to populate the cache
        state.prepare_layout(80, 20);

        // Get cached height - should be 1 (content only, no vpad)
        // With vpad=true (default), it would be 3 (1 content + 2 vpad)
        let cached_height = state.get_cached_entry_height(0).unwrap();
        assert_eq!(
            cached_height, 1,
            "Cached height should be 1 (no vpad) but got {}",
            cached_height
        );
    }

    #[test]
    fn test_cached_height_with_default_appearance_has_vpad() {
        // Default appearance has vpad=true for prompt blocks
        let mut state = ScrollbackState::new();

        // Push a user prompt (1 line of content)
        state.push_block(user_block("Hello"));

        // Prepare layout to populate the cache
        state.prepare_layout(80, 20);

        // Get cached height - should be 3 (1 content + 2 vpad)
        let cached_height = state.get_cached_entry_height(0).unwrap();
        assert_eq!(
            cached_height, 3,
            "Cached height should be 3 (with vpad) but got {}",
            cached_height
        );
    }

    #[test]
    fn test_push_chunk_to_agent() {
        let mut state = ScrollbackState::new();

        // Push an empty agent message (streaming mode)
        let id = state.push_block(RenderBlock::agent_message_streaming());

        // Initially empty
        if let Some(entry) = state.get_by_id(id)
            && let RenderBlock::AgentMessage(msg) = &entry.block
        {
            assert!(msg.text().is_empty());
        }

        // Push a chunk
        assert!(state.push_chunk_to_agent(id, "Hello "));
        assert!(state.push_chunk_to_agent(id, "world!"));

        // Verify content was appended
        if let Some(entry) = state.get_by_id(id) {
            if let RenderBlock::AgentMessage(msg) = &entry.block {
                assert_eq!(msg.text(), "Hello world!");
            } else {
                panic!("Expected agent message");
            }
        } else {
            panic!("Entry not found");
        }

        // Verify the entry is marked as having dirty height
        assert!(
            state.dirty_heights.contains(&id),
            "Entry should be in dirty_heights after push_chunk"
        );
    }

    #[test]
    fn test_push_chunk_to_nonexistent_entry() {
        let mut state = ScrollbackState::new();

        // Try to push to a non-existent entry
        let fake_id = EntryId::new(999);
        assert!(!state.push_chunk_to_agent(fake_id, "test"));
    }

    #[test]
    fn test_push_chunk_to_wrong_type() {
        let mut state = ScrollbackState::new();

        // Push a user prompt (not an agent message)
        let id = state.push_block(RenderBlock::user_prompt("Hello"));

        // Try to push chunk - should fail silently
        assert!(!state.push_chunk_to_agent(id, "test"));
    }

    #[test]
    fn test_dirty_heights_cleared_after_prepare_layout() {
        let mut state = ScrollbackState::new();

        // Push an agent message and prepare initial layout
        let id = state.push_block(RenderBlock::agent_message_streaming());
        state.prepare_layout(80, 20);

        // Verify dirty_heights is clear after prepare_layout
        assert!(
            state.dirty_heights.is_empty(),
            "dirty_heights should be empty after prepare_layout"
        );

        // Push a chunk - should mark as dirty
        state.push_chunk_to_agent(id, "Hello");
        assert!(
            state.dirty_heights.contains(&id),
            "Entry should be dirty after push_chunk"
        );

        // Prepare layout again - should clear dirty_heights
        state.prepare_layout(80, 20);
        assert!(
            state.dirty_heights.is_empty(),
            "dirty_heights should be empty after second prepare_layout"
        );
    }

    #[test]
    fn test_content_generation_bumps_on_content_changes() {
        let mut state = ScrollbackState::new();
        let mut last = state.content_generation();

        let id = state.push_block(stub_block("one"));
        assert!(state.content_generation() > last, "push bumps");
        last = state.content_generation();

        let agent = state.start_streaming_agent();
        assert!(state.content_generation() > last, "streaming push bumps");
        last = state.content_generation();

        state.push_chunk_to_agent(agent, "hello");
        assert!(state.content_generation() > last, "streamed chunk bumps");
        last = state.content_generation();

        state.remove_entry(id);
        assert!(state.content_generation() > last, "remove_entry bumps");
        last = state.content_generation();

        state.clear();
        assert!(state.content_generation() > last, "clear bumps");
    }

    #[test]
    fn test_content_generation_unchanged_by_display_toggles() {
        let mut state = ScrollbackState::new();
        state.push_block(RenderBlock::execute_with_output(
            "cargo test",
            "output",
            None::<String>,
        ));
        state.prepare_layout(80, 10);
        state.set_selected(Some(0));

        let content_gen = state.content_generation();
        let link_gen = state.generation();

        // Fold and raw-mode change display, not the searchable corpus: the
        // link-map generation moves, content_generation must hold steady.
        state.collapse_all();
        state.expand_all();
        state.toggle_raw_selected();

        assert_eq!(
            state.content_generation(),
            content_gen,
            "display toggles must not change content_generation"
        );
        assert!(
            state.generation() > link_gen,
            "display toggles still bump the link-map generation"
        );
    }

    #[test]
    fn test_content_generation_unchanged_by_scroll_and_resize() {
        let mut state = ScrollbackState::new();
        for i in 0..50 {
            state.push_block(stub_block(&format!("entry {i}")));
        }
        state.prepare_layout(80, 10);

        let content_gen = state.content_generation();
        let link_gen = state.generation();

        // Scroll/resize moves the viewport, not the corpus: link-map generation moves, content_generation must not.
        state.scroll_up(3);
        state.scroll_down(2);
        state.goto_top();
        state.goto_bottom();
        state.set_scroll_offset(1);
        state.prepare_layout(100, 12);

        assert_eq!(
            state.content_generation(),
            content_gen,
            "scroll/resize must not change content_generation"
        );
        assert!(
            state.generation() > link_gen,
            "scroll/resize should still bump the link-map generation"
        );
    }

    #[test]
    fn test_iter_entries_yields_all_in_order() {
        let mut state = ScrollbackState::new();
        let id0 = state.push_block(stub_block("zero"));
        let id1 = state.push_block(stub_block("one"));
        let id2 = state.push_block(stub_block("two"));

        let ids: Vec<EntryId> = state.iter_entries().map(|(id, _)| id).collect();
        assert_eq!(ids, vec![id0, id1, id2]);

        for (id, entry) in state.iter_entries() {
            assert_eq!(entry.id, id, "each entry is paired with its own id");
        }
        assert_eq!(state.iter_entries().count(), 3);
    }

    #[test]
    fn test_get_by_id_is_o1() {
        let mut state = ScrollbackState::new();

        // Push several entries
        let id1 = state.push_block(stub_block("one"));
        let id2 = state.push_block(stub_block("two"));
        let id3 = state.push_block(stub_block("three"));

        // Verify O(1) lookup by ID works (not testing performance, just correctness)
        assert!(state.get_by_id(id1).is_some());
        assert!(state.get_by_id(id2).is_some());
        assert!(state.get_by_id(id3).is_some());

        // Get index of each ID
        assert_eq!(state.index_of_id(id1), Some(0));
        assert_eq!(state.index_of_id(id2), Some(1));
        assert_eq!(state.index_of_id(id3), Some(2));
    }

    /// Total height computed via `compute_total_height_from_cache` after the
    /// next prepare_layout must include the newly pushed entry.
    #[test]
    fn test_push_then_prepare_layout_updates_total_height() {
        let mut state = ScrollbackState::new();
        state.push_block(stub_block("a"));
        state.prepare_layout(80, 20);
        let total_before = state.total_height;

        state.push_block(stub_block("b"));
        // No prepare_layout yet -- total_height is stale (matches old behavior).
        // The next prepare_layout must reconcile.
        state.prepare_layout(80, 20);
        assert!(
            state.total_height > total_before,
            "total_height must include the new entry after the next prepare_layout: \
             before={total_before}, after={}",
            state.total_height
        );
    }

    /// In batch mode, the cache should still be nullified (existing behavior)
    /// and the bulk rebuild should happen at end_batch. We're not regressing
    /// the batch path.
    #[test]
    fn test_push_in_batch_still_nullifies_cache() {
        let mut state = ScrollbackState::new();
        state.push_block(stub_block("a"));
        state.prepare_layout(80, 20);
        assert!(state.layout_cache.is_some());

        state.begin_batch();
        state.push_block(stub_block("b"));
        assert!(
            state.layout_cache.is_none(),
            "batch mode should null the cache for safety"
        );
        state.end_batch();

        // After end_batch, the next prepare_layout rebuilds the cache.
        state.prepare_layout(80, 20);
        assert!(state.layout_cache.is_some());
        assert_eq!(state.layout_cache.as_ref().unwrap().entries.len(), 2);
    }

    /// Pushing into an empty state (no cache yet) should fall through to
    /// invalidate_layout_cache (a no-op, since cache is already None) and not
    /// crash trying to extend a nonexistent cache.
    #[test]
    fn test_push_into_empty_state_with_no_cache() {
        let mut state = ScrollbackState::new();
        // Cache is None initially.
        assert!(state.layout_cache.is_none());

        // Push without ever calling prepare_layout. extend should fail
        // gracefully (returns false), invalidate is a no-op.
        let _id = state.push_block(stub_block("a"));
        assert!(state.layout_cache.is_none());
        // gaps_may_be_dirty is set by the fallback path.
        assert!(state.gaps_may_be_dirty);

        // First prepare_layout builds the cache from scratch.
        state.prepare_layout(80, 20);
        assert!(state.layout_cache.is_some());
        assert_eq!(state.layout_cache.as_ref().unwrap().entries.len(), 1);
    }

    #[test]
    fn test_mark_height_dirty() {
        let mut state = ScrollbackState::new();
        let id = state.push_block(stub_block("test"));

        // Clear dirty heights
        state.prepare_layout(80, 20);
        assert!(state.dirty_heights.is_empty());

        // Manually mark as dirty
        state.mark_height_dirty(id);
        assert!(state.dirty_heights.contains(&id));
    }

    #[test]
    fn set_pending_user_input_toggles_flag_and_reports_change() {
        let mut state = ScrollbackState::new();
        let id = state.push_block(stub_block("waiting"));

        // First flip from default false → true reports a change.
        assert!(state.set_pending_user_input(id, true));
        assert!(state.get_by_id(id).unwrap().is_pending_user_input);
        assert!(state.has_pending_user_input());

        // Repeated set with the same value is a no-op.
        assert!(!state.set_pending_user_input(id, true));

        // Flip back, then clear-all wipes the mark.
        assert!(state.set_pending_user_input(id, false));
        assert!(!state.has_pending_user_input());

        state.set_pending_user_input(id, true);
        state.clear_all_pending_user_input();
        assert!(!state.has_pending_user_input());
        assert!(!state.get_by_id(id).unwrap().is_pending_user_input);

        // Unknown id is silently a no-op (returns false, no panic).
        let missing = EntryId::new(99999);
        assert!(!state.set_pending_user_input(missing, true));
    }

    #[test]
    fn mark_completed_clears_pending_user_input() {
        // A finishing tool must drop its pending mark — otherwise a tool
        // that completes between two render frames would keep pulsing
        // forever after AgentView's next sync clears the queue entry.
        let mut entry = ScrollbackEntry::running(stub_block("tool"));
        entry.is_pending_user_input = true;

        entry.mark_completed();

        assert!(!entry.is_running);
        assert!(!entry.is_pending_user_input);
    }

    #[test]
    fn user_expanded_running_thinking_stays_expanded_on_finish() {
        let mut state = ScrollbackState::new();
        let id = state.push_block(RenderBlock::thinking_streaming());
        state.set_last_running(true);
        state.push_chunk_to_thinking(id, "deep thoughts");
        state.prepare_layout(80, 40);

        state.set_selected(Some(0));
        state.toggle_fold_selected();
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Expanded
        );

        state.finish_running(id);

        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Expanded
        );
    }

    #[test]
    fn untouched_running_thinking_collapses_on_finish() {
        let mut state = ScrollbackState::new();
        let id = state.push_block(RenderBlock::thinking_streaming());
        state.set_last_running(true);
        state.push_chunk_to_thinking(id, "deep thoughts");
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Truncated
        );

        state.finish_running(id);

        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Collapsed
        );
    }

    #[test]
    fn running_thinking_toggled_back_to_truncated_collapses_on_finish() {
        let mut state = ScrollbackState::new();
        let id = state.push_block(RenderBlock::thinking_streaming());
        state.set_last_running(true);
        state.push_chunk_to_thinking(id, "deep thoughts");
        state.prepare_layout(80, 40);

        state.set_selected(Some(0));
        state.toggle_fold_selected();
        state.toggle_fold_selected();
        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Truncated
        );

        state.finish_running(id);

        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Collapsed
        );
    }

    #[test]
    fn sticky_expanded_mode_still_expands_untouched_thinking_on_finish() {
        let mut state = ScrollbackState::new();
        let done = state.push_block(RenderBlock::thinking("earlier thoughts"));
        state.get_by_id_mut(done).unwrap().display_mode = DisplayMode::Collapsed;
        state.expand_all_thinking();

        let id = state.push_block(RenderBlock::thinking_streaming());
        state.set_last_running(true);
        state.push_chunk_to_thinking(id, "deep thoughts");

        state.finish_running(id);

        assert_eq!(
            state.get_by_id(id).unwrap().display_mode,
            DisplayMode::Expanded
        );
    }
}
