pub mod chips;
pub mod edges;
pub mod nodes;
pub mod panel;
use rataflow::{Background, MiniMap, MiniMapPosition};
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph, Sparkline, SparklineBar};
use crate::state::session::LogKind;
use crate::state::{App, Camera, Mode, Transport};
pub fn draw(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let show_scrubber = app.timeline.has_span();
let show_log = show_scrubber && !app.session.prompts.is_empty();
let (canvas_area, timeline_area, status_area) = if show_scrubber {
let panel_h = if show_log { 8 } else { 6 };
let [canvas, panel, status] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(panel_h),
Constraint::Length(1),
])
.areas(area);
(canvas, Some(panel), status)
} else {
let [canvas, status] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);
(canvas, None, status)
};
app.scrubber_area = None;
let selected = app.selected_agent_id();
let (flow_area, panel_area) = if selected.is_some() {
let [left, right] =
Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)])
.areas(canvas_area);
(left, Some(right))
} else {
(canvas_area, None)
};
render_canvas(frame, flow_area, app, selected.is_none());
if let Some(id) = app.pending_center.take() {
app.center_node(&id, false);
}
if let (Some(panel_area), Some(id)) = (panel_area, selected.as_ref()) {
panel::render(frame, panel_area, app, id);
}
if let Some(panel) = timeline_area {
render_timeline_panel(frame, panel, app, show_log);
}
render_status_bar(frame, status_area, app);
if app.show_help {
render_help(frame, area, &app.flow.theme.palette());
}
if app.show_info {
render_info(frame, area, app);
}
}
fn render_info(frame: &mut Frame, area: Rect, app: &App) {
let palette = app.flow.theme.palette();
let w = area.width.min(54);
let h = area.height.min(11);
if w < 24 || h < 7 {
return;
}
let popup = Rect::new(
area.x + (area.width - w) / 2,
area.y + (area.height - h) / 2,
w,
h,
);
frame.render_widget(Clear, popup);
let bg = Style::default().bg(palette.surface);
let key = bg.fg(palette.accent).add_modifier(Modifier::BOLD);
let txt = bg.fg(palette.text);
let dim = bg.fg(palette.subtle);
let info = &app.session_info;
let value_w = (w as usize).saturating_sub(12);
let row = |label: &'static str, value: String, style: Style| {
Line::from(vec![
Span::styled(format!(" {label:<8} "), key),
Span::styled(truncate(&value, value_w), style),
])
};
let dash = "—".to_string();
let lines = vec![
Line::from(""),
row(
"title",
info.title.clone().unwrap_or_else(|| dash.clone()),
txt,
),
row(
"perms",
info.permission_mode.clone().unwrap_or_else(|| dash.clone()),
txt,
),
row(
"mode",
info.mode.clone().unwrap_or_else(|| dash.clone()),
txt,
),
row(
"queued",
format!("{} · {} file edits", info.queued_ops, info.file_snapshots),
dim,
),
row(
"last",
info.last_prompt
.as_deref()
.map(|p| format!("\"{p}\""))
.unwrap_or_else(|| dash.clone()),
dim,
),
];
let block = Block::default()
.borders(Borders::ALL)
.border_style(bg.fg(palette.accent))
.style(bg)
.title_top(
Line::from(" session ")
.centered()
.style(bg.fg(palette.text).add_modifier(Modifier::BOLD)),
)
.title_bottom(Line::from(" i / esc to close ").centered().style(dim));
frame.render_widget(Paragraph::new(lines).block(block), popup);
}
pub(crate) struct ScrubberTally {
pub len: usize,
pub width: usize,
pub counts: Vec<u64>,
pub spawn_at: Vec<bool>,
pub fail_at: Vec<bool>,
pub maxc: u64,
pub gaps: Vec<usize>,
pub prompts: Vec<usize>,
}
pub(crate) fn compute_scrubber_tally(
items: &[crate::tailer::ReplayItem],
width: usize,
floor: usize,
) -> ScrubberTally {
let len = items.len();
let last = (width.saturating_sub(1)).max(1) as f64;
let reach = len.saturating_sub(floor);
let col_idx = |c: usize| -> usize {
(floor + ((c as f64 / last) * reach as f64).round() as usize).min(len)
};
let meta_tool_use_ids: std::collections::BTreeSet<&str> = items
.iter()
.filter_map(|it| match &it.update {
crate::tailer::Update::SubagentMeta { meta, .. } => meta.tool_use_id.as_deref(),
_ => None,
})
.collect();
let mut counts = vec![0u64; width];
let mut spawn_at = vec![false; width];
let mut fail_at = vec![false; width];
for c in 0..width {
let (a, b) = (col_idx(c), if c + 1 < width { col_idx(c + 1) } else { len });
for it in &items[a..b] {
match &it.update {
crate::tailer::Update::Entry { entry, .. } => {
counts[c] += entry.tool_use_count() as u64;
spawn_at[c] |= entry
.spawn_tool_use_ids()
.iter()
.any(|id| !meta_tool_use_ids.contains(id));
fail_at[c] |= entry.tool_failure_count() > 0;
}
crate::tailer::Update::SubagentMeta { .. } => spawn_at[c] = true,
}
}
}
let maxc = counts.iter().copied().max().unwrap_or(0);
ScrubberTally {
len,
width,
counts,
spawn_at,
fail_at,
maxc,
gaps: Vec::new(),
prompts: Vec::new(),
}
}
fn render_scrubber(frame: &mut Frame, area: Rect, app: &mut App) {
if area.width < 8 || area.height < 4 {
return;
}
let palette = app.flow.theme.palette();
let bg = Style::default().bg(palette.surface);
let [marker_row, bars_area, info_row] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(2),
Constraint::Length(1),
])
.areas(area);
let transport = app.transport();
app.scrubber_area = Some(Rect::new(
marker_row.x,
marker_row.y,
marker_row.width,
marker_row.height + bars_area.height,
));
let width = bars_area.width as usize;
let len = app.timeline.items.len();
if width >= 2 && len > 0 {
let p = app.timeline.progress().clamp(0.0, 1.0);
let head = ((p * (width - 1) as f64).round() as usize).min(width - 1);
let last = (width - 1) as f64;
let stale = app
.scrubber_tally
.as_ref()
.is_none_or(|t| t.len != len || t.width != width);
if stale {
let floor = app.timeline.floor();
let mut tally = compute_scrubber_tally(&app.timeline.items, width, floor);
tally.gaps = app.timeline.gap_markers();
tally.prompts = app.timeline.prompt_markers();
app.scrubber_tally = Some(tally);
}
let tally = app.scrubber_tally.as_ref().unwrap();
let counts = &tally.counts;
let spawn_at = &tally.spawn_at;
let fail_at = &tally.fail_at;
let maxc = tally.maxc;
let levels = (bars_area.height as u64) * 8;
let bars: Vec<SparklineBar> = counts
.iter()
.enumerate()
.map(|(c, &cnt)| {
let lvl = if cnt == 0 || maxc == 0 {
0
} else {
((cnt as f64 / maxc as f64) * levels as f64).ceil() as u64
};
let color = if c < head {
palette.accent
} else {
palette.subtle
};
SparklineBar::from(lvl).style(bg.fg(color))
})
.collect();
frame.render_widget(
Sparkline::default().data(bars).max(levels).style(bg),
bars_area,
);
let buf = frame.buffer_mut();
let marker_y = marker_row.y;
let bars_bot = bars_area.y + bars_area.height - 1;
let x = |col: usize| marker_row.x + col as u16;
if app.timeline.compress_gaps {
for &gi in &tally.gaps {
let col = (app.timeline.bar_fraction_for_index(gi) * last).round() as usize;
if col < width {
buf[(x(col), marker_y)]
.set_symbol("»")
.set_style(bg.fg(palette.subtle));
}
}
}
for &pi in &tally.prompts {
let col = (app.timeline.bar_fraction_for_index(pi) * last).round() as usize;
if col < width {
let (glyph, style) = if col < head {
("◆", bg.fg(palette.accent).add_modifier(Modifier::BOLD))
} else {
("◇", bg.fg(palette.subtle))
};
buf[(x(col), marker_y)].set_symbol(glyph).set_style(style);
}
}
for c in (0..head).filter(|&c| spawn_at[c]) {
buf[(x(c), marker_y)]
.set_symbol("❋")
.set_style(bg.fg(Color::Indexed(173)).add_modifier(Modifier::BOLD));
}
for c in (0..head).filter(|&c| fail_at[c]) {
buf[(x(c), marker_y)]
.set_symbol("✗")
.set_style(bg.fg(palette.error).add_modifier(Modifier::BOLD));
}
let ph_style = Style::default()
.fg(palette.accent)
.bg(palette.muted)
.add_modifier(Modifier::BOLD);
for y in marker_y..=bars_bot {
buf[(x(head), y)].set_symbol("│").set_style(ph_style);
}
}
let clock = match app.timeline.cursor {
Some(c) => c
.with_timezone(&chrono::Local)
.format("%b %d %H:%M:%S")
.to_string(),
None => "—".to_string(),
};
let (tag, tag_style) = match transport {
Transport::Live => (
"● LIVE",
bg.fg(palette.success).add_modifier(Modifier::BOLD),
),
Transport::Playing => ("▶ play", bg.fg(palette.accent)),
Transport::Paused => ("⏸ paused", bg.fg(palette.accent)),
Transport::History => ("⏮ history", bg.fg(palette.subtle)),
Transport::Idle => ("■ end", bg.fg(palette.subtle)),
};
let tag_cols = tag.chars().count() as u16;
let [clock_area, tag_area] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(tag_cols + 1)]).areas(info_row);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(clock, bg.fg(palette.subtle)))).style(bg),
clock_area,
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(tag, tag_style)))
.style(bg)
.alignment(Alignment::Right),
tag_area,
);
}
fn render_timeline_panel(frame: &mut Frame, area: Rect, app: &mut App, show_log: bool) {
if area.width < 8 || area.height < 6 {
return;
}
let palette = app.flow.theme.palette();
let bg = Style::default().bg(palette.surface);
let border = bg.fg(palette.subtle);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border)
.style(bg);
let inner = block.inner(area);
frame.render_widget(block, area);
let content = if show_log {
let [log_row, div_row, scrub] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(4),
])
.areas(inner);
render_log_line(frame, log_row, app);
let buf = frame.buffer_mut();
let dy = div_row.y;
buf[(area.x, dy)].set_symbol("├").set_style(border);
for cx in inner.x..inner.x + inner.width {
buf[(cx, dy)].set_symbol("─").set_style(border);
}
buf[(area.x + area.width - 1, dy)]
.set_symbol("┤")
.set_style(border);
scrub
} else {
inner
};
render_scrubber(frame, content, app);
}
fn render_log_line(frame: &mut Frame, row: Rect, app: &App) {
let meta_tool_use_ids: std::collections::BTreeSet<String> = app
.timeline
.items
.iter()
.filter_map(|it| match &it.update {
crate::tailer::Update::SubagentMeta { meta, .. } => meta.tool_use_id.clone(),
_ => None,
})
.collect();
let Some(ev) = app
.session
.latest_event_at(app.timeline.cursor, &meta_tool_use_ids)
else {
return;
};
let palette = app.flow.theme.palette();
let bg = Style::default().bg(palette.surface);
let r = Rect::new(row.x + 1, row.y, row.width.saturating_sub(2), row.height);
if r.width == 0 {
return;
}
let time = ev
.ts
.with_timezone(&chrono::Local)
.format("%H:%M:%S")
.to_string();
let (icon, icon_style) = match ev.kind {
LogKind::Prompt => ("◆ ", bg.fg(palette.accent).add_modifier(Modifier::BOLD)),
LogKind::Spawn => (
"❋ ",
bg.fg(Color::Indexed(173)).add_modifier(Modifier::BOLD),
),
LogKind::Failure => ("✗ ", bg.fg(palette.error).add_modifier(Modifier::BOLD)),
};
let tw = (r.width as usize).saturating_sub(time.chars().count() + 3);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(format!("{time} "), bg.fg(palette.subtle)),
Span::styled(icon, icon_style),
Span::styled(truncate(&ev.text, tw), bg.fg(palette.text)),
]))
.style(bg),
r,
);
}
fn render_help(frame: &mut Frame, area: Rect, palette: &rataflow::Palette) {
let w = area.width.min(60);
let h = area.height.min(18);
if w < 24 || h < 9 {
return;
}
let popup = Rect::new(
area.x + (area.width - w) / 2,
area.y + (area.height - h) / 2,
w,
h,
);
frame.render_widget(Clear, popup);
let bg = Style::default().bg(palette.surface);
let key = bg.fg(palette.accent).add_modifier(Modifier::BOLD);
let txt = bg.fg(palette.text);
let dim = bg.fg(palette.subtle);
let quit_hint = if cfg!(target_arch = "wasm32") {
"close the browser tab"
} else {
"q · ctrl-c"
};
let lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" camera ", key),
Span::styled("o overview · f follow · pan/zoom = manual", txt),
]),
Line::from(vec![
Span::styled(" layout ", key),
Span::styled("r rearrange the graph", txt),
]),
Line::from(vec![
Span::styled(" navigate ", key),
Span::styled("tab / shift-tab cycle · ↑↓←→ · click select", txt),
]),
Line::from(vec![
Span::styled(" viewport ", key),
Span::styled("h j k l pan · + - zoom · 0 reset · c center", txt),
]),
Line::from(vec![
Span::styled(" panel ", key),
Span::styled("j/k · pgup/pgdn scroll · esc close", txt),
]),
Line::from(vec![
Span::styled(" timeline ", key),
Span::styled("[ ] step prompts ", txt),
Span::styled("◆", bg.fg(palette.accent)),
Span::styled(" · End/g live · drag to seek", txt),
]),
Line::from(vec![
Span::styled(" pacing ", key),
Span::styled("s skip idle gaps (on = »; off = real-time)", txt),
]),
Line::from(vec![
Span::styled(" info ", key),
Span::styled("i session details (mode, prompts, …)", txt),
]),
Line::from(vec![
Span::styled(" replay ", key),
Span::styled("space pause/resume", txt),
]),
Line::from(vec![
Span::styled(" quit ", key),
Span::styled(quit_hint, txt),
]),
Line::from(""),
Line::from(vec![
Span::styled(" status ", key),
Span::styled("● ", bg.fg(palette.success)),
Span::styled("active/running ", txt),
Span::styled("◌ ", dim),
Span::styled("idle ", txt),
Span::styled("✓ ", bg.fg(palette.accent)),
Span::styled("done ", txt),
Span::styled("✗ ", bg.fg(palette.error)),
Span::styled("failed", txt),
]),
Line::from(vec![
Span::styled(" ", key),
Span::styled("green edges = agent running", dim),
]),
];
let block = Block::default()
.borders(Borders::ALL)
.border_style(bg.fg(palette.accent))
.style(bg)
.title_top(
Line::from(" zoetrope — keys ")
.centered()
.style(bg.fg(palette.text).add_modifier(Modifier::BOLD)),
)
.title_bottom(Line::from(" ? or esc to close ").centered().style(dim));
frame.render_widget(Paragraph::new(lines).block(block), popup);
}
fn render_canvas(frame: &mut Frame, area: Rect, app: &mut App, show_minimap: bool) {
if area.width == 0 || area.height == 0 {
return;
}
frame.render_widget(Background::new(&app.flow), area);
frame.render_widget(&mut app.flow, area);
let now = app.timeline.now_reference();
chips::render(&app.chips, &app.flow, &app.session, now, frame.buffer_mut());
if show_minimap {
frame.render_widget(
MiniMap::new(&app.flow).position(MiniMapPosition::TopRight),
area,
);
}
}
fn render_status_bar(frame: &mut Frame, area: Rect, app: &App) {
if area.width == 0 || area.height == 0 {
return;
}
let palette = app.flow.theme.palette();
let bg = Style::default().bg(palette.surface);
let title = app.session_info.title.as_deref().unwrap_or("session");
let (badge, badge_color) = match app.transport() {
Transport::Live => (" ● LIVE ", palette.success),
Transport::Playing => (" ▶ PLAY ", palette.accent),
Transport::Paused => (" ⏸ PAUSE ", palette.accent),
Transport::History => (" ⏮ PAST ", palette.subtle),
Transport::Idle => (" ■ IDLE ", palette.subtle),
};
let mut left: Vec<Span> = vec![
Span::styled(
" zoetrope ",
Style::default()
.bg(palette.accent)
.fg(palette.canvas_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ", bg),
Span::styled(
badge,
bg.fg(palette.canvas_bg)
.bg(badge_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(" ", bg),
Span::styled(
truncate(title, 40),
bg.fg(palette.text).add_modifier(Modifier::BOLD),
),
];
left.push(Span::styled(
format!(
" {} agents · {} tools",
app.session.agent_count(),
app.session.tool_count()
),
bg.fg(palette.subtle),
));
match app.camera {
Camera::Overview => left.push(Span::styled(" ⌖ overview", bg.fg(palette.subtle))),
Camera::Follow => left.push(Span::styled(" ⌖ follow", bg.fg(palette.subtle))),
Camera::Manual => {}
}
if let Some(err) = &app.last_error {
left.push(Span::styled(
format!(" ⚠ {}", truncate(err, 50)),
bg.fg(palette.error).add_modifier(Modifier::BOLD),
));
}
let quit = if cfg!(target_arch = "wasm32") {
""
} else {
"q quit · "
};
let hints = if app.mode == Mode::Replay {
format!("{quit}? help · space pause")
} else {
format!("{quit}? help")
};
let hints_cols = hints.chars().count() as u16;
let [left_area, right_area] =
Layout::horizontal([Constraint::Fill(1), Constraint::Length(hints_cols + 1)]).areas(area);
frame.render_widget(Paragraph::new(Line::from(left)).style(bg), left_area);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(hints, bg.fg(palette.muted))))
.style(bg)
.alignment(Alignment::Right),
right_area,
);
}
pub(crate) fn status_color(
status: crate::state::session::AgentStatus,
palette: &rataflow::Palette,
) -> ratatui::style::Color {
use crate::state::session::AgentStatus;
match status {
AgentStatus::Running => palette.success,
AgentStatus::Idle => palette.subtle,
AgentStatus::Done => palette.accent,
AgentStatus::Failed => palette.error,
AgentStatus::Stopped => palette.muted,
}
}
pub(crate) fn truncate(s: &str, max: usize) -> String {
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
if max == 0 {
return String::new();
}
if s.width() <= max {
return s.to_string();
}
if max == 1 {
return "…".to_string();
}
let mut out = String::new();
let mut w = 0;
for ch in s.chars() {
let cw = ch.width().unwrap_or(0);
if w + cw > max - 1 {
break;
}
w += cw;
out.push(ch);
}
out.push('…');
out
}
pub(crate) fn wrap(text: &str, width: usize, max_lines: usize) -> Vec<String> {
use unicode_width::UnicodeWidthStr;
if width == 0 || max_lines == 0 {
return Vec::new();
}
let mut lines: Vec<String> = Vec::new();
let mut cur = String::new();
let mut cur_w = 0usize;
for word in text.split_whitespace() {
let wlen = word.width();
let fits = if cur.is_empty() {
wlen <= width
} else {
cur_w + 1 + wlen <= width
};
if fits {
if !cur.is_empty() {
cur.push(' ');
cur_w += 1;
}
cur.push_str(word);
cur_w += wlen;
} else if wlen > width {
if !cur.is_empty() {
lines.push(std::mem::take(&mut cur));
}
let mut rest = word;
while rest.width() > width {
let cut = split_at_width(rest, width);
lines.push(rest[..cut].to_string());
rest = &rest[cut..];
}
cur = rest.to_string();
cur_w = cur.width();
} else {
lines.push(std::mem::take(&mut cur));
cur = word.to_string();
cur_w = wlen;
}
}
if !cur.is_empty() {
lines.push(cur);
}
if lines.len() > max_lines {
lines.truncate(max_lines);
if let Some(last) = lines.last_mut() {
*last = truncate(&format!("{last} …"), width);
}
}
lines
}
fn split_at_width(s: &str, cols: usize) -> usize {
use unicode_width::UnicodeWidthChar;
let mut w = 0;
for (i, ch) in s.char_indices() {
let cw = ch.width().unwrap_or(0);
if w + cw > cols && i > 0 {
return i;
}
w += cw;
}
s.len()
}
pub(crate) fn truncate_tail(s: &str, max: usize) -> String {
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
if max == 0 {
return String::new();
}
if s.width() <= max {
return s.to_string();
}
if max == 1 {
return "…".to_string();
}
let mut w = 0;
let mut start = s.len();
for (i, ch) in s.char_indices().rev() {
let cw = ch.width().unwrap_or(0);
if w + cw > max - 1 {
break;
}
w += cw;
start = i;
}
format!("…{}", &s[start..])
}
#[cfg(test)]
mod tests {
use super::{compute_scrubber_tally, truncate, truncate_tail, wrap};
use crate::tailer::{ReplayItem, Source, Update};
#[test]
fn truncate_basic() {
assert_eq!(truncate("session title", 7), "sessio…");
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("", 5), "");
}
#[test]
fn wrap_word_wraps_and_caps() {
assert_eq!(
wrap("one two three four", 8, 5),
vec!["one two", "three", "four"]
);
assert_eq!(wrap("aaa bbb ccc ddd", 3, 2), vec!["aaa", "bb…"]);
assert_eq!(wrap("short", 20, 3), vec!["short"]);
}
#[test]
fn truncate_tail_keeps_the_basename() {
assert_eq!(truncate_tail("src/state/timeline.rs", 12), "…timeline.rs");
assert_eq!(truncate_tail("Cargo.toml", 20), "Cargo.toml");
}
#[test]
fn wide_text_stays_within_column_budgets() {
use unicode_width::UnicodeWidthStr;
let s = "日本語テスト"; let out = truncate(s, 6);
assert!(out.width() <= 6, "{out:?} is {} columns", out.width());
let tail = truncate_tail(s, 6);
assert!(tail.width() <= 6, "{tail:?} is {} columns", tail.width());
for line in wrap("修复解析错误 and fix the parser", 6, usize::MAX) {
assert!(line.width() <= 6, "{line:?} is {} columns", line.width());
}
}
#[test]
fn scrubber_tally_bins_tools_spawns_and_failures() {
let assistant = r#"{"type":"assistant","uuid":"a","timestamp":"2026-06-05T10:00:01.000Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}},{"type":"tool_use","id":"t2","name":"Agent","input":{}}]}}"#;
let failure = r#"{"type":"user","uuid":"u","timestamp":"2026-06-05T10:00:02.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","is_error":true}]}}"#;
let item = |line: &str, t: &str| {
ReplayItem::at(
Some(t.parse().unwrap()),
Update::Entry {
source: Source::Main,
entry: crate::transcript::parse_line(line).unwrap(),
},
)
};
let items = vec![
item(assistant, "2026-06-05T10:00:01.000Z"),
item(failure, "2026-06-05T10:00:02.000Z"),
];
let t = compute_scrubber_tally(&items, 4, 0);
assert_eq!(t.len, 2);
assert_eq!(t.width, 4);
assert_eq!(t.counts.iter().sum::<u64>(), 2);
assert_eq!(t.maxc, 2, "both tool_use land in the same column");
assert!(t.spawn_at.iter().any(|&s| s), "the Agent spawn is flagged");
assert!(
t.fail_at.iter().any(|&f| f),
"the errored result is flagged"
);
}
#[test]
fn spawn_is_marked_at_birth_meta_with_the_call_as_a_no_meta_fallback() {
let ts = "2026-06-05T10:00:01.000Z";
let call = format!(
r#"{{"type":"assistant","uuid":"a","timestamp":"{ts}","message":{{"role":"assistant","content":[{{"type":"tool_use","id":"toolu_1","name":"Agent","input":{{}}}}]}}}}"#
);
let call_item = || {
ReplayItem::at(
Some(ts.parse().unwrap()),
Update::Entry {
source: Source::Main,
entry: crate::transcript::parse_line(&call).unwrap(),
},
)
};
let meta_item = |tool_use_id: Option<&str>| {
ReplayItem::at(
Some("2026-06-05T10:00:05.000Z".parse().unwrap()),
Update::SubagentMeta {
agent_id: "a1000000000000001".into(),
workflow: None,
meta: crate::transcript::SubagentMeta {
agent_type: Some("subagent".into()),
description: None,
tool_use_id: tool_use_id.map(str::to_string),
stopped_by_user: None,
},
},
)
};
let t = compute_scrubber_tally(&[call_item(), meta_item(Some("toolu_1"))], 8, 0);
assert_eq!(
t.spawn_at.iter().filter(|&&s| s).count(),
1,
"one ❋, at birth — the call is suppressed by its meta"
);
let t = compute_scrubber_tally(&[call_item()], 4, 0);
assert!(t.spawn_at.iter().any(|&s| s), "no meta → the call marks ❋");
let t = compute_scrubber_tally(&[meta_item(None)], 4, 0);
assert!(t.spawn_at.iter().any(|&s| s), "meta birth marks ❋");
}
}