use std::collections::HashSet;
use super::{AutoExpand, Fold, Row, RowClass, Tone, expanded_for, in_flight};
use crate::transcript::{Block, EntryKind, Usage};
const TURN_SUFFIX: &str = "turn";
const TURN_GLYPH: &str = "⚙";
const TERM_SEP: &str = " · ";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Step {
Boundary,
Model,
Thinking,
ToolCall,
Plain,
}
pub(super) fn step_of(kind: &EntryKind, block: usize) -> Step {
match kind {
EntryKind::Delivered { .. } => Step::Boundary,
EntryKind::Model { blocks, .. } => match blocks.get(block) {
Some(Block::Thinking(_)) => Step::Thinking,
Some(Block::ToolUse { .. }) => Step::ToolCall,
Some(Block::Text(_)) | None => Step::Model,
},
EntryKind::ToolResult { .. } | EntryKind::Streaming { .. } | EntryKind::Raw => Step::Plain,
}
}
pub(super) fn usage_of(kind: &EntryKind) -> Option<&Usage> {
match kind {
EntryKind::Model { usage, .. } => Some(usage),
_ => None,
}
}
pub(super) fn group(
flat: &[Row],
steps: &[Step],
usage: &[Option<&Usage>],
auto: AutoExpand,
folds: &HashSet<String>,
) -> Vec<Row> {
let mut out = Vec::new();
let mut start = 0;
for (i, step) in steps.iter().enumerate() {
if *step != Step::Boundary {
continue;
}
push_turn(
span(flat, start, i),
span(steps, start, i),
span(usage, start, i),
auto,
folds,
&mut out,
);
if let Some(boundary) = flat.get(i) {
out.push(boundary.clone());
}
start = i + 1;
}
push_turn(
span(flat, start, flat.len()),
span(steps, start, steps.len()),
span(usage, start, usage.len()),
auto,
folds,
&mut out,
);
out
}
fn span<T>(slice: &[T], from: usize, to: usize) -> &[T] {
slice.get(from..to).unwrap_or_default()
}
fn push_turn(
rows: &[Row],
steps: &[Step],
usage: &[Option<&Usage>],
auto: AutoExpand,
folds: &HashSet<String>,
out: &mut Vec<Row>,
) {
let (Some((answer, run)), Some((_, run_steps))) = (rows.split_last(), steps.split_last())
else {
out.extend_from_slice(rows);
return;
};
let run_usage = span(usage, 0, run.len());
let counts = Counts::of(run, run_steps, run_usage);
let rolls_up =
answer.class == RowClass::Response && counts.inference > 0 && !rows.iter().any(in_flight);
match run.first() {
Some(first) if rolls_up => {
let parent = aggregate(&first.key, &counts, auto, folds);
let open = parent.expanded;
out.push(parent);
if open {
out.extend_from_slice(run);
}
out.push(answer.clone());
}
_ => out.extend_from_slice(rows),
}
}
fn aggregate(first_key: &str, counts: &Counts, auto: AutoExpand, folds: &HashSet<String>) -> Row {
let key = turn_key(first_key);
let mut row = Row {
expanded: false,
prefix: format!("{TURN_GLYPH} {}", counts.say()),
preview: String::new(),
body: String::new(),
hover: "what the agent did before answering — open it for each step".to_string(),
class: RowClass::Other,
tone: Tone::Weak,
role: None,
fold: Fold::Steps,
key,
};
row.expanded = expanded_for(&row, auto, folds.contains(&row.key));
row
}
fn turn_key(first_key: &str) -> String {
let entry = first_key
.rsplit_once('#')
.map_or(first_key, |(head, _)| head);
format!("{entry}#{TURN_SUFFIX}")
}
struct Counts {
inference: usize,
tools: usize,
thinking: usize,
tokens: Usage,
reported: usize,
}
impl Counts {
fn of(rows: &[Row], steps: &[Step], usage: &[Option<&Usage>]) -> Self {
let mut counts = Self {
inference: 0,
tools: 0,
thinking: 0,
tokens: Usage::new(),
reported: 0,
};
let mut entry = String::new();
for ((row, step), report) in rows.iter().zip(steps).zip(usage) {
match step {
Step::ToolCall => counts.tools += 1,
Step::Thinking => counts.thinking += 1,
Step::Model | Step::Boundary | Step::Plain => {}
}
if matches!(step, Step::Model | Step::Thinking | Step::ToolCall) {
let seen = turn_key(&row.key);
if seen != entry {
counts.inference += 1;
entry = seen;
if let Some(report) = report {
counts.fold(report);
}
}
}
}
counts
}
fn fold(&mut self, report: &Usage) {
if report.is_empty() {
return;
}
self.reported += 1;
for (counter, n) in report {
*self.tokens.entry(counter.clone()).or_default() += n;
}
}
fn say(&self) -> String {
let terms = [
(self.inference, "inference call"),
(self.tools, "tool call"),
(self.thinking, "thinking block"),
];
let mut said: Vec<String> = terms
.iter()
.filter(|(n, _)| *n > 0)
.map(|(n, word)| format!("{n} {word}{}", if *n == 1 { "" } else { "s" }))
.collect();
let at_least = if self.reported < self.inference {
"+"
} else {
""
};
for (counter, sum) in &self.tokens {
if *sum > 0 {
said.push(format!("{sum}{at_least} {}", counter.replace('_', " ")));
}
}
said.join(TERM_SEP)
}
}