Codex Handbook
tui/src/history_cell/hook_cell.rs 901 lines
//! History cell for hook execution.//!//! Hooks are intentionally quieter than normal tool calls. A hook that starts and finishes//! successfully without output should not leave a transcript artifact, and very fast hooks should//! not flash in the viewport. This cell keeps that policy local by treating each hook run as a//! small rendering state machine://!//! 1. New runs begin hidden in `PendingReveal`.//! 2. Runs that outlive the reveal delay become visible and may be coalesced with adjacent runs.//! 3. Visible quiet successes linger briefly so they do not disappear in the same frame they were//!    first drawn.//! 4. Completed runs only persist when they have output or a non-success status.use super::HistoryCell;use super::plain_lines;use crate::motion::MotionMode;use crate::motion::ReducedMotionIndicator;use crate::motion::activity_indicator;use crate::motion::shimmer_text;use crate::render::renderable::Renderable;use codex_app_server_protocol::HookEventName;use codex_app_server_protocol::HookOutputEntry;use codex_app_server_protocol::HookOutputEntryKind;use codex_app_server_protocol::HookRunStatus;use codex_app_server_protocol::HookRunSummary;use ratatui::prelude::*;use ratatui::style::Stylize;use ratatui::widgets::Paragraph;use ratatui::widgets::Wrap;use std::time::Duration;use std::time::Instant;#[derive(Debug)]pub(crate) struct HookCell {    /// Hook runs that are active, lingering, or have persistent output to render.    runs: Vec<HookRunCell>,    /// Mirrors the global animation setting so transcript rendering and viewport rendering agree.    animations_enabled: bool,}/// Minimum runtime before a hook is allowed to draw.////// Helps avoids a flash of text forwork that was effectively instant.const HOOK_RUN_REVEAL_DELAY: Duration = Duration::from_millis(300);/// Minimum time a quiet success remains on screen after becoming visible.////// This pairs with `HOOK_RUN_REVEAL_DELAY`: once the user has seen a hook row, keep it stable long/// enough to read instead of removing it immediately when the success event arrives.const QUIET_HOOK_MIN_VISIBLE: Duration = Duration::from_millis(600);const HOOK_OUTPUT_INDENT: &str = "  ";const HOOK_OUTPUT_BODY_INDENT: &str = "    ";#[derive(Debug)]struct HookRunCell {    /// Stable protocol id used to match begin/end updates for the same hook invocation.    id: String,    /// Hook event kind, kept outside `state` so a begin update can refresh metadata in place.    event_name: HookEventName,    /// Optional hook-supplied detail shown next to the running header.    status_message: Option<String>,    /// Rendering lifecycle for this run.    state: HookRunState,}#[derive(Debug)]enum HookRunState {    /// A newly-started run that is active but deliberately hidden until `reveal_deadline`.    PendingReveal {        /// The original start time, used for spinner phase and grouping once revealed.        start_time: Instant,        /// First instant at which the run may become visible.        reveal_deadline: Instant,    },    /// A run that survived the reveal delay and is currently shown as running.    VisibleRunning {        /// The original start time, used to keep animation timing stable across transitions.        start_time: Instant,        /// First instant the run was actually rendered, used by quiet-success linger.        visible_since: Instant,    },    /// A visible run that completed successfully without output but is still lingering briefly.    QuietLinger {        /// The original start time, retained so the spinner does not jump during the linger frame.        start_time: Instant,        /// Instant after which the quiet success can be removed entirely.        removal_deadline: Instant,    },    /// A completed run with output or a status worth preserving in history.    Completed {        /// Final protocol status for the hook invocation.        status: HookRunStatus,        /// Hook output entries rendered below the completed header.        entries: Vec<HookOutputEntry>,    },}#[derive(Debug, PartialEq, Eq)]struct RunningHookGroupKey {    event_name: HookEventName,    status_message: Option<String>,}/// Accumulator for adjacent running hooks that can share one status line.////// Grouping happens only while building display lines, the underlying runs stay separate so their/// protocol ids and completion transitions remain independent.struct RunningHookGroup {    /// Shared event/status pair for every run in this display group.    key: RunningHookGroupKey,    /// Earliest start time in the group, so the combined spinner reflects the oldest work.    start_time: Option<Instant>,    /// Number of adjacent runs represented by the group line.    count: usize,}impl HookCell {    /// Creates a cell around a hook that has just started.    fn new_active(run: HookRunSummary, animations_enabled: bool) -> Self {        let mut cell = Self {            runs: Vec::new(),            animations_enabled,        };        cell.start_run(run);        cell    }    /// Creates a cell around an already-completed hook from transcript/history data.    fn new_completed(run: HookRunSummary, animations_enabled: bool) -> Self {        let mut cell = Self {            runs: Vec::new(),            animations_enabled,        };        cell.add_completed_run(run);        cell    }    pub(crate) fn is_empty(&self) -> bool {        self.runs.is_empty()    }    /// Returns true while any run can still change due to an end event or timer.    pub(crate) fn is_active(&self) -> bool {        self.runs.iter().any(|run| run.state.is_active())    }    /// Completed hook cells are flushed out of the active slot once no timers remain.    pub(crate) fn should_flush(&self) -> bool {        !self.is_active() && !self.is_empty()    }    /// Returns whether this cell has at least one line worth drawing right now.    pub(crate) fn should_render(&self) -> bool {        self.runs.iter().any(|run| run.state.should_render())    }    /// Splits durable completed runs from ephemeral active-cell bookkeeping.    ///    /// Quiet successes are left behind so they can disappear from the active cell, while failures,    /// blocked/stopped hooks, and hooks with emitted output become a persistent history cell.    pub(crate) fn take_completed_persistent_runs(&mut self) -> Option<Self> {        let mut completed = Vec::new();        let mut remaining = Vec::new();        for run in self.runs.drain(..) {            if run.state.has_persistent_output() {                completed.push(run);            } else {                remaining.push(run);            }        }        self.runs = remaining;        (!completed.is_empty()).then_some(Self {            runs: completed,            animations_enabled: self.animations_enabled,        })    }    /// Used by callers that need to know whether the active cell currently occupies viewport space.    pub(crate) fn has_visible_running_run(&self) -> bool {        self.runs.iter().any(|run| run.state.is_running_visible())    }    /// Advances reveal/removal timers and reports whether rendering should be refreshed.    pub(crate) fn advance_time(&mut self, now: Instant) -> bool {        let old_len = self.runs.len();        let mut changed = false;        for run in &mut self.runs {            changed |= run.state.reveal_if_due(now);        }        self.runs.retain(|run| !run.state.quiet_linger_expired(now));        changed || self.runs.len() != old_len    }    /// Inserts or refreshes a started hook run.    ///    /// A duplicate begin event resets the reveal timer rather than adding a second row, because    /// matching by id is the invariant that keeps begin/end events paired.    pub(crate) fn start_run(&mut self, run: HookRunSummary) {        let now = Instant::now();        if let Some(existing) = self.runs.iter_mut().find(|existing| existing.id == run.id) {            existing.event_name = run.event_name;            existing.status_message = run.status_message;            existing.state = HookRunState::pending(now);            return;        }        self.runs.push(HookRunCell {            id: run.id,            event_name: run.event_name,            status_message: run.status_message,            state: HookRunState::pending(now),        });    }    /// Completes a run and returns whether the run was already present in this cell.    ///    /// Quiet successes intentionally avoid persistent output. If they were never visible, they    /// disappear immediately; if they had already drawn, they move into `QuietLinger`.    pub(crate) fn complete_run(&mut self, run: HookRunSummary) -> bool {        let Some(index) = self.runs.iter().position(|existing| existing.id == run.id) else {            return false;        };        if hook_run_is_quiet_success(&run) {            if !self.runs[index]                .state                .complete_quiet_success(Instant::now())            {                self.runs.remove(index);            }            return true;        }        let HookRunSummary {            event_name,            status_message,            status,            entries,            ..        } = run;        let existing = &mut self.runs[index];        existing.event_name = event_name;        existing.status_message = status_message;        existing.state = HookRunState::completed(status, entries);        true    }    /// Adds a completed hook that did not pass through this live cell.    ///    /// This is used for replay/restoration paths where the final run summary is already known.    pub(crate) fn add_completed_run(&mut self, run: HookRunSummary) {        if hook_run_is_quiet_success(&run) {            return;        }        let HookRunSummary {            id,            event_name,            status_message,            status,            entries,            ..        } = run;        self.runs.push(HookRunCell {            id,            event_name,            status_message,            state: HookRunState::completed(status, entries),        });    }    pub(crate) fn next_timer_deadline(&self) -> Option<Instant> {        self.runs            .iter()            .filter_map(|run| run.state.next_timer_deadline())            .min()    }    #[cfg(test)]    pub(crate) fn expire_quiet_runs_now_for_test(&mut self) {        for run in &mut self.runs {            run.expire_quiet_linger_now_for_test();        }    }    #[cfg(test)]    pub(crate) fn reveal_running_runs_now_for_test(&mut self) {        let now = Instant::now();        for run in &mut self.runs {            run.reveal_running_now_for_test(now);        }    }    #[cfg(test)]    pub(crate) fn reveal_running_runs_after_delayed_redraw_for_test(&mut self) {        let now = Instant::now();        for run in &mut self.runs {            run.reveal_running_after_delayed_redraw_for_test(now);        }    }}impl HistoryCell for HookCell {    /// Builds viewport lines while coalescing adjacent visible-running hooks.    fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {        let mut lines = Vec::new();        let mut running_group: Option<RunningHookGroup> = None;        for run in &self.runs {            if !run.state.should_render() {                continue;            }            let Some(key) = run.running_group_key() else {                // Completed runs keep their own output lines, so any pending running group must be                // emitted before drawing the completed run.                if let Some(group) = running_group.take() {                    push_running_hook_group(&mut lines, &group, self.animations_enabled);                }                push_hook_line_separator(&mut lines);                run.push_display_lines(&mut lines, self.animations_enabled);                continue;            };            if let Some(group) = running_group.as_mut()                && group.key == key            {                group.count += 1;                // Preserve the earliest start time so grouped spinners do not reset when a later                // adjacent hook is folded into the same line.                group.start_time = earliest_instant(group.start_time, run.state.start_time());                continue;            }            if let Some(group) =                running_group.replace(RunningHookGroup::new(key, run.state.start_time()))            {                push_running_hook_group(&mut lines, &group, self.animations_enabled);            }        }        if let Some(group) = running_group {            push_running_hook_group(&mut lines, &group, self.animations_enabled);        }        lines    }    /// Hook transcript output matches viewport output.    fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {        self.display_lines(width)    }    fn raw_lines(&self) -> Vec<Line<'static>> {        plain_lines(self.display_lines(u16::MAX))    }    /// Produces a coarse cache key for transcript overlays while hook animations are active.    fn transcript_animation_tick(&self) -> Option<u64> {        if !self.animations_enabled {            return None;        }        let elapsed = self            .runs            .iter()            .filter(|run| run.state.is_running_visible())            .find_map(|run| run.state.start_time())?            .elapsed();        Some(elapsed.as_millis() as u64 / 600)    }}impl Renderable for HookCell {    fn render(&self, area: Rect, buf: &mut Buffer) {        let lines = self.display_lines(area.width);        let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });        paragraph.render(area, buf);    }    fn desired_height(&self, width: u16) -> u16 {        HistoryCell::desired_height(self, width)    }}impl HookRunCell {    #[cfg(test)]    fn expire_quiet_linger_now_for_test(&mut self) {        if let HookRunState::QuietLinger {            removal_deadline, ..        } = &mut self.state        {            *removal_deadline = Instant::now();        }    }    #[cfg(test)]    fn reveal_running_now_for_test(&mut self, now: Instant) {        if let HookRunState::PendingReveal {            reveal_deadline, ..        } = &mut self.state        {            *reveal_deadline = now;        }    }    #[cfg(test)]    fn reveal_running_after_delayed_redraw_for_test(&mut self, now: Instant) {        if let HookRunState::PendingReveal {            reveal_deadline, ..        } = &mut self.state        {            let delayed_deadline = now                .checked_sub(QUIET_HOOK_MIN_VISIBLE + Duration::from_millis(100))                .unwrap_or(now);            *reveal_deadline = delayed_deadline;        }    }    /// Returns the grouping key only for states that render as running.    fn running_group_key(&self) -> Option<RunningHookGroupKey> {        self.state            .is_running_visible()            .then(|| RunningHookGroupKey {                event_name: self.event_name,                status_message: self.status_message.clone(),            })    }    /// Appends the lines for a single, ungrouped hook run.    fn push_display_lines(&self, lines: &mut Vec<Line<'static>>, animations_enabled: bool) {        let label = hook_event_label(self.event_name);        match &self.state {            HookRunState::VisibleRunning { start_time, .. }            | HookRunState::QuietLinger { start_time, .. } => {                let hook_text = format!("Running {label} hook");                push_running_hook_header(                    lines,                    &hook_text,                    Some(*start_time),                    self.status_message.as_deref(),                    animations_enabled,                );            }            HookRunState::Completed { status, entries } => {                let status_text = format!("{status:?}").to_lowercase();                let bullet = hook_completed_bullet(*status, entries);                lines.push(                    vec![                        bullet,                        " ".into(),                        format!("{label} hook ({status_text})").into(),                    ]                    .into(),                );                for entry in entries {                    let prefix = hook_output_prefix(entry.kind);                    let mut output_lines = entry.text.split('\n');                    if let Some(first_line) = output_lines.next() {                        lines.push(format!("{HOOK_OUTPUT_INDENT}{prefix}{first_line}").into());                    }                    for line in output_lines {                        if line.is_empty() {                            lines.push("".into());                        } else {                            lines.push(format!("{HOOK_OUTPUT_BODY_INDENT}{line}").into());                        }                    }                }            }            HookRunState::PendingReveal { .. } => {}        }    }}impl HookRunState {    /// Creates the hidden initial state for a live hook run.    fn pending(start_time: Instant) -> Self {        Self::PendingReveal {            start_time,            reveal_deadline: start_time + HOOK_RUN_REVEAL_DELAY,        }    }    /// Creates the persistent final state for a hook with visible output or a notable status.    fn completed(status: HookRunStatus, entries: Vec<HookOutputEntry>) -> Self {        Self::Completed { status, entries }    }    /// Returns true while the run is still waiting for a completion event or timer cleanup.    fn is_active(&self) -> bool {        match self {            HookRunState::PendingReveal { .. }            | HookRunState::VisibleRunning { .. }            | HookRunState::QuietLinger { .. } => true,            HookRunState::Completed { .. } => false,        }    }    /// Returns true when this run contributes at least one line to the current render.    fn should_render(&self) -> bool {        match self {            HookRunState::VisibleRunning { .. }            | HookRunState::QuietLinger { .. }            | HookRunState::Completed { .. } => true,            HookRunState::PendingReveal { .. } => false,        }    }    /// Returns true for completed runs that should survive outside the active cell.    fn has_persistent_output(&self) -> bool {        match self {            HookRunState::Completed { status, entries } => {                *status != HookRunStatus::Completed || !entries.is_empty()            }            HookRunState::PendingReveal { .. }            | HookRunState::VisibleRunning { .. }            | HookRunState::QuietLinger { .. } => false,        }    }    /// Returns the original start time for active states.    ///    /// Completed runs no longer animate, so they intentionally have no start time.    fn start_time(&self) -> Option<Instant> {        match self {            HookRunState::PendingReveal { start_time, .. }            | HookRunState::VisibleRunning { start_time, .. }            | HookRunState::QuietLinger { start_time, .. } => Some(*start_time),            HookRunState::Completed { .. } => None,        }    }    /// Returns true when the run should be treated as an in-progress row.    fn is_running_visible(&self) -> bool {        matches!(            self,            HookRunState::VisibleRunning { .. } | HookRunState::QuietLinger { .. }        )    }    /// Reveals a pending run once its deadline has passed.    ///    /// Returns true only when this call changes the state, allowing timer callbacks to avoid    /// unnecessary redraws.    fn reveal_if_due(&mut self, now: Instant) -> bool {        let HookRunState::PendingReveal {            start_time,            reveal_deadline,        } = self        else {            return false;        };        if now < *reveal_deadline {            return false;        }        *self = HookRunState::VisibleRunning {            start_time: *start_time,            visible_since: now,        };        true    }    /// Returns the next state-machine deadline owned by this run.    fn next_timer_deadline(&self) -> Option<Instant> {        match self {            HookRunState::PendingReveal {                reveal_deadline, ..            } => Some(*reveal_deadline),            HookRunState::QuietLinger {                removal_deadline, ..            } => Some(*removal_deadline),            HookRunState::VisibleRunning { .. } | HookRunState::Completed { .. } => None,        }    }    /// Returns true once a quiet success has lingered for long enough.    fn quiet_linger_expired(&self, now: Instant) -> bool {        match self {            HookRunState::QuietLinger {                removal_deadline, ..            } => now >= *removal_deadline,            HookRunState::PendingReveal { .. }            | HookRunState::VisibleRunning { .. }            | HookRunState::Completed { .. } => false,        }    }    /// Converts a visible quiet success into a temporary linger state.    ///    /// Returns false when the success should be removed immediately: either it was never visible or    /// it has already stayed visible for the minimum duration.    fn complete_quiet_success(&mut self, now: Instant) -> bool {        let HookRunState::VisibleRunning {            start_time,            visible_since,            ..        } = self        else {            return false;        };        let start_time = *start_time;        let minimum_deadline = *visible_since + QUIET_HOOK_MIN_VISIBLE;        if now >= minimum_deadline {            return false;        }        *self = HookRunState::QuietLinger {            start_time,            removal_deadline: minimum_deadline,        };        true    }}impl RunningHookGroup {    fn new(key: RunningHookGroupKey, start_time: Option<Instant>) -> Self {        Self {            key,            start_time,            count: 1,        }    }}/// Emits one grouped running-hook status row.fn push_running_hook_group(    lines: &mut Vec<Line<'static>>,    group: &RunningHookGroup,    animations_enabled: bool,) {    push_hook_line_separator(lines);    let label = hook_event_label(group.key.event_name);    let hook_text = if group.count == 1 {        format!("Running {label} hook")    } else {        format!("Running {} {label} hooks", group.count)    };    push_running_hook_header(        lines,        &hook_text,        group.start_time,        group.key.status_message.as_deref(),        animations_enabled,    );}/// Emits the animated or static header used by all running hook rows.fn push_running_hook_header(    lines: &mut Vec<Line<'static>>,    hook_text: &str,    start_time: Option<Instant>,    status_message: Option<&str>,    animations_enabled: bool,) {    let mut header = Vec::new();    let motion_mode = MotionMode::from_animations_enabled(animations_enabled);    if let Some(indicator) =        activity_indicator(start_time, motion_mode, ReducedMotionIndicator::Hidden)    {        header.push(indicator);        header.push(" ".into());    }    header.extend(shimmer_text(hook_text, motion_mode));    if !animations_enabled && let Some(span) = header.last_mut() {        span.style = span.style.patch(Style::default().bold());    }    if let Some(status_message) = status_message        && !status_message.is_empty()    {        header.push(": ".into());        header.push(status_message.to_string().dim());    }    lines.push(header.into());}/// Adds a blank separator between hook blocks without leaving a leading blank line.fn push_hook_line_separator(lines: &mut Vec<Line<'static>>) {    if !lines.is_empty() {        lines.push("".into());    }}/// Combines optional instants while preserving the earliest known start time.fn earliest_instant(left: Option<Instant>, right: Option<Instant>) -> Option<Instant> {    match (left, right) {        (Some(left), Some(right)) => Some(left.min(right)),        (Some(left), None) => Some(left),        (None, Some(right)) => Some(right),        (None, None) => None,    }}pub(crate) fn new_active_hook_cell(run: HookRunSummary, animations_enabled: bool) -> HookCell {    HookCell::new_active(run, animations_enabled)}pub(crate) fn new_completed_hook_cell(run: HookRunSummary, animations_enabled: bool) -> HookCell {    HookCell::new_completed(run, animations_enabled)}/// Returns true for hook completions that should be invisible in history.fn hook_run_is_quiet_success(run: &HookRunSummary) -> bool {    run.status == HookRunStatus::Completed && run.entries.is_empty()}fn hook_completed_bullet(status: HookRunStatus, entries: &[HookOutputEntry]) -> Span<'static> {    match status {        HookRunStatus::Completed => {            if entries                .iter()                .any(|entry| entry.kind == HookOutputEntryKind::Warning)            {                "•".bold()            } else {                "•".green().bold()            }        }        HookRunStatus::Blocked | HookRunStatus::Failed | HookRunStatus::Stopped => "•".red().bold(),        HookRunStatus::Running => "•".into(),    }}fn hook_output_prefix(kind: HookOutputEntryKind) -> &'static str {    match kind {        HookOutputEntryKind::Warning => "warning: ",        HookOutputEntryKind::Stop => "stop: ",        HookOutputEntryKind::Feedback => "feedback: ",        HookOutputEntryKind::Context => "hook context: ",        HookOutputEntryKind::Error => "error: ",    }}fn hook_event_label(event_name: HookEventName) -> &'static str {    match event_name {        HookEventName::PreToolUse => "PreToolUse",        HookEventName::PermissionRequest => "PermissionRequest",        HookEventName::PostToolUse => "PostToolUse",        HookEventName::PreCompact => "PreCompact",        HookEventName::PostCompact => "PostCompact",        HookEventName::SessionStart => "SessionStart",        HookEventName::UserPromptSubmit => "UserPromptSubmit",        HookEventName::SubagentStart => "SubagentStart",        HookEventName::SubagentStop => "SubagentStop",        HookEventName::Stop => "Stop",    }}#[cfg(test)]mod tests {    use super::*;    use crate::test_support::PathBufExt;    use crate::test_support::test_path_buf;    use pretty_assertions::assert_eq;    use ratatui::style::Modifier;    #[test]    fn completed_hook_with_warning_uses_default_bold_bullet() {        let entries = vec![HookOutputEntry {            kind: HookOutputEntryKind::Warning,            text: "Heads up from the hook".to_string(),        }];        let bullet = hook_completed_bullet(HookRunStatus::Completed, &entries);        assert_eq!(bullet.content.as_ref(), "•");        assert_eq!(bullet.style.fg, None);        assert!(bullet.style.add_modifier.contains(Modifier::BOLD));    }    #[test]    fn completed_hook_multiline_context_preserves_display_and_raw_lines() {        let cell = completed_hook_cell(            HookEventName::SessionStart,            HookRunStatus::Completed,            vec![HookOutputEntry {                kind: HookOutputEntryKind::Context,                text: "## Working Memory Recall\n\nSource: Codex compaction\nScope: Durable workspace memory"                    .to_string(),            }],        );        let expected = vec![            "• SessionStart hook (completed)".to_string(),            "  hook context: ## Working Memory Recall".to_string(),            "".to_string(),            "    Source: Codex compaction".to_string(),            "    Scope: Durable workspace memory".to_string(),        ];        assert_eq!(line_texts(&cell.display_lines(/*width*/ 80)), expected);        assert_eq!(line_texts(&cell.raw_lines()), expected);    }    #[test]    fn completed_hook_multiline_warning_prefixes_first_line_only() {        let cell = completed_hook_cell(            HookEventName::PostToolUse,            HookRunStatus::Completed,            vec![HookOutputEntry {                kind: HookOutputEntryKind::Warning,                text: "Heads up\nReview generated files".to_string(),            }],        );        assert_eq!(            line_texts(&cell.display_lines(/*width*/ 80)),            vec![                "• PostToolUse hook (completed)".to_string(),                "  warning: Heads up".to_string(),                "    Review generated files".to_string(),            ]        );    }    #[test]    fn pending_hook_does_not_animate_transcript() {        let cell =            HookCell::new_active(hook_run_summary("hook-1"), /*animations_enabled*/ true);        assert_eq!(cell.transcript_animation_tick(), None);    }    #[test]    fn visible_hook_animates_transcript_when_animations_enabled() {        let mut cell =            HookCell::new_active(hook_run_summary("hook-1"), /*animations_enabled*/ true);        cell.reveal_running_runs_now_for_test();        cell.advance_time(Instant::now());        assert_eq!(cell.transcript_animation_tick(), Some(0));    }    #[test]    fn visible_hook_does_not_animate_transcript_when_animations_disabled() {        let mut cell = HookCell::new_active(            hook_run_summary("hook-1"),            /*animations_enabled*/ false,        );        cell.reveal_running_runs_now_for_test();        cell.advance_time(Instant::now());        assert_eq!(cell.transcript_animation_tick(), None);    }    #[test]    fn visible_hook_without_animations_omits_spinner() {        let mut cell = HookCell::new_active(            hook_run_summary("hook-1"),            /*animations_enabled*/ false,        );        cell.reveal_running_runs_now_for_test();        cell.advance_time(Instant::now());        let rendered: Vec<String> = cell            .display_lines(/*width*/ 80)            .iter()            .map(line_text)            .collect();        assert_eq!(            rendered,            vec!["Running PostToolUse hook: checking output policy".to_string()]        );    }    fn completed_hook_cell(        event_name: HookEventName,        status: HookRunStatus,        entries: Vec<HookOutputEntry>,    ) -> HookCell {        let mut run = hook_run_summary("hook-1");        run.event_name = event_name;        run.status = status;        run.status_message = None;        run.completed_at = Some(2);        run.duration_ms = Some(1);        run.entries = entries;        HookCell::new_completed(run, /*animations_enabled*/ false)    }    fn line_texts(lines: &[Line<'_>]) -> Vec<String> {        lines.iter().map(line_text).collect()    }    fn line_text(line: &Line<'_>) -> String {        line.spans            .iter()            .map(|span| span.content.as_ref())            .collect::<String>()    }    fn hook_run_summary(id: &str) -> HookRunSummary {        HookRunSummary {            id: id.to_string(),            event_name: HookEventName::PostToolUse,            handler_type: codex_app_server_protocol::HookHandlerType::Command,            execution_mode: codex_app_server_protocol::HookExecutionMode::Sync,            scope: codex_app_server_protocol::HookScope::Turn,            source_path: test_path_buf("/tmp/hooks.json").abs(),            source: codex_app_server_protocol::HookSource::User,            display_order: 0,            status: HookRunStatus::Running,            status_message: Some("checking output policy".to_string()),            started_at: 1,            completed_at: None,            duration_ms: None,            entries: Vec::new(),        }    }}