use std::collections::HashMap;
use std::time::{Duration, Instant};
use crate::localization::{Locale, MessageId, tr};
#[derive(Debug, Clone, Default)]
pub struct SessionMetrics {
pub model_calls: u64,
pub tool_calls: u64,
pub llm_time: Duration,
pub tool_time: Duration,
ttft_total: Duration,
ttft_samples: u64,
rate_output_tokens: u64,
rate_stream_time: Duration,
tool_started: HashMap<String, Instant>,
}
impl SessionMetrics {
pub fn record_model_call(
&mut self,
output_tokens: u32,
stream_ms: u64,
first_token_ms: Option<u64>,
request_ms: Option<u64>,
) {
self.model_calls = self.model_calls.saturating_add(1);
let call_ms = request_ms.unwrap_or(stream_ms);
self.llm_time = self.llm_time.saturating_add(Duration::from_millis(call_ms));
if let Some(ttft) = first_token_ms {
self.ttft_total = self.ttft_total.saturating_add(Duration::from_millis(ttft));
self.ttft_samples = self.ttft_samples.saturating_add(1);
}
if stream_ms > 0 {
self.rate_output_tokens = self
.rate_output_tokens
.saturating_add(u64::from(output_tokens));
self.rate_stream_time = self
.rate_stream_time
.saturating_add(Duration::from_millis(stream_ms));
}
}
pub fn record_tool_started(&mut self, tool_id: &str) {
self.record_tool_started_at(tool_id, Instant::now());
}
fn record_tool_started_at(&mut self, tool_id: &str, at: Instant) {
self.tool_started.insert(tool_id.to_string(), at);
}
pub fn record_tool_completed(&mut self, tool_id: &str) {
self.record_tool_completed_at(tool_id, Instant::now());
}
fn record_tool_completed_at(&mut self, tool_id: &str, at: Instant) {
self.tool_calls = self.tool_calls.saturating_add(1);
if let Some(started) = self.tool_started.remove(tool_id) {
self.tool_time = self
.tool_time
.saturating_add(at.saturating_duration_since(started));
}
}
pub fn clear_in_flight(&mut self) {
self.tool_started.clear();
}
#[must_use]
pub fn steps(&self) -> u64 {
self.model_calls.saturating_add(self.tool_calls)
}
#[must_use]
pub fn ttft_average(&self) -> Option<Duration> {
if self.ttft_samples == 0 {
return None;
}
Some(self.ttft_total / u32::try_from(self.ttft_samples).unwrap_or(u32::MAX))
}
#[must_use]
pub fn tokens_per_second(&self) -> Option<f64> {
let secs = self.rate_stream_time.as_secs_f64();
if self.rate_output_tokens == 0 || !secs.is_finite() || secs <= 0.0 {
return None;
}
Some(self.rate_output_tokens as f64 / secs)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct MetricsSnapshot {
pub turns: u64,
pub steps: u64,
pub llm_time: Duration,
pub tool_time: Duration,
pub ttft_avg: Option<Duration>,
pub tokens_per_second: Option<f64>,
pub cache_hit_percent: Option<u8>,
pub input_tokens: u64,
}
impl MetricsSnapshot {
#[must_use]
pub fn is_empty(&self) -> bool {
self.turns == 0 && self.steps == 0 && self.input_tokens == 0
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MetricCell {
pub label: String,
pub value: String,
pub value_first: bool,
}
impl MetricCell {
fn width(&self) -> usize {
use unicode_width::UnicodeWidthStr;
self.label.width() + 1 + self.value.width()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetricGroup {
Input,
Cache,
Llm,
Turns,
Latency,
}
const GROUP_PRIORITY: [MetricGroup; 5] = [
MetricGroup::Input,
MetricGroup::Cache,
MetricGroup::Llm,
MetricGroup::Turns,
MetricGroup::Latency,
];
const GROUP_ORDER: [MetricGroup; 5] = [
MetricGroup::Turns,
MetricGroup::Llm,
MetricGroup::Latency,
MetricGroup::Cache,
MetricGroup::Input,
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MetricGroupCells {
pub group: MetricGroup,
pub cells: Vec<MetricCell>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Separators {
pub cell: &'static str,
pub group: &'static str,
}
impl Separators {
pub const UNICODE: Self = Self {
cell: " · ",
group: " │ ",
};
pub const ASCII: Self = Self {
cell: " . ",
group: " | ",
};
#[must_use]
pub fn for_ascii(ascii_safe: bool) -> Self {
if ascii_safe {
Self::ASCII
} else {
Self::UNICODE
}
}
}
#[must_use]
pub fn format_duration(duration: Duration) -> String {
let ms = duration.as_millis();
if ms == 0 {
return "0s".to_string();
}
if ms < 1_000 {
return format!("{ms}ms");
}
let secs = duration.as_secs();
if secs < 60 {
let tenths = (ms + 50) / 100;
return format!("{}.{}s", tenths / 10, tenths % 10);
}
if secs < 3_600 {
return format!("{}m{:02}s", secs / 60, secs % 60);
}
format!("{}h{:02}m", secs / 3_600, (secs % 3_600) / 60)
}
#[must_use]
pub fn format_tokens(tokens: u64) -> String {
const UNITS: [(u64, &str); 3] = [(1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")];
for (scale, suffix) in UNITS {
if tokens >= scale {
let scaled = tokens as f64 / scale as f64;
return if scaled >= 100.0 {
format!("{scaled:.0}{suffix}")
} else {
format!("{scaled:.1}{suffix}")
};
}
}
tokens.to_string()
}
#[must_use]
pub fn format_rate(rate: f64) -> String {
if rate < 10.0 {
format!("{rate:.1}")
} else {
format!("{rate:.0}")
}
}
#[must_use]
pub fn build_groups(snapshot: MetricsSnapshot, locale: Locale) -> Vec<MetricGroupCells> {
let label = |id: MessageId| tr(locale, id).into_owned();
let mut groups = Vec::new();
for group in GROUP_ORDER {
let cells = match group {
MetricGroup::Turns => vec![
MetricCell {
label: label(if snapshot.turns == 1 {
MessageId::SessionMetricsTurn
} else {
MessageId::SessionMetricsTurns
}),
value: snapshot.turns.to_string(),
value_first: true,
},
MetricCell {
label: label(if snapshot.steps == 1 {
MessageId::SessionMetricsStep
} else {
MessageId::SessionMetricsSteps
}),
value: snapshot.steps.to_string(),
value_first: true,
},
],
MetricGroup::Llm => vec![
MetricCell {
label: label(MessageId::SessionMetricsLlm),
value: format_duration(snapshot.llm_time),
value_first: false,
},
MetricCell {
label: label(MessageId::SessionMetricsTools),
value: format_duration(snapshot.tool_time),
value_first: false,
},
],
MetricGroup::Latency => {
let mut cells = Vec::new();
if let Some(ttft) = snapshot.ttft_avg {
cells.push(MetricCell {
label: label(MessageId::SessionMetricsTtft),
value: format_duration(ttft),
value_first: false,
});
}
if let Some(rate) = snapshot.tokens_per_second {
cells.push(MetricCell {
label: label(MessageId::SessionMetricsTokensPerSecond),
value: format_rate(rate),
value_first: true,
});
}
if cells.is_empty() {
continue;
}
cells
}
MetricGroup::Cache => {
let Some(pct) = snapshot.cache_hit_percent else {
continue;
};
vec![MetricCell {
label: label(MessageId::SessionMetricsCache),
value: format!("{pct}%"),
value_first: false,
}]
}
MetricGroup::Input => {
if snapshot.input_tokens == 0 {
continue;
}
vec![MetricCell {
label: label(MessageId::SessionMetricsInput),
value: format_tokens(snapshot.input_tokens),
value_first: false,
}]
}
};
groups.push(MetricGroupCells { group, cells });
}
groups
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedStrip {
pub groups: Vec<MetricGroupCells>,
pub separators: Separators,
}
impl RenderedStrip {
#[must_use]
pub fn text(&self) -> String {
let mut out = String::new();
for (index, group) in self.groups.iter().enumerate() {
if index > 0 {
out.push_str(self.separators.group);
}
for (cell_index, cell) in group.cells.iter().enumerate() {
if cell_index > 0 {
out.push_str(self.separators.cell);
}
if cell.value_first {
out.push_str(&cell.value);
out.push(' ');
out.push_str(&cell.label);
} else {
out.push_str(&cell.label);
out.push(' ');
out.push_str(&cell.value);
}
}
}
out
}
#[cfg(test)]
#[must_use]
pub fn width(&self) -> usize {
use unicode_width::UnicodeWidthStr;
self.text().width()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.groups.is_empty()
}
}
fn strip_width(groups: &[MetricGroupCells], separators: Separators) -> usize {
use unicode_width::UnicodeWidthStr;
let mut width = 0;
for (index, group) in groups.iter().enumerate() {
if index > 0 {
width += separators.group.width();
}
for (cell_index, cell) in group.cells.iter().enumerate() {
if cell_index > 0 {
width += separators.cell.width();
}
width += cell.width();
}
}
width
}
#[must_use]
pub fn fit_to_width(
mut groups: Vec<MetricGroupCells>,
budget: usize,
separators: Separators,
) -> RenderedStrip {
for group in GROUP_PRIORITY.iter().rev() {
if strip_width(&groups, separators) <= budget {
break;
}
if let Some(position) = groups.iter().position(|g| g.group == *group) {
groups[position].cells.truncate(1);
}
}
for group in GROUP_PRIORITY.iter().rev() {
if strip_width(&groups, separators) <= budget {
break;
}
if let Some(position) = groups.iter().position(|g| g.group == *group) {
groups.remove(position);
}
}
if strip_width(&groups, separators) > budget {
groups.clear();
}
RenderedStrip { groups, separators }
}
#[must_use]
pub fn snapshot_from_app(app: &crate::tui::app::App) -> MetricsSnapshot {
let hit = u64::from(app.session.total_cache_hit_tokens);
let miss = u64::from(app.session.total_cache_miss_tokens);
let cache_hit_percent = (hit + miss > 0).then(|| {
u8::try_from((hit * 100 + (hit + miss) / 2) / (hit + miss)).unwrap_or(100)
});
MetricsSnapshot {
turns: app.turn_counter,
steps: app.session_metrics.steps(),
llm_time: app.session_metrics.llm_time,
tool_time: app.session_metrics.tool_time,
ttft_avg: app.session_metrics.ttft_average(),
tokens_per_second: app.session_metrics.tokens_per_second(),
cache_hit_percent,
input_tokens: u64::from(app.session.total_input_tokens),
}
}
#[must_use]
pub fn spans(
strip: &RenderedStrip,
theme: &crate::palette::UiTheme,
) -> Vec<ratatui::text::Span<'static>> {
use ratatui::style::Style;
use ratatui::text::Span;
let dim = Style::default().fg(theme.text_dim);
let label = Style::default().fg(theme.text_muted);
let value = Style::default().fg(theme.text_soft);
let mut out = Vec::new();
for (index, group) in strip.groups.iter().enumerate() {
if index > 0 {
out.push(Span::styled(strip.separators.group, dim));
}
for (cell_index, cell) in group.cells.iter().enumerate() {
if cell_index > 0 {
out.push(Span::styled(strip.separators.cell, dim));
}
if cell.value_first {
out.push(Span::styled(cell.value.clone(), value));
out.push(Span::raw(" "));
out.push(Span::styled(cell.label.clone(), label));
} else {
out.push(Span::styled(cell.label.clone(), label));
out.push(Span::raw(" "));
out.push(Span::styled(cell.value.clone(), value));
}
}
}
out
}
#[must_use]
pub fn full_text(snapshot: MetricsSnapshot, locale: Locale, ascii_safe: bool) -> String {
RenderedStrip {
groups: build_groups(snapshot, locale),
separators: Separators::for_ascii(ascii_safe),
}
.text()
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> MetricsSnapshot {
MetricsSnapshot {
turns: 4,
steps: 108,
llm_time: Duration::from_secs(11 * 60 + 46),
tool_time: Duration::from_secs(60 + 52),
ttft_avg: Some(Duration::from_millis(1_500)),
tokens_per_second: Some(120.0),
cache_hit_percent: Some(99),
input_tokens: 9_300_000,
}
}
#[test]
fn durations_format_like_the_harness_strip() {
assert_eq!(format_duration(Duration::ZERO), "0s");
assert_eq!(format_duration(Duration::from_millis(320)), "320ms");
assert_eq!(format_duration(Duration::from_millis(1_500)), "1.5s");
assert_eq!(format_duration(Duration::from_millis(1_549)), "1.5s");
assert_eq!(format_duration(Duration::from_secs(59)), "59.0s");
assert_eq!(format_duration(Duration::from_secs(11 * 60 + 46)), "11m46s");
assert_eq!(format_duration(Duration::from_secs(3_600 + 120)), "1h02m");
}
#[test]
fn tokens_and_rates_format_compactly() {
assert_eq!(format_tokens(842), "842");
assert_eq!(format_tokens(12_345), "12.3K");
assert_eq!(format_tokens(128_000), "128K");
assert_eq!(format_tokens(9_300_000), "9.3M");
assert_eq!(format_tokens(1_200_000_000), "1.2B");
assert_eq!(format_rate(120.4), "120");
assert_eq!(format_rate(7.46), "7.5");
}
#[test]
fn full_strip_matches_the_reference_layout() {
let text = full_text(sample(), Locale::En, false);
assert_eq!(
text,
"4 turns · 108 steps │ LLM 11m46s · Tool call 1m52s │ TTFT avg 1.5s · 120 tok/s │ Cache hit 99% │ Input 9.3M"
);
let ascii = full_text(sample(), Locale::En, true);
assert!(ascii.is_ascii(), "{ascii}");
assert!(ascii.contains(" | LLM 11m46s . Tool call 1m52s | "));
}
#[test]
fn absent_evidence_omits_the_cell_instead_of_a_placeholder() {
let mut snapshot = sample();
snapshot.cache_hit_percent = None;
snapshot.ttft_avg = None;
snapshot.tokens_per_second = None;
snapshot.input_tokens = 0;
let text = full_text(snapshot, Locale::En, false);
assert_eq!(text, "4 turns · 108 steps │ LLM 11m46s · Tool call 1m52s");
assert!(!text.contains('—'), "{text}");
snapshot.ttft_avg = Some(Duration::from_millis(900));
let text = full_text(snapshot, Locale::En, false);
assert!(text.ends_with("│ TTFT avg 900ms"), "{text}");
snapshot.ttft_avg = None;
snapshot.tokens_per_second = Some(88.0);
let text = full_text(snapshot, Locale::En, false);
assert!(text.ends_with("│ 88 tok/s"), "{text}");
}
#[test]
fn singular_labels_for_one_turn_and_one_step() {
let snapshot = MetricsSnapshot {
turns: 1,
steps: 1,
..MetricsSnapshot::default()
};
let text = full_text(snapshot, Locale::En, false);
assert!(text.starts_with("1 turn · 1 step │"), "{text}");
}
#[test]
fn narrow_budgets_drop_the_least_useful_evidence_first() {
let groups = build_groups(sample(), Locale::En);
let full = fit_to_width(groups.clone(), 200, Separators::UNICODE);
assert_eq!(full.groups.len(), 5);
let width = full.width();
let trimmed = fit_to_width(groups.clone(), width - 1, Separators::UNICODE);
assert_eq!(
trimmed.text(),
"4 turns · 108 steps │ LLM 11m46s · Tool call 1m52s │ TTFT avg 1.5s │ Cache hit 99% │ Input 9.3M"
);
let normal = fit_to_width(groups.clone(), 60, Separators::UNICODE);
assert_eq!(
normal.text(),
"4 turns │ LLM 11m46s │ Cache hit 99% │ Input 9.3M"
);
let compact = fit_to_width(groups.clone(), 28, Separators::UNICODE);
assert_eq!(compact.text(), "Cache hit 99% │ Input 9.3M");
let none = fit_to_width(groups, 5, Separators::UNICODE);
assert!(none.is_empty());
}
#[test]
fn every_shipped_locale_has_short_labels() {
for locale in Locale::shipped_complete() {
let text = full_text(sample(), *locale, false);
assert!(text.contains("4 "), "{}: {text}", locale.tag());
assert!(text.contains("11m46s"), "{}: {text}", locale.tag());
for group in build_groups(sample(), *locale) {
for cell in group.cells {
assert!(
cell.label.chars().count() <= 12,
"{}: label `{}` is too long for the strip",
locale.tag(),
cell.label
);
}
}
}
}
#[test]
fn accumulators_derive_ttft_and_rate_from_reported_calls() {
let mut metrics = SessionMetrics::default();
metrics.record_model_call(100, 2_000, Some(500), Some(2_400));
metrics.record_model_call(20, 1_000, None, Some(1_100));
assert_eq!(metrics.model_calls, 2);
assert_eq!(metrics.llm_time, Duration::from_millis(3_500));
assert_eq!(metrics.ttft_average(), Some(Duration::from_millis(500)));
let rate = metrics.tokens_per_second().expect("rate");
assert!((rate - 40.0).abs() < 1e-9, "{rate}");
metrics.record_model_call(0, 700, None, None);
assert_eq!(metrics.llm_time, Duration::from_millis(4_200));
assert!((metrics.tokens_per_second().unwrap() - 120.0 / 3.7).abs() < 1e-9);
}
#[test]
fn tool_time_needs_both_edges_and_in_flight_timers_are_dropped() {
let mut metrics = SessionMetrics::default();
let t0 = Instant::now();
metrics.record_tool_started_at("a", t0);
metrics.record_tool_completed_at("a", t0 + Duration::from_millis(1_500));
metrics.record_tool_completed_at("ghost", t0 + Duration::from_secs(9));
assert_eq!(metrics.tool_calls, 2);
assert_eq!(metrics.tool_time, Duration::from_millis(1_500));
assert_eq!(metrics.steps(), 2);
metrics.record_tool_started_at("b", t0);
metrics.clear_in_flight();
metrics.record_tool_completed_at("b", t0 + Duration::from_secs(5));
assert_eq!(metrics.tool_time, Duration::from_millis(1_500));
}
}