//! SessionEventBlock — typed session-level events displayed in scrollback.
//!
//! Unlike [`super::SystemMessageBlock`] (which renders arbitrary text),
//! `SessionEventBlock` uses a [`SessionEvent`] enum so each event variant
//! carries structured data (e.g., elapsed time, error messages, token counts).
//! This enables variant-specific rendering and future styling differentiation.

use std::time::Duration;

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

use super::tool::HookRunEntry;
use crate::render::wrapping::word_wrap_lines;
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::{
    AccentStyle, BlockContext, BlockLine, BlockOutput, DisplayMode, Selectable,
};
use crate::theme::Theme;
use crate::util::format_duration;

/// Shared text-selection range id for recap body lines (header is excluded).
const RECAP_BODY_RANGE: u16 = 0;

/// A session-level event with structured data.
///
/// Each variant carries the information needed to render a concise,
/// informational message in the scrollback. These are non-interactive:
/// unselectable, unfoldable, no accent.
#[derive(Debug, Clone)]
pub enum SessionEvent {
    /// Agent turn completed normally.
    TurnCompleted {
        /// Wall-clock elapsed time for the turn. `None` when unknown — a
        /// wake turn whose deltas carried no `turnStartMs` (old shells)
        /// renders without a duration rather than lying with "0.0s".
        elapsed: Option<Duration>,
    },
    /// Agent turn was cancelled by the user.
    TurnCancelled {
        /// Wall-clock elapsed time before cancellation.
        elapsed: Duration,
    },
    /// Agent turn was halted by the system (e.g. doom loop detection).
    TurnHalted {
        /// Wall-clock elapsed time before the turn was halted.
        elapsed: Duration,
    },
    /// Agent turn failed with an error.
    TurnFailed {
        /// Error description.
        error: String,
        /// Elapsed time, if available.
        elapsed: Option<Duration>,
    },
    /// Auto-compaction started (context window threshold reached).
    CompactionStarted {
        /// Percentage of context window used (e.g., 85).
        percentage: u8,
    },
    /// Auto-compaction completed successfully.
    CompactionCompleted {
        /// Tokens used before compaction (`None` from older shells).
        tokens_before: Option<u64>,
        /// Tokens used after compaction.
        tokens_after: u64,
        /// How long compaction took (milliseconds).
        elapsed_ms: Option<i64>,
    },
    /// Auto-compaction failed.
    CompactionFailed {
        /// Error description.
        error: String,
    },
    /// Auto-compaction was cancelled (turn was cancelled mid-compact).
    CompactionCancelled,
    /// Retry failed — all retries exhausted or a non-retryable error.
    ///
    /// Covers both `RetryState::Exhausted` (tried N times, all failed) and
    /// `RetryState::Failed` (non-retryable error like auth or 413).
    RetryFailed {
        /// Human-readable error description.
        error: String,
        /// Structured error category from `RetryState::Failed::error_type`.
        /// Used to match known error patterns without fragile string matching.
        error_type: Option<String>,
    },
    /// The server rejected the credentials (401 / auth error) and automatic
    /// recovery was exhausted. Rendered as a prominent call-to-action that
    /// points the user at `/login` to re-authenticate, replacing the raw
    /// "Retry failed: Unauthorized (401) …" dump.
    ReAuthRequired,
    /// Terminal context overflow — ideally unreachable, since auto-compaction should
    /// shrink the conversation first; a safeguard for when it didn't (estimate drift
    /// vs the server's max_prompt_length, or compaction suppressed/failed). One actionable
    /// prompt, replacing the CompactionFailed + RetryFailed + TurnFailed stack.
    ContextTooLarge,
    /// Manual `/compact` command completed.
    CompactCompleted {
        /// Wall-clock elapsed time for the command.
        elapsed: Duration,
    },
    /// Hook annotation — displayed inline after a tool call.
    /// Message comes from agent via XaiSessionUpdate::HookAnnotation.
    HookAnnotation {
        /// The hook message
        message: String,
    },
    /// The session's persisted model is no longer available after re-auth.
    /// Both IDs are empty when re-shown on blocked prompt attempts.
    ModelUnavailable {
        previous_model_id: String,
        new_model_id: String,
        reason: String,
    },
    /// Memory was saved (flush, dream, or session-end).
    MemorySaved {
        /// File path that was written.
        path: String,
        /// What triggered the save: "session-end", "flush", or "dream".
        trigger: String,
    },
    /// A `/goal` finished (status → Complete). Carries the goal's total
    /// elapsed time across all its turns, distinct from the per-turn
    /// "Worked for" marker.
    GoalCompleted {
        /// Goal end-to-end elapsed time (`GoalUpdated.elapsed_ms`).
        elapsed: Duration,
    },
    /// A session recap — a short "where was I" summary of the session so far.
    /// Surfaced on demand via `/recap` (`auto = false`) or automatically when
    /// the user returns to the terminal after being away (`auto = true`).
    Recap {
        /// The one-line recap text.
        summary: String,
        /// `true` for the automatic return-from-away recap, `false` for `/recap`.
        auto: bool,
    },
}

impl SessionEvent {
    /// Format the event as a human-readable string.
    pub fn message(&self) -> String {
        match self {
            SessionEvent::TurnCompleted {
                elapsed: Some(elapsed),
            } => {
                format!("Worked for {}.", format_duration(*elapsed))
            }
            SessionEvent::TurnCompleted { elapsed: None } => "Turn completed.".to_string(),
            SessionEvent::TurnCancelled { elapsed } => {
                format!("Turn cancelled by user in {}.", format_duration(*elapsed))
            }
            SessionEvent::TurnHalted { elapsed } => {
                format!(
                    "Agent was unable to make progress \u{2014} turn ended in {}.",
                    format_duration(*elapsed)
                )
            }
            SessionEvent::TurnFailed {
                error,
                elapsed: Some(elapsed),
            } => {
                format!("Turn failed in {}: {error}", format_duration(*elapsed))
            }
            SessionEvent::TurnFailed {
                error,
                elapsed: None,
            } => {
                format!("Turn failed: {error}")
            }
            SessionEvent::CompactionStarted { percentage } => {
                format!("Context {percentage}% full. Compacting…")
            }
            SessionEvent::CompactionCompleted {
                tokens_before,
                tokens_after,
                elapsed_ms,
            } => {
                let after = format_tokens(*tokens_after);
                // Older shells don't send tokens_before — keep the legacy format.
                let body = match tokens_before {
                    Some(before) if *before > 0 => {
                        format!(
                            "Context compacted: {} → {after} tokens",
                            format_tokens(*before)
                        )
                    }
                    _ => format!("Context compacted → {after} tokens"),
                };
                if let Some(ms) = elapsed_ms {
                    let secs = *ms as f64 / 1000.0;
                    format!("{body} ({secs:.1}s)")
                } else {
                    body
                }
            }
            SessionEvent::CompactionFailed { error } => {
                if error.trim().is_empty() {
                    "Compaction failed.".to_string()
                } else {
                    format!("Compaction failed: {error}")
                }
            }
            SessionEvent::CompactionCancelled => "Compaction cancelled.".to_string(),
            SessionEvent::RetryFailed { error, error_type } => {
                if error_type.as_deref() == Some("encrypted_content_mismatch") {
                    "This session's conversation history is incompatible with the \
                     current model. Please start a new session."
                        .to_string()
                } else {
                    format!("Retry failed: {error}")
                }
            }
            SessionEvent::ReAuthRequired => {
                "Authentication required \u{2014} your session has expired or your \
                 credentials were rejected. Run /login to re-authenticate, then resend \
                 your message."
                    .to_string()
            }
            SessionEvent::ContextTooLarge => {
                "This conversation is too large for the model's context window. \
                 Use /new to start a new session."
                    .to_string()
            }
            SessionEvent::CompactCompleted { elapsed } => {
                format!("Compaction completed in {}.", format_duration(*elapsed))
            }
            SessionEvent::HookAnnotation { message } => message.clone(),
            SessionEvent::ModelUnavailable {
                new_model_id,
                reason,
                ..
            } => {
                if new_model_id.is_empty() {
                    reason.clone()
                } else {
                    format!("{reason} Switched to \"{new_model_id}\".")
                }
            }
            SessionEvent::MemorySaved { path, trigger } => {
                let short_path = crate::util::abbreviate_path(path);
                format!("Memory saved ({trigger}) \u{2192} {short_path}  \u{00b7}  /memory to view")
            }
            SessionEvent::GoalCompleted { elapsed } => {
                format!(
                    "Goal complete \u{2014} {} end-to-end.",
                    format_duration(*elapsed)
                )
            }
            SessionEvent::Recap { summary, auto: _ } => {
                // Always "Recap —" (manual `/recap` and auto return-from-away).
                format!("Recap \u{2014} {summary}")
            }
        }
    }

    /// The recap summary text when this is a [`SessionEvent::Recap`].
    ///
    /// Recap events render in the tool-call visual style (bullet + bold
    /// "Recap" header + muted body); every other variant stays a plain
    /// informational line. This accessor is the single branch point the
    /// `SessionEventBlock` trait methods use to opt the recap into that style.
    fn recap_summary(&self) -> Option<&str> {
        match self {
            SessionEvent::Recap { summary, .. } => Some(summary.as_str()),
            _ => None,
        }
    }

    /// Whether this event marks the end of an agent turn (the "Turn
    /// completed/cancelled/failed" markers). These are the only events that
    /// can carry the turn's stop/stop_failure hook runs inline — but a
    /// parked marker renders mid-turn while the turn is still running
    /// shell-side, before any Stop hook fires, so hook eligibility is the
    /// block-level [`SessionEventBlock::accepts_stop_hooks`].
    pub fn is_turn_terminal(&self) -> bool {
        matches!(
            self,
            SessionEvent::TurnCompleted { .. }
                | SessionEvent::TurnCancelled { .. }
                | SessionEvent::TurnHalted { .. }
                | SessionEvent::TurnFailed { .. }
        )
    }
}

/// Format a token count with "k" suffix for thousands.
fn format_tokens(tokens: u64) -> String {
    if tokens >= 1000 {
        format!("{:.1}k", tokens as f64 / 1000.0)
    } else {
        tokens.to_string()
    }
}

/// Background work counted on a turn-end marker's work suffix
/// ("1 command, 1 monitor and 2 subagents still running."). A static
/// snapshot taken at push time — nothing mutates it; a later completion
/// re-emits a fresh work-only status line when no wake response follows,
/// and otherwise gets the wake turn's own end marker (see the pager's
/// `push_wake_end_marker`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct EndWork {
    /// Background bash commands still running (`BgTaskStatus::Running`,
    /// non-monitor).
    pub running_commands: usize,
    /// Background monitors still running — counted apart from commands so a
    /// long-lived watch reads as "1 monitor", not a stuck command.
    pub running_monitors: usize,
    /// Subagents still running.
    pub running_subagents: usize,
}

impl EndWork {
    /// `None` at all-zero counts — the one spelling of "a workless marker
    /// carries no `end_work`", so it renders the legacy text and closes the
    /// between-turns status window.
    pub fn nonzero(self) -> Option<Self> {
        (self != Self::default()).then_some(self)
    }

    /// The "N … still running." line, `None` when every count is zero so a
    /// marker collapses to the plain legacy completion text (and no
    /// between-turns status line is emitted). Doubles as the work-only
    /// status line re-emitted after a background completion. Ends with a
    /// period, never an ellipsis: the marker states a settled fact, not a
    /// progress spinner.
    pub fn still_running_text(&self) -> Option<String> {
        let count = |n: usize, noun: &str| -> Option<String> {
            match n {
                0 => None,
                1 => Some(format!("1 {noun}")),
                n => Some(format!("{n} {noun}s")),
            }
        };
        let parts: Vec<String> = [
            count(self.running_commands, "command"),
            count(self.running_monitors, "monitor"),
            count(self.running_subagents, "subagent"),
        ]
        .into_iter()
        .flatten()
        .collect();
        let (last, head) = parts.split_last()?;
        let joined = if head.is_empty() {
            last.clone()
        } else {
            format!("{} and {last}", head.join(", "))
        };
        Some(format!("{joined} still running."))
    }
}

/// Block that renders a [`SessionEvent`] in scrollback.
///
/// Visually identical to [`super::SystemMessageBlock`] (muted text, compact,
/// unselectable). The structured `event` field is available for future
/// styling differentiation (e.g., red text for failures).
#[derive(Debug, Clone)]
pub struct SessionEventBlock {
    /// The typed event data.
    pub event: SessionEvent,
    /// Stop/stop_failure hook runs folded into a turn-terminal marker
    /// (`(event_name, runs)` per hook batch). Rendered as a right-justified
    /// `stop  [hooks: N]` summary on the marker line, with per-hook detail
    /// on expand. Always empty for non-terminal events.
    pub stop_hooks: Vec<(String, Vec<HookRunEntry>)>,
    /// The prompt turn a terminal marker belongs to, when known. Gates
    /// which stop-hook batches may merge into it.
    pub prompt_id: Option<String>,
    /// Work counts appended to a turn-end marker as a "still running"
    /// suffix, snapshotted at push time. `None` for every non-marker event
    /// and for markers pushed with no work in flight — those render the
    /// legacy text unchanged.
    pub end_work: Option<EndWork>,
    /// The marker was pushed at park time (user-interruptible blocking
    /// wait): the turn is still running shell-side, so it must never accept
    /// stop hooks. Rendering is unchanged — a parked wait reads as stopped —
    /// and the real completion still prints its own marker.
    pub parked: bool,
}

impl SessionEventBlock {
    /// Create a new session event block.
    pub fn new(event: SessionEvent) -> Self {
        Self {
            event,
            stop_hooks: Vec::new(),
            prompt_id: None,
            end_work: None,
            parked: false,
        }
    }

    /// A turn-terminal marker carrying the turn's stop-hook runs and prompt id.
    pub fn with_stop_hooks(
        event: SessionEvent,
        stop_hooks: Vec<(String, Vec<HookRunEntry>)>,
        prompt_id: Option<String>,
    ) -> Self {
        debug_assert!(stop_hooks.is_empty() || event.is_turn_terminal());
        Self {
            event,
            stop_hooks,
            prompt_id,
            end_work: None,
            parked: false,
        }
    }

    /// Whether this marker may carry/accept stop-hook runs: a turn-terminal
    /// event that is not a parked line (which renders while the turn is
    /// still running shell-side, before any Stop hook fires).
    pub fn accepts_stop_hooks(&self) -> bool {
        self.event.is_turn_terminal() && !self.parked
    }

    /// The marker line text: the turn-scoped prefix ("Worked for X.")
    /// plus the work suffix ("1 command still running.") snapshotted at push
    /// time. No `end_work` (or all-zero counts) is byte-identical to the
    /// legacy event text.
    pub fn marker_text(&self) -> String {
        match self.end_work.as_ref().and_then(EndWork::still_running_text) {
            Some(work) => format!("{} {work}", self.event.message()),
            None => self.event.message(),
        }
    }

    /// Whether any attached stop hook actually ran (non-skipped). Gates the
    /// fold/selection affordances and the inline summary, mirroring
    /// [`ToolCallHookData::has_content`](super::tool::ToolCallHookData::has_content).
    pub fn has_stop_hook_content(&self) -> bool {
        self.stop_hooks.iter().any(|(_, runs)| {
            runs.iter()
                .any(|r| !matches!(r.status, super::tool::HookRunStatus::Skipped))
        })
    }

    /// A recap with real body content — i.e. not the empty loading spinner or a
    /// stray empty recap. Gates the interactive affordances (folding + j/k
    /// selection) so navigation never lands on a recap that can't fold.
    fn recap_has_body(&self) -> bool {
        self.event
            .recap_summary()
            .is_some_and(|s| !s.trim().is_empty())
    }

    /// Merge the stop-hook runs into the marker's output: a right-justified
    /// `stop  [hooks: N]` summary on the marker line (its own right-justified
    /// line when the marker text leaves no room or wraps), plus per-hook
    /// detail lines when expanded.
    ///
    /// The summary spans are decoration — [`Selectable::Spans`] keeps
    /// drag-copy on the marker text only, so a copied "Worked for
    /// 4.4s." never drags the padding and hook counts along.
    fn append_stop_hooks(&self, lines: &mut Vec<BlockLine>, ctx: &BlockContext) {
        use super::tool::hook::{render_hooks_for_mode, render_stop_hooks_summary};

        if !self.has_stop_hook_content() {
            return;
        }
        let Some(summary) = render_stop_hooks_summary(&self.stop_hooks) else {
            return;
        };

        let avail = ctx.width as usize;
        let summary_width: usize = summary
            .iter()
            .map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
            .sum();
        // Inline attach is for single-line markers only: on a wrapped marker
        // (a long TurnFailed error) the summary would land mid-paragraph.
        let single_line = lines.len() == 1;
        if let Some(first) = lines.first_mut() {
            let text = crate::scrollback::types::line_plain_text(&first.content);
            let text_width = unicode_width::UnicodeWidthStr::width(text.as_str());
            // Restrict drag-copy to the marker text span(s) before padding.
            let text_spans = first.content.spans.len();
            if single_line && text_width + 2 + summary_width <= avail {
                let pad = avail - text_width - summary_width;
                first.content.spans.push(Span::raw(" ".repeat(pad)));
                first.content.spans.extend(summary);
                first.selectable = Selectable::Spans(0..text_spans);
                first.selection_text = Some(text);
            } else {
                // No room on the marker line (or a wrapped marker) —
                // right-justify on its own line below the text.
                let pad = avail.saturating_sub(summary_width);
                let mut spans = vec![Span::raw(" ".repeat(pad))];
                spans.extend(summary);
                lines.push(BlockLine::separator(Line::from(spans)));
            }
        }

        // Expanded: per-hook detail below the marker line. The section header
        // ("stop") is redundant with the inline summary for a single batch;
        // keep it when both stop_failure and stop ran so the groups read apart.
        if !matches!(ctx.mode, DisplayMode::Collapsed) {
            let multiple = self.stop_hooks.len() > 1;
            for (event_name, runs) in &self.stop_hooks {
                let detail = if multiple {
                    render_hooks_for_mode(event_name, runs, ctx.mode)
                } else {
                    super::tool::hook::render_hooks_detail(runs, ctx.mode)
                };
                lines.extend(detail);
            }
        }
    }

    /// Render a recap event in the tool-call visual style.
    ///
    /// Mirrors [`OtherToolCallBlock`](super::OtherToolCallBlock): a bold
    /// "Recap" header (the dot bullet is prepended later by
    /// `RenderBlock::output` via [`has_bullet`](BlockContent::has_bullet))
    /// with the summary shown as muted body text below when expanded. When
    /// collapsed, the summary's first line trails the header as a preview.
    ///
    /// While the recap is still being generated the entry is `is_running`, so
    /// only the header is shown and the animated accent sidebar (see
    /// [`accent`](BlockContent::accent)) signals progress.
    ///
    /// Text selection mirrors [`ThinkingBlock`](super::ThinkingBlock): the
    /// "Recap" label (and the blank separator under it) are decoration
    /// ([`BlockLine::separator`] / [`Selectable::None`]) so drag-highlight and
    /// copy only include the summary body, never the chrome label.
    fn recap_output(&self, ctx: &BlockContext, summary: &str) -> BlockOutput {
        let theme = Theme::current();
        let muted_collapsed =
            ctx.mute_when_collapsed(ctx.appearance.scrollback.blocks.tool.muted_collapsed);

        // "Recap" header — bold, neutral primary text (like a tool-call header).
        // Dimmed to muted gray while collapsed-and-unselected.
        let header_text_style = if muted_collapsed {
            theme.muted()
        } else {
            theme.primary()
        };
        let header_style = header_text_style.add_modifier(Modifier::BOLD);
        // Non-selectable chrome (same as Thinking / tool label prefixes).
        let header_line =
            || BlockLine::separator(Line::from(Span::styled("Recap".to_string(), header_style)));

        // Loading: header only; the animated gray sidebar is the feedback.
        if ctx.is_running {
            return BlockOutput {
                lines: vec![header_line()],
            };
        }

        match ctx.mode {
            DisplayMode::Collapsed => {
                let mut spans = vec![Span::styled("Recap".to_string(), header_style)];
                let preview = summary.lines().next().unwrap_or(summary).trim();
                if !preview.is_empty() {
                    spans.push(Span::styled(format!("  {preview}"), theme.muted()));
                }
                let line = crate::render::line_utils::truncate_line(
                    Line::from(spans),
                    ctx.content_width(),
                );
                // Only the preview span is copyable — never the "Recap" label.
                // No preview (empty after trim) → fully non-selectable.
                let selectable = if preview.is_empty() {
                    Selectable::None
                } else {
                    Selectable::Spans(1..2)
                };
                BlockOutput {
                    lines: vec![BlockLine {
                        content: line,
                        selectable,
                        selection_range: (!preview.is_empty()).then_some(RECAP_BODY_RANGE),
                        selection_text: (!preview.is_empty()).then(|| preview.to_string()),
                        ..Default::default()
                    }],
                }
            }
            DisplayMode::Truncated | DisplayMode::Expanded => {
                let mut lines: Vec<BlockLine> = vec![header_line()];
                // Blank gap under the header is decoration, not copyable text.
                lines.push(BlockLine::separator(Line::from("")));

                let styled_lines = summary
                    .split('\n')
                    .map(|line| Line::from(Span::styled(line.to_string(), theme.muted())));
                let wrapped =
                    word_wrap_lines(styled_lines, (ctx.width as usize).saturating_sub(2).max(20));
                for wrapped_line in wrapped {
                    lines.push(
                        BlockLine::styled(wrapped_line)
                            .with_selection_range(Some(RECAP_BODY_RANGE)),
                    );
                }

                BlockOutput { lines }
            }
        }
    }
}

impl BlockContent for SessionEventBlock {
    fn output(&self, ctx: &BlockContext) -> BlockOutput {
        // Recap renders in the tool-call style (bullet + bold header + body).
        if let Some(summary) = self.event.recap_summary() {
            return self.recap_output(ctx, summary);
        }

        let theme = Theme::current();
        // Failures and re-auth / context-overflow prompts are actionable, not
        // informational — render them in the warning color, not muted noise.
        let style = if matches!(
            self.event,
            SessionEvent::ReAuthRequired
                | SessionEvent::ContextTooLarge
                | SessionEvent::CompactionFailed { .. }
        ) {
            ratatui::style::Style::default().fg(theme.warning)
        } else {
            theme.muted()
        };

        let text = self.marker_text();
        let wrapped = if text.contains('\n') {
            let input_lines = text
                .split('\n')
                .map(|s| Line::from(Span::styled(s.to_owned(), style)));
            word_wrap_lines(input_lines, ctx.width as usize)
        } else {
            word_wrap_lines(
                std::iter::once(Line::from(Span::styled(text, style))),
                ctx.width as usize,
            )
        };
        let mut lines: Vec<BlockLine> = wrapped
            .into_iter()
            .map(|line| BlockLine::styled(line).with_selection_range(Some(0)))
            .collect();

        if lines.is_empty() {
            lines.push(BlockLine::styled(Line::from("")).with_selection_range(Some(0)));
        }
        self.append_stop_hooks(&mut lines, ctx);
        BlockOutput { lines }
    }

    fn accent(&self, ctx: &BlockContext) -> Option<AccentStyle> {
        let theme = Theme::current();
        if self.event.recap_summary().is_some() {
            // Loading: animated sidebar so there's feedback that the recap is
            // being generated. Gray rather than the magenta `accent_running` —
            // the recap is a passive marker, not an active tool turn.
            if ctx.is_running {
                return Some(AccentStyle::animated(theme.gray));
            }
            // Finished: neutral tool accent bar when expanded (no special color).
            return (ctx.mode != DisplayMode::Collapsed)
                .then(|| AccentStyle::static_color(theme.accent_tool));
        }
        if matches!(
            self.event,
            SessionEvent::ReAuthRequired
                | SessionEvent::ContextTooLarge
                | SessionEvent::CompactionFailed { .. }
        ) {
            Some(AccentStyle::static_color(theme.warning))
        } else {
            None
        }
    }

    fn bullet(&self, ctx: &BlockContext) -> Option<AccentStyle> {
        // Recap: animated dot while loading; default gray dot when collapsed-idle;
        // accent color when expanded. Other events never show a bullet.
        if self.event.recap_summary().is_some()
            && !ctx.is_running
            && ctx.mode == DisplayMode::Collapsed
        {
            return None;
        }
        self.accent(ctx)
    }

    fn has_vpad(&self, _ctx: &BlockContext) -> bool {
        false // Compact like SystemMessageBlock
    }

    fn has_raw_mode(&self) -> bool {
        false
    }

    fn is_foldable(&self) -> bool {
        // A recap with body content folds, as does a turn marker carrying
        // stop-hook runs (fold = per-hook detail). Other events are single
        // informational lines with nothing to collapse.
        self.recap_has_body() || self.has_stop_hook_content()
    }

    fn is_selectable(&self) -> bool {
        // Recap is tool-like: navigable so it can be folded — but only once it
        // has body content (mirrors `is_foldable`), so j/k never lands on the
        // loading spinner or an empty recap. A turn marker with stop hooks is
        // navigable for the same reason. Other events stay non-interactive.
        self.recap_has_body() || self.has_stop_hook_content()
    }

    fn default_display_mode(&self) -> DisplayMode {
        // A marker with stop hooks starts collapsed: the right-justified
        // summary is the resting state; detail is opt-in via fold.
        if self.has_stop_hook_content() {
            DisplayMode::Collapsed
        } else {
            DisplayMode::Expanded
        }
    }

    fn has_bullet(&self, ctx: &BlockContext) -> bool {
        // Recap only, and only when the shared tool bullet is configured — so it
        // tracks the same appearance setting as real tool calls.
        self.event.recap_summary().is_some()
            && ctx
                .appearance
                .scrollback
                .blocks
                .tool
                .bullet
                .char()
                .is_some()
    }

    fn is_groupable(&self) -> bool {
        true
    }
}

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

    #[test]
    fn turn_completed_message() {
        let event = SessionEvent::TurnCompleted {
            elapsed: Some(Duration::from_secs(125)),
        };
        assert_eq!(event.message(), "Worked for 2m5s.");
    }

    #[test]
    fn turn_cancelled_message() {
        let event = SessionEvent::TurnCancelled {
            elapsed: Duration::from_secs(10),
        };
        assert_eq!(event.message(), "Turn cancelled by user in 10s.");
    }

    #[test]
    fn goal_completed_message_shows_end_to_end_time() {
        let event = SessionEvent::GoalCompleted {
            elapsed: Duration::from_secs(619),
        };
        assert_eq!(event.message(), "Goal complete \u{2014} 10m19s end-to-end.");
    }

    #[test]
    fn turn_halted_message() {
        let event = SessionEvent::TurnHalted {
            elapsed: Duration::from_secs(45),
        };
        assert_eq!(
            event.message(),
            "Agent was unable to make progress \u{2014} turn ended in 45s."
        );
    }

    #[test]
    fn turn_failed_with_elapsed_message() {
        let event = SessionEvent::TurnFailed {
            error: "connection reset".into(),
            elapsed: Some(Duration::from_secs(3)),
        };
        assert_eq!(event.message(), "Turn failed in 3.0s: connection reset");
    }

    #[test]
    fn turn_failed_without_elapsed_message() {
        let event = SessionEvent::TurnFailed {
            error: "auth error".into(),
            elapsed: None,
        };
        assert_eq!(event.message(), "Turn failed: auth error");
    }

    #[test]
    fn model_unavailable_with_switch() {
        let event = SessionEvent::ModelUnavailable {
            previous_model_id: "kigi-4.5".into(),
            new_model_id: "kigi".into(),
            reason: "Model \"kigi-4.5\" is no longer available.".into(),
        };
        assert_eq!(
            event.message(),
            "Model \"kigi-4.5\" is no longer available. Switched to \"kigi\"."
        );
    }

    #[test]
    fn model_unavailable_blocked_reprompt() {
        let event = SessionEvent::ModelUnavailable {
            previous_model_id: String::new(),
            new_model_id: String::new(),
            reason: "Your previous model is no longer available. Please start a new session."
                .into(),
        };
        assert_eq!(
            event.message(),
            "Your previous model is no longer available. Please start a new session."
        );
    }

    #[test]
    fn retry_failed_generic() {
        let event = SessionEvent::RetryFailed {
            error: "connection timeout".into(),
            error_type: None,
        };
        assert_eq!(event.message(), "Retry failed: connection timeout");
    }

    #[test]
    fn retry_failed_encrypted_content_mismatch() {
        let event = SessionEvent::RetryFailed {
            error: "raw API error message".into(),
            error_type: Some("encrypted_content_mismatch".into()),
        };
        assert_eq!(
            event.message(),
            "This session's conversation history is incompatible with the \
             current model. Please start a new session."
        );
    }

    #[test]
    fn retry_failed_other_error_type_shows_raw() {
        let event = SessionEvent::RetryFailed {
            error: "bad request".into(),
            error_type: Some("api_400".into()),
        };
        assert_eq!(event.message(), "Retry failed: bad request");
    }

    #[test]
    fn reauth_required_message_points_at_login() {
        let msg = SessionEvent::ReAuthRequired.message();
        assert!(msg.contains("/login"), "must tell the user to run /login");
        assert!(
            msg.to_lowercase().contains("authentication")
                || msg.to_lowercase().contains("credentials"),
            "must explain it is an auth problem: {msg}"
        );
    }

    #[test]
    fn reauth_required_has_warning_accent() {
        let block = SessionEventBlock::new(SessionEvent::ReAuthRequired);
        let theme = Theme::current();
        let accent = block.accent(&ctx());
        assert_eq!(
            accent.map(|a| a.color),
            Some(theme.warning),
            "re-auth prompt must stand out with a warning accent"
        );
    }

    #[test]
    fn context_too_large_message_is_actionable() {
        let msg = SessionEvent::ContextTooLarge.message();
        assert!(
            msg.to_lowercase().contains("too large"),
            "must explain the conversation is too large: {msg}"
        );
        assert!(
            msg.contains("/new"),
            "must offer /new as the recovery action: {msg}"
        );
    }

    #[test]
    fn context_too_large_has_warning_accent() {
        let block = SessionEventBlock::new(SessionEvent::ContextTooLarge);
        let theme = Theme::current();
        let accent = block.accent(&ctx());
        assert_eq!(
            accent.map(|a| a.color),
            Some(theme.warning),
            "context-too-large prompt must stand out with a warning accent"
        );
    }

    #[test]
    fn compaction_completed_renders_before_after_delta() {
        let event = SessionEvent::CompactionCompleted {
            tokens_before: Some(48_800),
            tokens_after: 27_100,
            elapsed_ms: Some(21_000),
        };
        assert_eq!(
            event.message(),
            "Context compacted: 48.8k → 27.1k tokens (21.0s)"
        );
    }

    #[test]
    fn compaction_completed_without_before_keeps_legacy_format() {
        let event = SessionEvent::CompactionCompleted {
            tokens_before: None,
            tokens_after: 27_100,
            elapsed_ms: None,
        };
        assert_eq!(event.message(), "Context compacted → 27.1k tokens");
    }

    #[test]
    fn compaction_failed_empty_error_is_terse() {
        let event = SessionEvent::CompactionFailed {
            error: String::new(),
        };
        assert_eq!(event.message(), "Compaction failed.");
    }

    #[test]
    fn compaction_failed_curated_error_is_appended() {
        let event = SessionEvent::CompactionFailed {
            error: "out of credits or over your spending limit. Add credits and retry.".into(),
        };
        assert_eq!(
            event.message(),
            "Compaction failed: out of credits or over your spending limit. Add credits and retry."
        );
    }

    #[test]
    fn compaction_failed_has_warning_accent() {
        let block = SessionEventBlock::new(SessionEvent::CompactionFailed {
            error: "out of credits or over your spending limit. Add credits and retry.".into(),
        });
        let theme = Theme::current();
        assert_eq!(
            block.accent(&ctx()).map(|a| a.color),
            Some(theme.warning),
            "an actionable compaction failure must use a warning accent, not muted"
        );
    }

    fn ctx() -> BlockContext {
        BlockContext {
            mode: crate::scrollback::types::DisplayMode::Expanded,
            is_running: false,
            width: 80,
            raw: false,
            max_lines: None,
            appearance: crate::appearance::AppearanceConfig::default(),
            is_selected: false,
            cwd: None,
        }
    }

    #[test]
    fn memory_saved_message_formats_correctly() {
        let event = SessionEvent::MemorySaved {
            path: "/some/absolute/path/MEMORY.md".into(),
            trigger: "flush".into(),
        };
        let msg = event.message();
        assert!(msg.starts_with("Memory saved (flush)"));
        assert!(msg.contains("/memory to view"));
    }

    #[test]
    fn recap_manual_and_auto_use_same_label() {
        let manual = SessionEvent::Recap {
            summary: "refactored the parser".into(),
            auto: false,
        };
        assert_eq!(manual.message(), "Recap \u{2014} refactored the parser");

        let auto = SessionEvent::Recap {
            summary: "refactored the parser".into(),
            auto: true,
        };
        assert_eq!(auto.message(), "Recap \u{2014} refactored the parser");
    }

    /// `ctx()` with an overridden display mode / selection state.
    fn recap_ctx(mode: DisplayMode, is_selected: bool) -> BlockContext {
        BlockContext {
            mode,
            is_selected,
            ..ctx()
        }
    }

    /// `ctx()` in the running/loading state (mode is `Expanded` from `ctx()`).
    fn recap_running_ctx() -> BlockContext {
        BlockContext {
            is_running: true,
            ..ctx()
        }
    }

    fn plain(line: &BlockLine) -> String {
        crate::scrollback::types::line_plain_text(&line.content)
    }

    #[test]
    fn recap_renders_tool_style_header_and_body() {
        let block = SessionEventBlock::new(SessionEvent::Recap {
            summary: "Refactored the parser and added tests.".into(),
            auto: false,
        });
        let out = block.output(&recap_ctx(DisplayMode::Expanded, false));
        assert_eq!(
            plain(&out.lines[0]),
            "Recap",
            "header line is the 'Recap' label"
        );
        let body = out.lines.iter().map(plain).collect::<Vec<_>>().join("\n");
        assert!(
            body.contains("Refactored the parser and added tests."),
            "summary is shown as body text: {body}"
        );
    }

    #[test]
    fn recap_is_foldable_selectable_and_bulleted_open_by_default() {
        let block = SessionEventBlock::new(SessionEvent::Recap {
            summary: "did stuff".into(),
            auto: false,
        });
        assert!(
            block.is_foldable(),
            "recap collapses/expands like a tool call"
        );
        assert!(
            block.is_selectable(),
            "recap is navigable so it can be folded"
        );
        assert!(
            block.has_bullet(&ctx()),
            "recap shows the shared tool bullet under default appearance"
        );
        assert_eq!(
            block.default_display_mode(),
            DisplayMode::Expanded,
            "recap is open by default"
        );
    }

    #[test]
    fn recap_accent_and_bullet_use_neutral_tool_color_when_idle() {
        let block = SessionEventBlock::new(SessionEvent::Recap {
            summary: "did stuff".into(),
            auto: false,
        });
        let theme = Theme::current();
        // Expanded: static neutral tool accent bar (no special color).
        let expanded = block.accent(&recap_ctx(DisplayMode::Expanded, false));
        assert_eq!(expanded.map(|a| a.color), Some(theme.accent_tool));
        assert_eq!(expanded.map(|a| a.animated), Some(false));
        // Collapsed: no accent bar; bullet falls back to the default gray dot.
        assert_eq!(
            block.accent(&recap_ctx(DisplayMode::Collapsed, false)),
            None
        );
        assert_eq!(
            block.bullet(&recap_ctx(DisplayMode::Collapsed, false)),
            None
        );
    }

    #[test]
    fn recap_loading_shows_header_only_with_animated_sidebar() {
        // Empty summary + running entry = the in-flight loading state.
        let block = SessionEventBlock::new(SessionEvent::Recap {
            summary: String::new(),
            auto: false,
        });
        let theme = Theme::current();
        let rc = recap_running_ctx();

        // Header only — no blank line or body while still generating.
        let out = block.output(&rc);
        assert_eq!(out.lines.len(), 1, "loading recap is just the header");
        assert_eq!(plain(&out.lines[0]), "Recap");

        // The sidebar + bullet animate in gray (the feedback) — not the magenta
        // running color used for active tool turns.
        let accent = block.accent(&rc).expect("loading recap has an accent bar");
        assert_eq!(accent.color, theme.gray);
        assert!(accent.animated, "loading sidebar animates");
        assert_eq!(
            block.bullet(&rc),
            Some(accent),
            "bullet animates while loading"
        );
    }

    #[test]
    fn recap_collapsed_shows_header_with_first_line_preview() {
        let block = SessionEventBlock::new(SessionEvent::Recap {
            summary: "First line of recap.\nSecond line.".into(),
            auto: false,
        });
        let out = block.output(&recap_ctx(DisplayMode::Collapsed, false));
        assert_eq!(out.lines.len(), 1, "collapsed recap is a single line");
        let text = plain(&out.lines[0]);
        assert!(text.starts_with("Recap"), "starts with the header: {text}");
        assert!(
            text.contains("First line of recap."),
            "shows a preview: {text}"
        );
        assert!(
            !text.contains("Second line"),
            "preview is first line only: {text}"
        );
    }

    #[test]
    fn recap_loading_or_empty_is_not_selectable_or_foldable() {
        // The in-flight spinner (empty summary) — and any stray empty recap —
        // must not be selectable or foldable, so j/k never stops on a block
        // that can't fold and offers no interaction (mirrors `is_foldable`).
        for summary in ["", "   \n  "] {
            let block = SessionEventBlock::new(SessionEvent::Recap {
                summary: summary.into(),
                auto: false,
            });
            assert!(
                !block.is_selectable(),
                "empty/loading recap is not selectable: {summary:?}"
            );
            assert!(
                !block.is_foldable(),
                "empty/loading recap is not foldable: {summary:?}"
            );
        }
    }

    #[test]
    fn recap_expanded_header_is_not_text_selectable() {
        let block = SessionEventBlock::new(SessionEvent::Recap {
            summary: "We fixed the recap body selection.\nSecond line.".into(),
            auto: false,
        });
        let out = block.output(&recap_ctx(DisplayMode::Expanded, false));
        assert!(
            matches!(out.lines[0].selectable, Selectable::None),
            "header must be decoration, not copyable"
        );
        assert_eq!(out.lines[0].selection_range, None);
        assert!(
            matches!(out.lines[1].selectable, Selectable::None),
            "blank gap under header must not be selectable"
        );
        let body: Vec<_> = out.lines.iter().skip(2).collect();
        assert!(!body.is_empty(), "expected body lines");
        for line in body {
            assert!(
                matches!(line.selectable, Selectable::All),
                "body lines are fully selectable"
            );
            assert_eq!(
                line.selection_range,
                Some(0),
                "body shares one selection range so multi-line drag works"
            );
        }
    }

    #[test]
    fn recap_collapsed_only_preview_is_text_selectable() {
        let block = SessionEventBlock::new(SessionEvent::Recap {
            summary: "First line of recap.\nSecond line.".into(),
            auto: false,
        });
        let out = block.output(&recap_ctx(DisplayMode::Collapsed, false));
        assert_eq!(out.lines.len(), 1);
        let line = &out.lines[0];
        assert!(
            matches!(&line.selectable, Selectable::Spans(r) if *r == (1..2)),
            "only the preview span is selectable, not the Recap label: {:?}",
            line.selectable
        );
        assert_eq!(line.selection_range, Some(0));
        assert_eq!(
            line.selection_text.as_deref(),
            Some("First line of recap."),
            "copy payload is the preview body only"
        );
    }

    #[test]
    fn non_recap_events_stay_non_interactive() {
        let block = SessionEventBlock::new(SessionEvent::TurnCompleted {
            elapsed: Some(Duration::from_secs(5)),
        });
        assert!(!block.is_foldable());
        assert!(!block.is_selectable());
        assert!(!block.has_bullet(&ctx()));
        assert_eq!(block.accent(&ctx()), None);
    }

    fn stop_group(name: &str) -> (String, Vec<HookRunEntry>) {
        use super::super::tool::HookRunStatus;
        (
            name.to_string(),
            vec![HookRunEntry {
                name: "global/notify".into(),
                status: HookRunStatus::Success {
                    elapsed: Duration::from_millis(12),
                },
                output: None,
            }],
        )
    }

    fn completed_with_stop_hooks() -> SessionEventBlock {
        SessionEventBlock::with_stop_hooks(
            SessionEvent::TurnCompleted {
                elapsed: Some(Duration::from_secs(5)),
            },
            vec![stop_group("stop")],
            None,
        )
    }

    #[test]
    fn stop_hooks_summary_is_right_justified_on_marker_line() {
        let block = completed_with_stop_hooks();
        let out = block.output(&BlockContext {
            mode: DisplayMode::Collapsed,
            ..ctx()
        });
        assert_eq!(out.lines.len(), 1, "collapsed marker stays a single line");
        let text = plain(&out.lines[0]);
        assert!(
            text.starts_with("Worked for 5.0s."),
            "marker text keeps the left edge: {text}"
        );
        assert!(
            text.ends_with("stop  [hooks: 1]"),
            "summary sits at the right edge: {text}"
        );
        assert_eq!(
            unicode_width::UnicodeWidthStr::width(text.as_str()),
            80,
            "padding right-justifies the summary to the content width"
        );
        // Drag-copy stays on the marker text, never the padding or counts.
        assert!(
            matches!(&out.lines[0].selectable, Selectable::Spans(r) if *r == (0..1)),
            "only the marker text span is selectable: {:?}",
            out.lines[0].selectable
        );
        assert_eq!(
            out.lines[0].selection_text.as_deref(),
            Some("Worked for 5.0s.")
        );
    }

    #[test]
    fn stop_hooks_summary_wraps_to_own_line_when_narrow() {
        let block = completed_with_stop_hooks();
        // "Worked for 5.0s." is 16 cols; the summary is 16 — no room
        // at width 30, so the summary right-justifies on its own line.
        let out = block.output(&BlockContext {
            mode: DisplayMode::Collapsed,
            width: 30,
            ..ctx()
        });
        assert_eq!(out.lines.len(), 2);
        let summary_line = plain(&out.lines[1]);
        assert!(summary_line.ends_with("stop  [hooks: 1]"));
        assert_eq!(
            unicode_width::UnicodeWidthStr::width(summary_line.as_str()),
            30
        );
        assert!(
            matches!(out.lines[1].selectable, Selectable::None),
            "the overflow summary line is decoration"
        );
    }

    #[test]
    fn stop_hooks_summary_goes_below_wrapped_multi_line_marker() {
        // A wrapped TurnFailed marker whose first line has room for the
        // summary: attaching there would read mid-paragraph, so the summary
        // right-justifies on its own line below the text instead.
        let block = SessionEventBlock::with_stop_hooks(
            SessionEvent::TurnFailed {
                error: format!("boom {}", "x".repeat(70)),
                elapsed: Some(Duration::from_secs(3)),
            },
            vec![stop_group("stop")],
            None,
        );
        let out = block.output(&BlockContext {
            mode: DisplayMode::Collapsed,
            ..ctx()
        });
        assert_eq!(out.lines.len(), 3, "two wrapped text lines + summary line");
        assert!(
            !plain(&out.lines[0]).contains("[hooks:"),
            "no summary interleaved with the wrapped text: {}",
            plain(&out.lines[0])
        );
        let summary_line = plain(&out.lines[2]);
        assert!(summary_line.ends_with("stop  [hooks: 1]"));
        assert!(
            matches!(out.lines[2].selectable, Selectable::None),
            "the summary line is decoration"
        );
    }

    #[test]
    fn stop_hooks_detail_only_when_expanded() {
        let block = completed_with_stop_hooks();
        let collapsed = block.output(&BlockContext {
            mode: DisplayMode::Collapsed,
            ..ctx()
        });
        let collapsed_text = collapsed
            .lines
            .iter()
            .map(plain)
            .collect::<Vec<_>>()
            .join("\n");
        assert!(
            !collapsed_text.contains("global/notify"),
            "collapsed marker hides per-hook detail: {collapsed_text}"
        );

        let expanded = block.output(&ctx());
        let expanded_text = expanded
            .lines
            .iter()
            .map(plain)
            .collect::<Vec<_>>()
            .join("\n");
        assert!(
            expanded_text.contains("global/notify (12ms)"),
            "expanded marker shows per-hook detail: {expanded_text}"
        );
    }

    #[test]
    fn marker_with_stop_hooks_is_interactive_and_starts_collapsed() {
        let block = completed_with_stop_hooks();
        assert!(block.is_foldable(), "fold reveals per-hook detail");
        assert!(block.is_selectable(), "navigable so it can be folded");
        assert_eq!(block.default_display_mode(), DisplayMode::Collapsed);

        // All-skipped batches change nothing (mirrors has_content()).
        use super::super::tool::HookRunStatus;
        let skipped = SessionEventBlock::with_stop_hooks(
            SessionEvent::TurnCompleted {
                elapsed: Some(Duration::from_secs(5)),
            },
            vec![(
                "stop".into(),
                vec![HookRunEntry {
                    name: "h".into(),
                    status: HookRunStatus::Skipped,
                    output: None,
                }],
            )],
            None,
        );
        assert!(!skipped.is_foldable());
        assert!(!skipped.is_selectable());
        let out = skipped.output(&BlockContext {
            mode: DisplayMode::Collapsed,
            ..ctx()
        });
        assert_eq!(plain(&out.lines[0]), "Worked for 5.0s.");
    }

    #[test]
    fn stop_and_stop_failure_groups_render_labeled_sections() {
        let block = SessionEventBlock::with_stop_hooks(
            SessionEvent::TurnFailed {
                error: "boom".into(),
                elapsed: Some(Duration::from_secs(3)),
            },
            vec![stop_group("stop_failure"), stop_group("stop")],
            None,
        );
        let out = block.output(&BlockContext {
            mode: DisplayMode::Collapsed,
            ..ctx()
        });
        let text = plain(&out.lines[0]);
        assert!(
            text.ends_with("stop_failure  [hooks: 1]  stop  [hooks: 1]"),
            "both groups summarized: {text}"
        );

        let expanded = block.output(&ctx());
        let expanded_text = expanded
            .lines
            .iter()
            .map(plain)
            .collect::<Vec<_>>()
            .join("\n");
        assert!(
            expanded_text.contains("stop_failure") && expanded_text.contains("global/notify"),
            "multi-group detail keeps section headers: {expanded_text}"
        );
    }

    /// A marker block with snapshotted work counts (parked by default — the
    /// shape `maybe_push_parked_marker` pushes).
    fn marker_with_work(commands: usize, monitors: usize, subagents: usize) -> SessionEventBlock {
        SessionEventBlock {
            event: SessionEvent::TurnCompleted {
                elapsed: Some(Duration::from_secs(24)),
            },
            stop_hooks: Vec::new(),
            prompt_id: None,
            end_work: Some(EndWork {
                running_commands: commands,
                running_monitors: monitors,
                running_subagents: subagents,
            }),
            parked: true,
        }
    }

    #[test]
    fn end_work_suffix_names_actual_pending_work() {
        let msg = |c, m, s| marker_with_work(c, m, s).marker_text();
        assert_eq!(msg(1, 0, 0), "Worked for 24s. 1 command still running.");
        assert_eq!(msg(0, 0, 1), "Worked for 24s. 1 subagent still running.");
        assert_eq!(msg(3, 0, 0), "Worked for 24s. 3 commands still running.");
        assert_eq!(
            msg(2, 0, 1),
            "Worked for 24s. 2 commands and 1 subagent still running."
        );
        // Monitors are counted apart from commands so a long-lived watch
        // reads as "1 monitor", not a stuck command.
        assert_eq!(msg(0, 1, 0), "Worked for 24s. 1 monitor still running.");
        assert_eq!(
            msg(1, 1, 2),
            "Worked for 24s. 1 command, 1 monitor and 2 subagents still running."
        );
    }

    #[test]
    fn end_work_suffix_disappears_at_zero_counts() {
        // All-zero counts (a sleep-park with nothing running) and a legacy
        // `end_work: None` marker must both render the byte-identical plain
        // completion text.
        assert_eq!(marker_with_work(0, 0, 0).marker_text(), "Worked for 24s.");
        assert_eq!(
            SessionEventBlock::new(SessionEvent::TurnCompleted {
                elapsed: Some(Duration::from_secs(24)),
            })
            .marker_text(),
            "Worked for 24s."
        );
    }

    #[test]
    fn still_running_text_is_the_work_only_status_form() {
        // The between-turns status line reuses the marker suffix verbatim
        // (no turn prefix), and stays absent at zero counts.
        let work = EndWork {
            running_commands: 2,
            running_monitors: 0,
            running_subagents: 1,
        };
        assert_eq!(
            work.still_running_text().as_deref(),
            Some("2 commands and 1 subagent still running.")
        );
        assert_eq!(EndWork::default().still_running_text(), None);
    }

    #[test]
    fn parked_markers_never_accept_stop_hooks() {
        // A parked marker renders mid-turn, before any Stop hook fires.
        let block = marker_with_work(1, 0, 0);
        assert!(!block.accepts_stop_hooks(), "parked marker refuses hooks");

        // The real terminal marker accepts.
        let settled = SessionEventBlock::new(SessionEvent::TurnCompleted {
            elapsed: Some(Duration::from_secs(24)),
        });
        assert!(settled.accepts_stop_hooks());
        // Non-terminal events never accept, parked or not.
        let recap = SessionEventBlock::new(SessionEvent::Recap {
            summary: "did stuff".into(),
            auto: false,
        });
        assert!(!recap.accepts_stop_hooks());
    }

    #[test]
    fn parked_marker_output_reads_as_completed_with_work_suffix() {
        // The rendered block line matches `marker_text` (the suffix rides the
        // same muted line, wrapped like any other session event).
        let block = marker_with_work(1, 0, 2);
        let out = block.output(&ctx());
        assert_eq!(
            plain(&out.lines[0]),
            "Worked for 24s. 1 command and 2 subagents still running."
        );
    }
}
