Files
Kigi-CLI/crates/codegen/kigi-tui/src/scrollback/state/mod.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

3306 lines
129 KiB
Rust

//! 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 — grok-shell sends full accumulated buffer each tick.
// The shell now sends clean output (no ANSI codes) when the client sets
// x.ai/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
);
}
}