use std::collections::HashMap;
use std::time::Duration;
use chrono::{DateTime, Utc};
use rataflow::Palette;
use ratatui::buffer::Buffer;
use ratatui::style::{Modifier, Style};
use crate::state::graph::AgentFlow;
use crate::state::session::{SessionModel, ToolState};
const CHIP_TTL: Duration = Duration::from_millis(2500);
const CHIP_TTL_ERR: Duration = Duration::from_millis(4000);
const MAX_PER_AGENT: usize = 3;
const MIN_NODE_WIDTH: i32 = crate::ui::nodes::CELL_MIN_WIDTH as i32;
fn ttl(state: ToolState) -> Duration {
match state {
ToolState::Err => CHIP_TTL_ERR,
ToolState::Pending | ToolState::Ok => CHIP_TTL,
}
}
struct Chip {
agent_id: String,
name: String,
start: usize,
count: usize,
afterglow: Option<Duration>,
}
fn group_state(
model: &SessionModel,
agent_id: &str,
start: usize,
count: usize,
) -> Option<ToolState> {
let calls = model
.agent(agent_id)?
.tool_calls
.get(start..start + count)?;
if calls.is_empty() {
return None;
}
if calls.iter().any(|c| c.state == ToolState::Pending) {
Some(ToolState::Pending)
} else if calls.iter().any(|c| c.state == ToolState::Err) {
Some(ToolState::Err)
} else {
Some(ToolState::Ok)
}
}
const RUN_GAP: chrono::Duration = chrono::Duration::milliseconds(2500);
fn within_gap(prev: Option<DateTime<Utc>>, next: Option<DateTime<Utc>>) -> bool {
match (prev, next) {
(Some(p), Some(n)) => (n - p).abs() <= RUN_GAP,
_ => true,
}
}
#[derive(Default)]
pub struct ChipTray {
chips: Vec<Chip>,
seen: HashMap<String, usize>,
seeded: bool,
}
impl ChipTray {
pub fn is_seeded(&self) -> bool {
self.seeded
}
pub fn adopt_baseline(&mut self, model: &SessionModel) {
self.chips.clear();
self.seen.clear();
for id in &model.spawn_order {
if let Some(info) = model.agent(id) {
self.seen.insert(id.clone(), info.tool_calls.len());
}
}
self.seeded = true;
}
pub fn reconcile(&mut self, dt: Duration, playing: bool, model: &SessionModel) {
if playing {
for chip in &mut self.chips {
if let Some(age) = &mut chip.afterglow {
*age += dt;
}
}
}
let prior: HashMap<(String, usize), (usize, Option<Duration>)> = self
.chips
.drain(..)
.map(|c| ((c.agent_id, c.start), (c.count, c.afterglow)))
.collect();
for id in &model.spawn_order {
let Some(info) = model.agent(id) else {
continue;
};
let seen = self.seen.get(id).copied().unwrap_or(0);
let calls = &info.tool_calls;
let mut i = 0;
while i < calls.len() {
let name = &calls[i].name;
let start = i;
i += 1;
while i < calls.len()
&& calls[i].name == *name
&& within_gap(calls[i - 1].ts, calls[i].ts)
{
i += 1;
}
let count = i - start;
let settled = !calls[start..i]
.iter()
.any(|c| c.state == ToolState::Pending);
let prev = prior.get(&(id.clone(), start)).copied();
let afterglow: Option<Option<Duration>> = if !settled {
Some(None) } else {
match prev {
None => (start + count > seen).then_some(Some(Duration::ZERO)),
Some((pc, pa)) => Some(if count > pc || pa.is_none() {
Some(Duration::ZERO)
} else {
pa
}),
}
};
let Some(afterglow) = afterglow else {
continue;
};
if let Some(age) = afterglow {
let state = group_state(model, id, start, count);
if state.is_some_and(|s| age >= ttl(s)) {
continue;
}
}
self.chips.push(Chip {
agent_id: id.clone(),
name: name.clone(),
start,
count,
afterglow,
});
}
self.seen.insert(id.clone(), calls.len());
}
self.chips.retain(|chip| {
chip.afterglow.is_some() || model.agent(&chip.agent_id).is_some_and(|a| !a.terminal)
});
let mut counts: HashMap<&str, usize> = HashMap::new();
let mut keep = vec![true; self.chips.len()];
for (i, chip) in self.chips.iter().enumerate().rev() {
if chip.afterglow.is_none() {
continue; }
let count = counts.entry(chip.agent_id.as_str()).or_insert(0);
if *count < MAX_PER_AGENT {
*count += 1;
} else {
keep[i] = false;
}
}
drop(counts);
let mut keep = keep.into_iter();
self.chips.retain(|_| keep.next().unwrap_or(true));
}
pub fn clear(&mut self) {
*self = Self::default();
}
}
fn fmt_dur(d: chrono::Duration) -> String {
let ms = d.num_milliseconds().max(0);
if ms < 1000 {
format!("{ms}ms")
} else if ms < 10_000 {
format!("{:.1}s", ms as f64 / 1000.0)
} else if ms < 60_000 {
format!("{}s", ms / 1000)
} else {
let s = ms / 1000;
format!("{}m{}s", s / 60, s % 60)
}
}
pub fn render(
tray: &ChipTray,
flow: &AgentFlow,
model: &SessionModel,
now: Option<DateTime<Utc>>,
buf: &mut Buffer,
) {
let palette = flow.theme.palette();
let mut slots: HashMap<&str, i32> = HashMap::new();
for chip in &tray.chips {
let Some(state) = group_state(model, &chip.agent_id, chip.start, chip.count) else {
continue;
};
let age = chip.afterglow.unwrap_or(Duration::ZERO);
if chip.afterglow.is_some() && age >= ttl(state) {
continue;
}
let Some((left, _, right, bottom)) = flow.node_terminal_rect(&chip.agent_id) else {
continue;
};
if right - left < MIN_NODE_WIDTH {
continue;
}
let slot = slots
.entry(chip.agent_id.as_str())
.and_modify(|s| *s += 1)
.or_insert(0);
let y = bottom + *slot;
let body_style = chip_style(state, age, &palette);
let body = if chip.count > 1 {
format!("⚒ {} ×{}", chip.name, chip.count)
} else {
let dur = model
.agent(&chip.agent_id)
.and_then(|a| a.tool_calls.get(chip.start))
.and_then(|tc| tc.duration(now))
.map(|d| format!(" {}", fmt_dur(d)))
.unwrap_or_default();
format!("⚒ {}{}", chip.name, dur)
};
let glyph: Option<(&str, Style)> = match state {
ToolState::Pending => None,
ToolState::Ok => Some((" ✓", body_style.fg(palette.success))),
ToolState::Err => Some((
" ✗",
body_style.fg(palette.error).add_modifier(Modifier::BOLD),
)),
};
let mut cells: Vec<(char, Style)> = body.chars().map(|c| (c, body_style)).collect();
if let Some((g, gs)) = glyph {
cells.extend(g.chars().map(|c| (c, gs)));
}
for (i, (ch, style)) in cells.into_iter().enumerate() {
let x = left + 1 + i as i32;
if flow.is_in_bounds(x, y) {
buf[(x as u16, y as u16)].set_char(ch).set_style(style);
}
}
}
}
fn chip_style(state: ToolState, age: Duration, palette: &Palette) -> Style {
match state {
ToolState::Err => Style::default()
.fg(palette.error)
.add_modifier(Modifier::BOLD),
ToolState::Pending => Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
ToolState::Ok => {
let f = age.as_secs_f64() / CHIP_TTL.as_secs_f64();
let color = if f < 0.45 {
palette.text
} else if f < 0.75 {
palette.subtle
} else {
palette.muted
};
Style::default().fg(color)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::session::SessionModel;
use crate::transcript::SubagentMeta;
fn model_with_tools(n: usize) -> SessionModel {
let mut m = SessionModel::new("s1".into());
let meta = SubagentMeta {
agent_type: Some("guide".into()),
description: None,
tool_use_id: Some("t1".into()),
stopped_by_user: None,
};
m.apply_meta("sub1", None, &meta);
let agent = m.agents.get_mut("sub1").unwrap();
for i in 0..n {
agent.tool_calls.push(crate::state::session::ToolCallInfo {
id: format!("toolu_{i}"),
name: "Bash".into(),
summary: None,
ts: None,
end_ts: None,
state: ToolState::Pending,
});
}
m
}
fn model_with_named_tools(names: &[&str]) -> SessionModel {
let mut m = SessionModel::new("s1".into());
let meta = SubagentMeta {
agent_type: Some("guide".into()),
description: None,
tool_use_id: Some("t1".into()),
stopped_by_user: None,
};
m.apply_meta("sub1", None, &meta);
let agent = m.agents.get_mut("sub1").unwrap();
for (i, name) in names.iter().enumerate() {
agent.tool_calls.push(crate::state::session::ToolCallInfo {
id: format!("toolu_{i}"),
name: (*name).into(),
summary: None,
ts: None,
end_ts: None,
state: ToolState::Pending,
});
}
m
}
fn all_ok(mut m: SessionModel) -> SessionModel {
for tc in &mut m.agents.get_mut("sub1").unwrap().tool_calls {
tc.state = ToolState::Ok;
}
m
}
fn at_secs(mut m: SessionModel, secs: &[i64]) -> SessionModel {
let base = chrono::DateTime::<chrono::Utc>::from_timestamp(1_700_000_000, 0).unwrap();
let calls = &mut m.agents.get_mut("sub1").unwrap().tool_calls;
for (tc, s) in calls.iter_mut().zip(secs) {
tc.ts = Some(base + chrono::Duration::seconds(*s));
}
m
}
fn observe(tray: &mut ChipTray, model: &SessionModel) {
tray.reconcile(Duration::ZERO, true, model);
}
fn play(tray: &mut ChipTray, secs: u64, model: &SessionModel) {
tray.reconcile(Duration::from_secs(secs), true, model);
}
#[test]
fn reconcile_aggregates_consecutive_same_name_calls() {
let mut tray = ChipTray::default();
observe(&mut tray, &model_with_tools(2));
assert_eq!(tray.chips.len(), 1);
assert_eq!(tray.chips[0].count, 2);
observe(&mut tray, &model_with_tools(2));
assert_eq!(tray.chips.len(), 1);
assert_eq!(tray.chips[0].count, 2);
observe(&mut tray, &model_with_tools(3));
assert_eq!(tray.chips.len(), 1);
assert_eq!(tray.chips[0].count, 3);
}
#[test]
fn reconcile_opens_a_new_run_on_a_different_name() {
let mut tray = ChipTray::default();
observe(
&mut tray,
&model_with_named_tools(&["bash", "bash", "read", "read"]),
);
assert_eq!(tray.chips.len(), 2);
assert_eq!(
(tray.chips[0].name.as_str(), tray.chips[0].count),
("bash", 2)
);
assert_eq!(
(tray.chips[1].name.as_str(), tray.chips[1].count),
("read", 2)
);
}
#[test]
fn a_burst_of_same_name_calls_is_one_counting_chip() {
let mut tray = ChipTray::default();
observe(&mut tray, &all_ok(model_with_tools(20)));
assert_eq!(tray.chips.len(), 1, "a burst is one chip, not 20 churning");
assert_eq!(tray.chips[0].count, 20);
}
#[test]
fn a_real_time_gap_splits_a_run() {
let mut tray = ChipTray::default();
let model = at_secs(all_ok(model_with_tools(3)), &[0, 1, 100]);
observe(&mut tray, &model);
assert_eq!(tray.chips.len(), 2, "the 100s gap opens a second run");
assert_eq!((tray.chips[0].start, tray.chips[0].count), (0, 2));
assert_eq!((tray.chips[1].start, tray.chips[1].count), (2, 1));
}
#[test]
fn a_growing_run_re_anchors_bright_instead_of_aging_out() {
let mut tray = ChipTray::default();
observe(&mut tray, &all_ok(model_with_tools(2)));
assert_eq!(tray.chips[0].count, 2);
play(&mut tray, 2, &all_ok(model_with_tools(2)));
assert_eq!(tray.chips.len(), 1, "run survives the gap");
observe(&mut tray, &all_ok(model_with_tools(4)));
assert_eq!(tray.chips.len(), 1);
assert_eq!(tray.chips[0].count, 4, "extended, not a fresh chip");
assert_eq!(
tray.chips[0].afterglow,
Some(Duration::ZERO),
"a new member re-anchors the afterglow"
);
}
#[test]
fn a_run_reads_pending_while_in_flight_then_settles() {
let mut tray = ChipTray::default();
let mut model = model_with_tools(3);
let calls = &mut model.agents.get_mut("sub1").unwrap().tool_calls;
calls[0].state = ToolState::Ok;
calls[1].state = ToolState::Ok;
observe(&mut tray, &model);
assert_eq!(tray.chips.len(), 1);
assert_eq!(tray.chips[0].count, 3);
assert_eq!(
group_state(&model, "sub1", 0, 3),
Some(ToolState::Pending),
"an in-flight run reads pending, not done"
);
assert_eq!(
tray.chips[0].afterglow, None,
"an unsettled run stays bright (no fade until every call returns)"
);
model.agents.get_mut("sub1").unwrap().tool_calls[2].state = ToolState::Ok;
observe(&mut tray, &model);
assert_eq!(group_state(&model, "sub1", 0, 3), Some(ToolState::Ok));
assert_eq!(tray.chips[0].afterglow, Some(Duration::ZERO));
}
#[test]
fn per_agent_cap_exempts_pending_and_keeps_newest_completed_runs() {
let mut tray = ChipTray::default();
let names = ["a", "b", "c", "d", "e"];
observe(&mut tray, &model_with_named_tools(&names));
assert_eq!(tray.chips.len(), 5, "pending runs are cap-exempt");
observe(&mut tray, &all_ok(model_with_named_tools(&names)));
assert_eq!(tray.chips.len(), MAX_PER_AGENT);
let kept: Vec<&str> = tray.chips.iter().map(|c| c.name.as_str()).collect();
assert_eq!(kept, vec!["c", "d", "e"], "newest runs kept");
}
#[test]
fn afterglow_ages_in_playing_time_and_expires() {
let mut tray = ChipTray::default();
let model = all_ok(model_with_tools(1));
observe(&mut tray, &model);
assert_eq!(
tray.chips[0].afterglow,
Some(Duration::ZERO),
"afterglow starts at zero on completion"
);
play(&mut tray, 2, &model);
assert_eq!(tray.chips.len(), 1);
play(&mut tray, 1, &model);
assert!(
tray.chips.is_empty(),
"expired 3 playing-seconds after completion"
);
}
#[test]
fn afterglow_freezes_when_not_playing() {
let mut tray = ChipTray::default();
let model = all_ok(model_with_tools(1));
observe(&mut tray, &model);
tray.reconcile(Duration::from_secs(60), false, &model); assert_eq!(tray.chips.len(), 1, "frozen while paused");
assert_eq!(tray.chips[0].afterglow, Some(Duration::ZERO));
play(&mut tray, 3, &model);
assert!(tray.chips.is_empty());
}
#[test]
fn pending_runs_persist_then_anchor_on_completion() {
let mut tray = ChipTray::default();
let mut model = model_with_tools(1);
observe(&mut tray, &model);
assert_eq!(tray.chips[0].afterglow, None, "pending → no afterglow yet");
play(&mut tray, 60, &model);
assert_eq!(tray.chips.len(), 1, "pending runs never expire");
assert_eq!(tray.chips[0].afterglow, None);
model.agents.get_mut("sub1").unwrap().tool_calls[0].state = ToolState::Ok;
observe(&mut tray, &model);
assert_eq!(tray.chips[0].afterglow, Some(Duration::ZERO));
}
#[test]
fn a_new_call_after_the_run_faded_starts_a_fresh_run() {
let mut tray = ChipTray::default();
let m1 = at_secs(all_ok(model_with_tools(1)), &[0]);
observe(&mut tray, &m1);
assert_eq!(tray.chips[0].afterglow, Some(Duration::ZERO));
play(&mut tray, 5, &m1);
assert!(tray.chips.is_empty());
let m2 = at_secs(all_ok(model_with_tools(2)), &[0, 100]);
observe(&mut tray, &m2);
assert_eq!(tray.chips.len(), 1);
assert_eq!(
tray.chips[0].start, 1,
"fresh run at the new call, not extended"
);
assert_eq!(tray.chips[0].count, 1);
}
#[test]
fn reconcile_drops_orphaned_chips_after_reset() {
let mut tray = ChipTray::default();
observe(&mut tray, &model_with_tools(2));
assert_eq!(tray.chips.len(), 1);
observe(&mut tray, &SessionModel::new("s2".into()));
assert!(tray.chips.is_empty());
}
#[test]
fn a_running_owners_in_flight_run_survives_ticks() {
let mut tray = ChipTray::default();
let model = model_with_tools(1); observe(&mut tray, &model);
assert_eq!(tray.chips.len(), 1);
play(&mut tray, 5, &model);
assert_eq!(
tray.chips.len(),
1,
"a running owner's in-flight run persists"
);
assert_eq!(tray.chips[0].afterglow, None);
}
#[test]
fn pending_chips_survive_quiet_but_die_when_terminal() {
let mut tray = ChipTray::default();
let mut model = model_with_tools(1);
observe(&mut tray, &model);
assert_eq!(tray.chips.len(), 1);
model.agents.get_mut("sub1").unwrap().status = crate::state::session::AgentStatus::Done;
observe(&mut tray, &model);
assert_eq!(
tray.chips.len(),
1,
"a quiet-but-not-terminal owner keeps its in-flight chip"
);
model.agents.get_mut("sub1").unwrap().terminal = true;
observe(&mut tray, &model);
assert!(
tray.chips.is_empty(),
"a terminal owner's dangling pending tool is dropped"
);
}
#[test]
fn failed_chips_outlive_ok_chips() {
assert!(ttl(ToolState::Err) > ttl(ToolState::Ok));
}
#[test]
fn fmt_dur_reads_at_a_glance() {
use chrono::Duration as D;
assert_eq!(fmt_dur(D::milliseconds(3)), "3ms");
assert_eq!(fmt_dur(D::milliseconds(847)), "847ms");
assert_eq!(fmt_dur(D::milliseconds(1234)), "1.2s"); assert_eq!(fmt_dur(D::seconds(42)), "42s"); assert_eq!(fmt_dur(D::seconds(143)), "2m23s"); assert_eq!(fmt_dur(D::milliseconds(-5)), "0ms"); }
#[test]
fn adopt_baseline_hides_completed_history_but_reconcile_shows_new_work() {
let mut tray = ChipTray::default();
assert!(!tray.is_seeded());
let hist = all_ok(model_with_tools(2));
tray.adopt_baseline(&hist);
assert!(tray.is_seeded());
assert!(tray.chips.is_empty(), "baseline itself spawns nothing");
observe(&mut tray, &hist);
assert!(tray.chips.is_empty(), "completed history must not chip");
let more = at_secs(all_ok(model_with_tools(3)), &[0, 1, 100]);
observe(&mut tray, &more);
assert_eq!(tray.chips.len(), 1);
assert_eq!(tray.chips[0].start, 2);
assert_eq!(tray.chips[0].count, 1);
}
#[test]
fn reconcile_reconstructs_in_flight_pending_runs_after_a_seek() {
let mut tray = ChipTray::default();
let mut model = at_secs(model_with_tools(3), &[0, 1, 100]);
let calls = &mut model.agents.get_mut("sub1").unwrap().tool_calls;
calls[0].state = ToolState::Ok;
calls[1].state = ToolState::Ok;
tray.adopt_baseline(&model);
assert!(tray.chips.is_empty(), "baseline itself spawns nothing");
observe(&mut tray, &model);
assert_eq!(tray.chips.len(), 1, "the in-flight tool is reconstructed");
assert_eq!(
tray.chips[0].start, 2,
"only the pending tail, not the done history"
);
assert_eq!(tray.chips[0].afterglow, None, "shown as pending (bright)");
}
#[test]
fn clear_resets_seeding() {
let mut tray = ChipTray::default();
tray.adopt_baseline(&model_with_tools(1));
assert!(tray.is_seeded());
tray.clear();
assert!(
!tray.is_seeded(),
"a new session needs a fresh backfill baseline"
);
}
}