//! Verb-group aggregation: the "Read 10 files, Ran 2 subagents" header
//! label for a folded run of consecutive non-destructive tool calls and
//! subagent lifecycle rows, plus any finished collapsed thoughts the run
//! claims. Also home of the run classification ([`run_step`]) shared by the
//! layout fold, range resolution, and the label walk.
//!
//! The layout pass in `state/layout.rs` detects the runs and marks the header
//! via `EntryLayoutInfo::verb_group_header`; the render loop calls
//! [`verb_group_header_label`] to build the live label each frame (running
//! entries repaint every tick, so tense and counts update in place — no
//! per-call detail churns beside the label while the run executes).
//!
//! The same bucket vocabulary labels group-truncation ("N more") headers:
//! the render loop calls [`truncation_header_label`] with the fold's span,
//! and both walks feed the shared `BucketAccumulator` so the two label
//! families can't drift.

use ratatui::style::Modifier;
use ratatui::text::{Line, Span};

use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SubagentBlockKind;
use crate::scrollback::blocks::tool::{ToolCallBlock, VerbGroupKind};
use crate::scrollback::entry::ScrollbackEntry;
use crate::scrollback::types::DisplayMode;
use crate::theme::Theme;

/// One step of a verb-group run walk.
pub(crate) enum RunStep {
    /// A collapsed verb-groupable tool or subagent entry: joins the run and
    /// counts toward the fold threshold ([`RunScan::folds`]).
    Member(VerbGroupKind),
    /// A finished, collapsed, shown thinking entry: claims into the run
    /// (folds to height 0) but never counts toward the threshold and never
    /// appears in the header label.
    ThoughtMember,
    /// An entry that renders its own rows (or none) without joining or
    /// breaking the run: hidden, streaming, user-opened, or chrome-carrying
    /// thinking, and a manually-opened verb-groupable tool.
    Transparent,
    /// Anything else: ends the run.
    Break,
}

/// Classify one entry for run walking — the single source of truth shared by
/// the layout fold scan, `verb_group_range_of`, and the label walk.
///
/// Members are collapsed verb-groupable tool calls and subagent lifecycle
/// rows; pending-user-input and hook-annotated rows stay standalone (their
/// prompt / `[hooks: N/M]` chrome must remain visible). A manually-opened
/// member is [`RunStep::Transparent`] — it keeps its own rows inside the run
/// instead of splitting it. Thinking never breaks a run: a finished
/// collapsed thought folds in as [`RunStep::ThoughtMember`]; hidden,
/// still-streaming, opened, or chrome-carrying thinking is
/// [`RunStep::Transparent`].
pub(crate) fn run_step(entry: &ScrollbackEntry, show_thinking: bool) -> RunStep {
    // Prompt / `[hooks: N/M]` chrome must stay visible, so chrome-carrying
    // entries never claim into a run.
    let no_chrome = !entry.is_pending_user_input && entry.hook_data.is_none();
    // Claimed entries are collapsed single-row + chromeless — the contract
    // the fold's absolute height-2 expanded slot leans on. Tools check the
    // collapsed half inline to split Member from Transparent.
    let claimable = entry.display_mode == DisplayMode::Collapsed && no_chrome;
    if let RenderBlock::ToolCall(block) = &entry.block
        && let Some(kind) = block.verb_group_kind()
        && no_chrome
    {
        if entry.display_mode == DisplayMode::Collapsed {
            RunStep::Member(kind)
        } else {
            // A manually-opened member keeps its own rows without splitting
            // the run — same treatment as opened thinking — so toggling a
            // member of an expanded group never dissolves the group.
            RunStep::Transparent
        }
    } else if matches!(entry.block, RenderBlock::Subagent(_)) {
        if claimable {
            RunStep::Member(VerbGroupKind::Subagent)
        } else {
            // Subagent rows are always collapsed single-row entries; this
            // arm only guards chrome — prompt / hook rows must stay visible,
            // so such an entry splits the run like a chrome-carrying tool.
            RunStep::Break
        }
    } else if entry.block.is_thinking() {
        if show_thinking && !entry.is_running && claimable {
            RunStep::ThoughtMember
        } else {
            RunStep::Transparent
        }
    } else {
        RunStep::Break
    }
}

/// Whether an in-place block swap changes the entry's verb-group kind (e.g.
/// the eager `Other` placeholder refining into a `Read`). Such swaps change
/// fold membership, so the swap site must mark the entry structurally dirty
/// for the layout fold to catch up on the next frame.
pub(crate) fn verb_group_kind_changed(old: &RenderBlock, new: &RenderBlock) -> bool {
    let kind_of = |block: &RenderBlock| match block {
        RenderBlock::ToolCall(tc) => tc.verb_group_kind(),
        _ => None,
    };
    kind_of(old) != kind_of(new)
}

/// Shape of one forward run walk, as reported by [`scan_run_forward`].
pub(crate) struct RunScan {
    /// Member entries counted (tool calls and subagent rows), including a
    /// member start entry. Thought members claim but never count — the fold
    /// threshold is members-only.
    pub(crate) members: usize,
    /// Exclusive run end: one past the last claimed entry (member or thought
    /// member), so trailing transparent entries stay outside the run.
    pub(crate) end: usize,
    /// Where the walk stopped: the breaking entry's index, or the first index
    /// where `entry_at` returned `None`.
    pub(crate) stop: usize,
}

impl RunScan {
    /// Whether the run folds into a verb-group header row. One member is
    /// enough — the compact label beats the member's own row, and the header
    /// appearing with the first streaming call avoids a fold-in jump when the
    /// second arrives. Members-only: thought members claim into runs but
    /// never count, so a pure-thought run (whose label would be empty) never
    /// folds. The single predicate shared by the layout fold and
    /// `verb_group_range_of` so the two can't drift.
    pub(crate) fn folds(&self) -> bool {
        self.members >= 1
    }
}

/// Walk a run forward from `start` until a breaking entry or the end of the
/// entries, and report the run's shape. Returns `None` when the entry at
/// `start` is missing or cannot anchor a run (members and thought members
/// can; transparent and breaking entries cannot) — anchor eligibility lives
/// in this function's matches, not in caller pre-checks — so a returned scan
/// always has `end > start` and `stop > start` (`members` may be 0 for a
/// thought-anchored walk with no members). The layout fold scan and
/// `verb_group_range_of` share this walk so both agree on the exact run
/// shape; the label walk needs per-member block data and stays its own loop,
/// kept in sync by its exhaustive `RunStep` match.
pub(crate) fn scan_run_forward<'e>(
    entry_at: impl Fn(usize) -> Option<&'e ScrollbackEntry>,
    start: usize,
    show_thinking: bool,
) -> Option<RunScan> {
    // Members and finished thoughts anchor runs; transparent thinking may
    // sit inside one but cannot start one.
    match run_step(entry_at(start)?, show_thinking) {
        RunStep::Member(_) | RunStep::ThoughtMember => {}
        RunStep::Transparent | RunStep::Break => return None,
    }
    let mut members = 0usize;
    let mut end = start;
    let mut i = start;
    while let Some(entry) = entry_at(i) {
        match run_step(entry, show_thinking) {
            RunStep::Member(_) => {
                members += 1;
                end = i + 1;
            }
            RunStep::ThoughtMember => end = i + 1,
            RunStep::Transparent => {}
            RunStep::Break => break,
        }
        i += 1;
    }
    Some(RunScan {
        members,
        end,
        stop: i,
    })
}

/// Aggregated header state for one verb-group run.
pub struct VerbGroupHeaderLabel {
    /// Styled label line rendered on the header row.
    pub line: Line<'static>,
    /// Plain-text label (selection/copy text for the header row).
    pub text: String,
    /// Any member still running (animated accent + present-tense verbs).
    pub running: bool,
    /// Any member failed (error accent).
    pub failed: bool,
}

/// The single channel a group-header row's aggregated label travels
/// through, mirroring the fold families of `groups::GroupKind`. A header
/// row belongs to exactly one fold, so a row carries at most one label —
/// the exclusivity is structural. The variant picks the header chrome
/// (verb-run headers wear run-state accents; truncation headers keep the
/// dimmed fold chrome); the label payload is shared.
pub enum GroupHeaderLabel {
    /// Verb-group run header ("Read 3 files, Searched 2 patterns").
    VerbRun(VerbGroupHeaderLabel),
    /// Labeled truncation ("N more") header ("Ran 6 commands").
    Truncation(VerbGroupHeaderLabel),
}

impl GroupHeaderLabel {
    /// The aggregated label payload, whichever fold family produced it.
    pub fn label(&self) -> &VerbGroupHeaderLabel {
        match self {
            GroupHeaderLabel::VerbRun(label) | GroupHeaderLabel::Truncation(label) => label,
        }
    }
}

/// Per-kind aggregation bucket, ordered by first appearance in the run.
/// Borrows citation strings from the walked blocks (per-frame, no allocation).
struct Bucket<'e> {
    kind: VerbGroupKind,
    calls: usize,
    /// Distinct-count override: when non-empty its size replaces `calls` as
    /// the displayed count. Holds WebSearch citation URLs (distinct result
    /// websites) and subagent child session ids (started + terminal rows of
    /// one subagent count once; a burst of terminal rows counts each
    /// distinct subagent).
    sources: std::collections::HashSet<&'e str>,
}

/// Walk the verb-group run starting at `header_idx` (same [`run_step`] rules
/// as the layout fold: thinking and hidden entries are skipped, anything
/// else ends the run) and build the aggregated label. The label counts
/// members only: folded thoughts contribute nothing here and surface as
/// their own member rows only when the group is expanded.
///
/// `end` is the run's exclusive upper bound in `entries` indices. Callers
/// with the fold's span (see `state::groups`) pass its exact end so the
/// label counts precisely the entries the fold claimed; callers without one
/// pass `entries.len()` and rely on the [`RunStep::Break`] arm, which is
/// kept as the in-bound stop in either case.
pub fn verb_group_header_label(
    entries: &[&ScrollbackEntry],
    header_idx: usize,
    end: usize,
    show_thinking: bool,
    theme: &Theme,
) -> VerbGroupHeaderLabel {
    let mut acc = BucketAccumulator::default();

    let end = end.min(entries.len());
    for &entry in &entries[header_idx.min(end)..end] {
        let kind = match run_step(entry, show_thinking) {
            RunStep::Member(kind) => kind,
            RunStep::Break => break,
            RunStep::ThoughtMember | RunStep::Transparent => continue,
        };
        acc.push(kind, entry);
    }

    acc.into_label(theme)
}

/// Aggregated label for a truncation ("N more") header, describing the rows
/// the fold hid — "Ran 6 commands, Read 2 files" — through the same bucket
/// vocabulary as verb-group headers.
///
/// Walks the span's participants (skipping hidden thinking exactly like the
/// fold's projection) from `range.start`, stopping after `limit`
/// participants when given — the collapsed header describes only its hidden
/// prefix; the expanded collapse header passes `None` and describes the
/// whole run. Thoughts occupy participant slots but are NEVER bucketed:
/// like verb-group labels, group labels stay tools-only. Returns `None` —
/// the caller keeps the plain "N more" count — when nothing was bucketed (a
/// pure-thought prefix) or when any walked participant has no bucket
/// (System/SessionEvent rows, lifecycle chrome): thoughts are the only
/// participants a label may silently omit, anything else would make it
/// under-describe what the fold conceals.
pub fn truncation_header_label(
    entries: &[&ScrollbackEntry],
    range: std::ops::Range<usize>,
    limit: Option<usize>,
    show_thinking: bool,
    theme: &Theme,
) -> Option<VerbGroupHeaderLabel> {
    let mut acc = BucketAccumulator::default();
    let end = range.end.min(entries.len());
    let mut participants = 0usize;

    for &entry in &entries[range.start.min(end)..end] {
        if limit.is_some_and(|n| participants >= n) {
            break;
        }
        if entry.is_hidden_thinking(show_thinking) {
            continue;
        }
        participants += 1;
        if entry.block.is_thinking() {
            continue;
        }
        match &entry.block {
            RenderBlock::ToolCall(block) => acc.push(block.label_kind()?, entry),
            RenderBlock::Subagent(_) => acc.push(VerbGroupKind::Subagent, entry),
            // A participant the vocabulary can't name would leave the label
            // dishonest about what's hidden; decline so the numerically
            // exact plain count renders instead.
            _ => return None,
        }
    }

    if acc.is_empty() {
        return None;
    }
    Some(acc.into_label(theme))
}

/// Shared bucket accumulation + label rendering for the aggregated group
/// headers. Callers own the walk (which entries join and under what
/// classification); this owns per-kind counting, distinct-source overrides,
/// failure counting, and the rendered line.
#[derive(Default)]
struct BucketAccumulator<'e> {
    buckets: Vec<Bucket<'e>>,
    running: bool,
    failed_count: usize,
}

impl<'e> BucketAccumulator<'e> {
    fn is_empty(&self) -> bool {
        self.buckets.is_empty()
    }

    fn push(&mut self, kind: VerbGroupKind, entry: &'e ScrollbackEntry) {
        let pos = match self.buckets.iter().position(|b| b.kind == kind) {
            Some(pos) => pos,
            None => {
                self.buckets.push(Bucket {
                    kind,
                    calls: 0,
                    sources: std::collections::HashSet::new(),
                });
                self.buckets.len() - 1
            }
        };
        let bucket = &mut self.buckets[pos];
        bucket.calls += 1;
        // Bucketed entries are tool-call or subagent rows by construction
        // (both walks); the block feeds the distinct-count override and
        // failure detection.
        match &entry.block {
            RenderBlock::ToolCall(block) => {
                if let ToolCallBlock::WebSearch(b) = block
                    && b.is_success()
                {
                    bucket
                        .sources
                        .extend(b.citations.iter().map(String::as_str));
                }
                if block_failed(block) {
                    self.failed_count += 1;
                }
            }
            RenderBlock::Subagent(sb) => {
                bucket.sources.insert(sb.child_session_id.as_str());
                // Cancelled is deliberate, not an error — only Failed feeds
                // the red suffix.
                if matches!(sb.kind, SubagentBlockKind::Failed { .. }) {
                    self.failed_count += 1;
                }
            }
            // Unreachable today; release keeps the generic count so the
            // label can't desync from the fold that claimed the entry.
            _ => debug_assert!(false, "bucketed entry has a block with no label-extras arm"),
        }

        if entry.is_running {
            self.running = true;
        }
    }

    fn into_label(self, theme: &Theme) -> VerbGroupHeaderLabel {
        let text_style = theme.fg(theme.gray_bright).add_modifier(Modifier::BOLD);
        let mut spans: Vec<Span<'static>> = Vec::new();
        let mut text = String::new();
        for (i, bucket) in self.buckets.iter().enumerate() {
            let count = if bucket.sources.is_empty() {
                bucket.calls
            } else {
                bucket.sources.len()
            };
            let segment = format!(
                "{}{} {} {}",
                if i == 0 { "" } else { ", " },
                bucket.kind.verb(self.running),
                count,
                bucket.kind.noun(count)
            );
            text.push_str(&segment);
            spans.push(Span::styled(segment, text_style));
        }
        if self.failed_count > 0 {
            let suffix = format!(" · {} failed", self.failed_count);
            text.push_str(&suffix);
            spans.push(Span::styled(suffix, theme.fg(theme.accent_error)));
        }

        VerbGroupHeaderLabel {
            line: Line::from(spans),
            text,
            running: self.running,
            failed: self.failed_count > 0,
        }
    }
}

/// Whether a bucketed block completed with an error. Variants are listed
/// explicitly so a new `ToolCallBlock` variant must decide here too. The
/// action kinds reach labels only through truncation buckets (verb folds
/// exclude them), where their failures count like any other member's.
fn block_failed(block: &ToolCallBlock) -> bool {
    match block {
        ToolCallBlock::Read(b) => !b.is_success(),
        ToolCallBlock::ListDir(b) => !b.is_success(),
        ToolCallBlock::Search(b) => !b.is_success(),
        ToolCallBlock::WebFetch(b) => !b.is_success(),
        ToolCallBlock::WebSearch(b) => !b.is_success(),
        ToolCallBlock::MemorySearch(b) => !b.is_success(),
        ToolCallBlock::IntegrationSearch(b) => !b.is_success(),
        ToolCallBlock::Skill(b) => !b.is_success(),
        ToolCallBlock::Execute(b) => !b.is_success(),
        ToolCallBlock::Edit(b) => !b.is_success(),
        ToolCallBlock::UseTool(b) => !b.is_success(),
        ToolCallBlock::Other(b) => !b.is_success(),
        ToolCallBlock::Lifecycle(_) => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::scrollback::blocks::SubagentBlock;
    use crate::scrollback::blocks::tool::{
        ListDirToolCallBlock, ReadToolCallBlock, SearchToolCallBlock, WebSearchToolCallBlock,
    };

    fn entry(block: ToolCallBlock) -> ScrollbackEntry {
        ScrollbackEntry::new(RenderBlock::ToolCall(block)).with_display_mode(DisplayMode::Collapsed)
    }

    fn read(path: &str) -> ScrollbackEntry {
        entry(ToolCallBlock::Read(ReadToolCallBlock::new(path)))
    }

    fn subagent(block: SubagentBlock) -> ScrollbackEntry {
        ScrollbackEntry::new(RenderBlock::Subagent(block))
    }

    fn sub_started(child_sid: &str) -> ScrollbackEntry {
        subagent(SubagentBlock::started(
            "task", child_sid, "explore", None, None, None, /*is_background=*/ true,
        ))
    }

    fn sub_completed(child_sid: &str) -> ScrollbackEntry {
        subagent(SubagentBlock::completed(
            "task",
            child_sid,
            std::time::Duration::from_secs(3),
        ))
    }

    fn label(entries: &[ScrollbackEntry]) -> VerbGroupHeaderLabel {
        let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
        verb_group_header_label(
            &refs,
            0,
            refs.len(),
            /*show_thinking=*/ true,
            &Theme::current(),
        )
    }

    #[test]
    fn buckets_in_first_appearance_order_with_plurality() {
        let entries = vec![
            read("a.rs"),
            entry(ToolCallBlock::Search(SearchToolCallBlock::new("todo"))),
            read("b.rs"),
            entry(ToolCallBlock::ListDir(ListDirToolCallBlock::new("src"))),
        ];
        let l = label(&entries);
        assert_eq!(l.text, "Read 2 files, Searched 1 pattern, Listed 1 dir");
        assert!(!l.running);
        assert!(!l.failed);
    }

    #[test]
    fn skill_reads_bucket_separately_from_files() {
        let entries = vec![
            read("a.rs"),
            read("/x/skills/deploy/SKILL.md"),
            read("b.rs"),
        ];
        let l = label(&entries);
        assert_eq!(l.text, "Read 2 files, Read 1 skill");
    }

    #[test]
    fn failed_members_append_suffix_and_flag() {
        let entries = vec![
            read("a.rs"),
            entry(ToolCallBlock::Read(
                ReadToolCallBlock::new("gone.rs").with_error("no such file"),
            )),
            entry(ToolCallBlock::Read(
                ReadToolCallBlock::new("also-gone.rs").with_error("no such file"),
            )),
        ];
        let l = label(&entries);
        assert_eq!(l.text, "Read 3 files · 2 failed");
        assert!(l.failed);
    }

    #[test]
    fn running_flips_tense_only() {
        let mut entries = vec![
            read("a.rs"),
            entry(ToolCallBlock::Search(SearchToolCallBlock::new("todo"))),
        ];
        entries[1].is_running = true;
        let l = label(&entries);
        assert_eq!(l.text, "Reading 1 file, Searching 1 pattern");
        assert!(l.running);

        entries[1].is_running = false;
        let l = label(&entries);
        assert_eq!(l.text, "Read 1 file, Searched 1 pattern");
        assert!(!l.running);
    }

    #[test]
    fn web_search_counts_distinct_sources_with_call_fallback() {
        let searched = |query: &str, citations: &[&str]| {
            let mut b = WebSearchToolCallBlock::new(query);
            b.citations = citations.iter().map(|s| s.to_string()).collect();
            b.content = Some("results".into());
            entry(ToolCallBlock::WebSearch(b))
        };
        // Three distinct URLs across two searches, one duplicated.
        let entries = vec![
            searched("kigi", &["https://a.com", "https://b.com"]),
            searched("pager", &["https://b.com", "https://c.com"]),
        ];
        let l = label(&entries);
        assert_eq!(l.text, "Searched 3 websites");

        // No citations yet (still running / no results): fall back to call count.
        let entries = vec![
            entry(ToolCallBlock::WebSearch(WebSearchToolCallBlock::new("a"))),
            entry(ToolCallBlock::WebSearch(WebSearchToolCallBlock::new("b"))),
        ];
        let l = label(&entries);
        assert_eq!(l.text, "Searched 2 websites");
    }

    #[test]
    fn run_ends_at_separator_and_skips_hidden_thinking() {
        let entries = [
            read("a.rs"),
            ScrollbackEntry::new(RenderBlock::thinking("hmm")),
            read("b.rs"),
            ScrollbackEntry::new(RenderBlock::execute("ls")),
            read("c.rs"),
        ];
        let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
        let l = verb_group_header_label(
            &refs,
            0,
            refs.len(),
            /*show_thinking=*/ false,
            &Theme::current(),
        );
        assert_eq!(l.text, "Read 2 files");
    }

    #[test]
    fn label_stays_tools_only_across_shown_thinking_states() {
        let mut streaming = ScrollbackEntry::new(RenderBlock::thinking("live"));
        streaming.is_running = true;
        let entries = [
            read("a.rs"),
            // Finished + collapsed: folds into the run, never labeled.
            ScrollbackEntry::new(RenderBlock::thinking("done"))
                .with_display_mode(DisplayMode::Collapsed),
            read("b.rs"),
            // Streaming: transparent, keeps its own live panel.
            streaming,
            read("c.rs"),
            // User-opened: transparent, keeps its own rows.
            ScrollbackEntry::new(RenderBlock::thinking("opened"))
                .with_display_mode(DisplayMode::Expanded),
            read("d.rs"),
            // Non-thinking separators still end the run.
            ScrollbackEntry::new(RenderBlock::execute("ls")),
            read("e.rs"),
        ];
        let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
        let l = verb_group_header_label(
            &refs,
            0,
            refs.len(),
            /*show_thinking=*/ true,
            &Theme::current(),
        );
        assert_eq!(l.text, "Read 4 files");
    }

    fn execute() -> ScrollbackEntry {
        ScrollbackEntry::new(RenderBlock::execute("ls")).with_display_mode(DisplayMode::Collapsed)
    }

    fn thought() -> ScrollbackEntry {
        ScrollbackEntry::new(RenderBlock::thinking("hmm")).with_display_mode(DisplayMode::Collapsed)
    }

    fn trunc_label(
        entries: &[ScrollbackEntry],
        limit: Option<usize>,
    ) -> Option<VerbGroupHeaderLabel> {
        let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
        truncation_header_label(
            &refs,
            0..refs.len(),
            limit,
            /*show_thinking=*/ true,
            &Theme::current(),
        )
    }

    #[test]
    fn truncation_label_buckets_commands_and_never_thoughts() {
        // 3 commands + 2 thoughts hidden: thoughts occupy participant slots
        // but the label stays tools-only.
        let entries = vec![execute(), thought(), execute(), thought(), execute()];
        let l = trunc_label(&entries, None).expect("commands bucket");
        assert_eq!(l.text, "Ran 3 commands");
    }

    #[test]
    fn truncation_label_limit_counts_participants_not_buckets() {
        // limit=3 covers [execute, thought, execute]: the thought consumes a
        // participant slot without appearing in the label.
        let entries = vec![execute(), thought(), execute(), execute(), execute()];
        let l = trunc_label(&entries, Some(3)).expect("prefix buckets");
        assert_eq!(l.text, "Ran 2 commands");
    }

    #[test]
    fn truncation_label_mixes_kinds_in_first_appearance_order() {
        let entries = vec![
            execute(),
            read("a.rs"),
            ScrollbackEntry::new(RenderBlock::edit("src/main.rs", None))
                .with_display_mode(DisplayMode::Collapsed),
            execute(),
        ];
        let l = trunc_label(&entries, None).expect("buckets");
        assert_eq!(l.text, "Ran 2 commands, Read 1 file, Edited 1 file");
    }

    #[test]
    fn truncation_label_none_for_pure_thought_prefix() {
        let entries = vec![thought(), thought(), execute()];
        assert!(
            trunc_label(&entries, Some(2)).is_none(),
            "a prefix of only thoughts buckets nothing; caller falls back to 'N more'"
        );
    }

    #[test]
    fn truncation_label_none_for_prefix_with_unbucketable_rows() {
        let system = ScrollbackEntry::new(RenderBlock::system("hook ran"))
            .with_display_mode(DisplayMode::Collapsed);
        let entries = vec![execute(), system, execute()];
        assert!(
            trunc_label(&entries, None).is_none(),
            "a hidden System row has no bucket; the plain count stays numerically honest"
        );
        // The unbucketable row past the limit never walks: the prefix labels.
        let entries = vec![
            execute(),
            ScrollbackEntry::new(RenderBlock::system("hook ran"))
                .with_display_mode(DisplayMode::Collapsed),
        ];
        let l = trunc_label(&entries, Some(1)).expect("prefix buckets");
        assert_eq!(l.text, "Ran 1 command");
    }

    #[test]
    fn truncation_label_counts_failed_commands() {
        let failed = RenderBlock::execute_with_output("false", "", Some("exit 1"));
        let entries = vec![
            execute(),
            ScrollbackEntry::new(failed).with_display_mode(DisplayMode::Collapsed),
        ];
        let l = trunc_label(&entries, None).expect("buckets");
        assert_eq!(l.text, "Ran 2 commands · 1 failed");
        assert!(l.failed);
    }

    #[test]
    fn truncation_label_skips_hidden_thinking_without_consuming_limit() {
        let mut hidden_thought = ScrollbackEntry::new(RenderBlock::thinking("hidden"));
        hidden_thought.set_display_mode(DisplayMode::Collapsed);
        let entries = [execute(), hidden_thought, execute()];
        let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
        // show_thinking=false: the thought is hidden chrome, not a
        // participant — both commands fit in a limit of 2.
        let l = truncation_header_label(&refs, 0..refs.len(), Some(2), false, &Theme::current())
            .expect("buckets");
        assert_eq!(l.text, "Ran 2 commands");
    }

    #[test]
    fn subagent_rows_bucket_with_tools_and_count_distinct_subagents() {
        // A background subagent leaves BOTH its started row and a terminal
        // row in the run; the child-session-id source override collapses
        // them to one displayed subagent.
        let entries = vec![
            read("a.rs"),
            sub_started("child-A"),
            read("b.rs"),
            sub_completed("child-A"),
        ];
        let l = label(&entries);
        assert_eq!(l.text, "Read 2 files, Ran 1 subagent");
        assert!(!l.failed);
    }

    #[test]
    fn subagent_completion_burst_counts_each_subagent() {
        let entries = vec![sub_completed("child-A"), sub_completed("child-B")];
        let l = label(&entries);
        assert_eq!(l.text, "Ran 2 subagents");
    }

    #[test]
    fn subagent_failed_feeds_suffix_cancelled_does_not() {
        let entries = vec![
            subagent(SubagentBlock::failed(
                "task",
                "child-A",
                std::time::Duration::from_secs(3),
                Some("boom".into()),
            )),
            subagent(SubagentBlock::cancelled(
                "task",
                "child-B",
                std::time::Duration::from_secs(3),
            )),
        ];
        let l = label(&entries);
        assert_eq!(l.text, "Ran 2 subagents · 1 failed");
        assert!(l.failed);
    }

    #[test]
    fn running_subagent_flips_group_tense() {
        let mut entries = vec![read("a.rs"), sub_started("child-A")];
        entries[1].is_running = true;
        let l = label(&entries);
        assert_eq!(l.text, "Reading 1 file, Running 1 subagent");
        assert!(l.running);
    }
}
