use super::HistoryCell;
use super::plain_lines;
use crate::tui_internal::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::tui_internal::motion::MotionMode;
use crate::tui_internal::motion::ReducedMotionIndicator;
use crate::tui_internal::motion::activity_indicator;
use crate::tui_internal::motion::shimmer_text;
use crate::tui_internal::render::line_utils::push_owned_lines;
use crate::tui_internal::render::renderable::Renderable;
use crate::tui_internal::ui_consts::TRANSCRIPT_HINT;
use crate::tui_internal::wrapping::RtOptions;
use crate::tui_internal::wrapping::word_wrap_line;
use lemurclaw_core::app_server_protocol::HookEventName;
use lemurclaw_core::app_server_protocol::HookOutputEntry;
use lemurclaw_core::app_server_protocol::HookOutputEntryKind;
use lemurclaw_core::app_server_protocol::HookRunStatus;
use lemurclaw_core::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 {
runs: Vec<HookRunCell>,
animations_enabled: bool,
}
const HOOK_RUN_REVEAL_DELAY: Duration = Duration::from_millis(300);
const QUIET_HOOK_MIN_VISIBLE: Duration = Duration::from_millis(600);
const HOOK_OUTPUT_INDENT: &str = " ";
const HOOK_OUTPUT_BODY_INDENT: &str = " ";
const HOOK_CONTEXT_MAX_DISPLAY_ROWS: usize = 3;
#[derive(Debug)]
struct HookRunCell {
id: String,
event_name: HookEventName,
status_message: Option<String>,
state: HookRunState,
}
#[derive(Debug)]
enum HookRunState {
PendingReveal {
start_time: Instant,
reveal_deadline: Instant,
},
VisibleRunning {
start_time: Instant,
visible_since: Instant,
},
QuietLinger {
start_time: Instant,
removal_deadline: Instant,
},
Completed {
status: HookRunStatus,
entries: Vec<HookOutputEntry>,
},
}
#[derive(Debug, PartialEq, Eq)]
struct RunningHookGroupKey {
event_name: HookEventName,
status_message: Option<String>,
}
struct RunningHookGroup {
key: RunningHookGroupKey,
start_time: Option<Instant>,
count: usize,
}
impl HookCell {
fn new_active(run: HookRunSummary, animations_enabled: bool) -> Self {
let mut cell = Self {
runs: Vec::new(),
animations_enabled,
};
cell.start_run(run);
cell
}
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()
}
pub(crate) fn is_active(&self) -> bool {
self.runs.iter().any(|run| run.state.is_active())
}
pub(crate) fn should_flush(&self) -> bool {
!self.is_active() && !self.is_empty()
}
pub(crate) fn should_render(&self) -> bool {
self.runs.iter().any(|run| run.state.should_render())
}
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,
})
}
pub(crate) fn has_visible_running_run(&self) -> bool {
self.runs.iter().any(|run| run.state.is_running_visible())
}
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
}
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),
});
}
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
}
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);
}
}
fn output_lines(&self, width: u16, render_full_context: bool) -> 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 {
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,
width,
render_full_context,
);
continue;
};
if let Some(group) = running_group.as_mut()
&& group.key == key
{
group.count += 1;
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
}
}
impl HistoryCell for HookCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
self.output_lines(width, false)
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.output_lines(width, true)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.output_lines(u16::MAX, true))
}
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;
}
}
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(),
})
}
fn push_display_lines(
&self,
lines: &mut Vec<Line<'static>>,
animations_enabled: bool,
width: u16,
render_full_context: 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 {
if !render_full_context && entry.kind == HookOutputEntryKind::Context {
lines.extend(hook_context_preview_lines(&entry.text, width));
} else {
push_full_hook_output_entry(lines, entry);
}
}
}
HookRunState::PendingReveal { .. } => {}
}
}
}
fn push_full_hook_output_entry(lines: &mut Vec<Line<'static>>, entry: &HookOutputEntry) {
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());
}
}
}
fn hook_context_preview_lines(text: &str, width: u16) -> Vec<Line<'static>> {
let width = usize::from(width.max(1));
let mut wrapped = Vec::new();
let mut source_lines = text.split('\n');
let first_line = source_lines.next().unwrap_or_default();
push_wrapped_hook_context_line(
&mut wrapped,
first_line,
width,
Line::from(format!(
"{HOOK_OUTPUT_INDENT}{}",
hook_output_prefix(HookOutputEntryKind::Context)
)),
);
for line in source_lines {
if line.is_empty() {
wrapped.push("".into());
} else {
push_wrapped_hook_context_line(
&mut wrapped,
line,
width,
Line::from(HOOK_OUTPUT_BODY_INDENT),
);
}
}
if wrapped.len() <= HOOK_CONTEXT_MAX_DISPLAY_ROWS {
return wrapped;
}
let retained_rows = HOOK_CONTEXT_MAX_DISPLAY_ROWS - 1;
let omitted_rows = wrapped.len() - retained_rows;
wrapped.truncate(retained_rows);
let hint = vec![
HOOK_OUTPUT_BODY_INDENT.into(),
format!("… +{omitted_rows} lines ({TRANSCRIPT_HINT})").dim(),
]
.into();
wrapped.push(truncate_line_with_ellipsis_if_overflow(hint, width));
wrapped
}
fn push_wrapped_hook_context_line(
output: &mut Vec<Line<'static>>,
text: &str,
width: usize,
initial_indent: Line<'static>,
) {
let line = Line::from(text.to_string());
let wrapped = word_wrap_line(
&line,
RtOptions::new(width)
.initial_indent(initial_indent)
.subsequent_indent(Line::from(HOOK_OUTPUT_BODY_INDENT)),
);
push_owned_lines(&wrapped, output);
}
impl HookRunState {
fn pending(start_time: Instant) -> Self {
Self::PendingReveal {
start_time,
reveal_deadline: start_time + HOOK_RUN_REVEAL_DELAY,
}
}
fn completed(status: HookRunStatus, entries: Vec<HookOutputEntry>) -> Self {
Self::Completed { status, entries }
}
fn is_active(&self) -> bool {
match self {
HookRunState::PendingReveal { .. }
| HookRunState::VisibleRunning { .. }
| HookRunState::QuietLinger { .. } => true,
HookRunState::Completed { .. } => false,
}
}
fn should_render(&self) -> bool {
match self {
HookRunState::VisibleRunning { .. }
| HookRunState::QuietLinger { .. }
| HookRunState::Completed { .. } => true,
HookRunState::PendingReveal { .. } => false,
}
}
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,
}
}
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,
}
}
fn is_running_visible(&self) -> bool {
matches!(
self,
HookRunState::VisibleRunning { .. } | HookRunState::QuietLinger { .. }
)
}
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
}
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,
}
}
fn quiet_linger_expired(&self, now: Instant) -> bool {
match self {
HookRunState::QuietLinger {
removal_deadline, ..
} => now >= *removal_deadline,
HookRunState::PendingReveal { .. }
| HookRunState::VisibleRunning { .. }
| HookRunState::Completed { .. } => false,
}
}
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,
}
}
}
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,
);
}
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());
}
fn push_hook_line_separator(lines: &mut Vec<Line<'static>>) {
if !lines.is_empty() {
lines.push("".into());
}
}
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)
}
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::SessionEnd => "SessionEnd",
HookEventName::UserPromptSubmit => "UserPromptSubmit",
HookEventName::SubagentStart => "SubagentStart",
HookEventName::SubagentStop => "SubagentStop",
HookEventName::Stop => "Stop",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui_internal::test_support::PathBufExt;
use crate::tui_internal::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_short_multiline_context_preserves_display_transcript_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".to_string(),
}],
);
let expected = vec![
"• SessionStart hook (completed)".to_string(),
" hook context: ## Working Memory Recall".to_string(),
"".to_string(),
" Source: Codex compaction".to_string(),
];
assert_eq!(line_texts(&cell.display_lines( 80)), expected);
assert_eq!(line_texts(&cell.transcript_lines( 80)), expected);
assert_eq!(line_texts(&cell.raw_lines()), expected);
}
#[test]
fn completed_hook_long_single_line_context_is_truncated_only_in_display() {
let full_context = format!(
"{}tail-marker",
"context words that should wrap across the terminal width ".repeat(8)
);
let cell = completed_hook_cell(
HookEventName::SessionStart,
HookRunStatus::Completed,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: full_context.clone(),
}],
);
let display_lines = cell.display_lines( 80);
let display = line_texts(&display_lines);
assert_eq!(display.len(), 4);
assert_eq!(
Paragraph::new(Text::from(display_lines[1..].to_vec()))
.wrap(Wrap { trim: false })
.line_count( 80),
HOOK_CONTEXT_MAX_DISPLAY_ROWS
);
assert!(
display
.iter()
.any(|line| line.contains("ctrl + t to view transcript")),
"expected truncated context to advertise the transcript: {display:?}"
);
assert!(display.iter().all(|line| !line.contains("tail-marker")));
let expected_full = vec![
"• SessionStart hook (completed)".to_string(),
format!(" hook context: {full_context}"),
];
assert_eq!(
line_texts(&cell.transcript_lines( 80)),
expected_full
);
assert_eq!(line_texts(&cell.raw_lines()), expected_full);
}
#[test]
fn completed_hook_non_context_entries_are_not_truncated() {
for kind in [
HookOutputEntryKind::Warning,
HookOutputEntryKind::Stop,
HookOutputEntryKind::Feedback,
HookOutputEntryKind::Error,
] {
let cell = completed_hook_cell(
HookEventName::UserPromptSubmit,
HookRunStatus::Stopped,
vec![HookOutputEntry {
kind,
text: "first\nsecond\nthird\nfourth\nfifth".to_string(),
}],
);
let display = line_texts(&cell.display_lines( 20));
assert!(
display.iter().any(|line| line == " fifth"),
"expected {kind:?} output to remain complete: {display:?}"
);
assert!(
display
.iter()
.all(|line| !line.contains("ctrl + t to view transcript")),
"did not expect a transcript hint for {kind:?}: {display:?}"
);
}
}
#[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( 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"), 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"), 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"),
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"),
false,
);
cell.reveal_running_runs_now_for_test();
cell.advance_time(Instant::now());
let rendered: Vec<String> = cell
.display_lines( 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, 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: lemurclaw_core::app_server_protocol::HookHandlerType::Command,
execution_mode: lemurclaw_core::app_server_protocol::HookExecutionMode::Sync,
scope: lemurclaw_core::app_server_protocol::HookScope::Turn,
source_path: test_path_buf("/tmp/hooks.json").abs(),
source: lemurclaw_core::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(),
}
}
}