codex-helper-tui 0.12.0

Terminal UI crate for codex-helper.
Documentation
use std::collections::HashMap;

use super::model::tokens_short;
use super::state::UiState;
use super::types::StatsFocus;
use crate::state::UsageBucket;
use crate::usage::UsageMetrics;

#[derive(Debug, Clone)]
pub(in crate::tui) enum StatsTarget {
    Config(String),
    Provider(String),
}

fn sum_buckets(buckets: &[(i32, UsageBucket)]) -> UsageBucket {
    let mut out = UsageBucket::default();
    for (_, b) in buckets {
        out.requests_total = out.requests_total.saturating_add(b.requests_total);
        out.requests_error = out.requests_error.saturating_add(b.requests_error);
        out.duration_ms_total = out.duration_ms_total.saturating_add(b.duration_ms_total);
        out.usage.add_assign(&b.usage);
    }
    out
}

#[derive(Debug, Clone, Default)]
struct RecentBreakdown {
    total: u64,
    err: u64,
    class_2xx: u64,
    class_3xx: u64,
    class_4xx: u64,
    class_5xx: u64,
    top_status: Vec<(u16, u64)>,
    top_models_by_tokens: Vec<(String, (u64, i64))>,
    top_paths_by_tokens: Vec<(String, (u64, u64, i64))>,
}

fn compute_recent_breakdown(
    ui: &UiState,
    snapshot: &super::model::Snapshot,
    target: &StatsTarget,
) -> RecentBreakdown {
    let mut by_model: HashMap<String, (u64, i64)> = HashMap::new();
    let mut by_path: HashMap<String, (u64, u64, i64)> = HashMap::new();
    let mut by_status: HashMap<u16, u64> = HashMap::new();
    let mut out = RecentBreakdown::default();

    for r in &snapshot.recent {
        let matches = match target {
            StatsTarget::Config(name) => r.config_name.as_deref() == Some(name.as_str()),
            StatsTarget::Provider(name) => r.provider_id.as_deref() == Some(name.as_str()),
        };
        if !matches {
            continue;
        }
        if ui.stats_errors_only && r.status_code < 400 {
            continue;
        }

        out.total += 1;
        if r.status_code >= 400 {
            out.err += 1;
        }
        match r.status_code {
            200..=299 => out.class_2xx += 1,
            300..=399 => out.class_3xx += 1,
            400..=499 => out.class_4xx += 1,
            _ => out.class_5xx += 1,
        }
        *by_status.entry(r.status_code).or_insert(0) += 1;

        let model = r.model.as_deref().unwrap_or("-");
        let tokens = r.usage.as_ref().map(|u| u.total_tokens).unwrap_or(0);
        by_model
            .entry(model.to_string())
            .and_modify(|(c, t)| {
                *c = c.saturating_add(1);
                *t = t.saturating_add(tokens);
            })
            .or_insert((1, tokens));

        by_path
            .entry(r.path.clone())
            .and_modify(|(c, e, t)| {
                *c = c.saturating_add(1);
                if r.status_code >= 400 {
                    *e = e.saturating_add(1);
                }
                *t = t.saturating_add(tokens);
            })
            .or_insert((1, if r.status_code >= 400 { 1 } else { 0 }, tokens));
    }

    let mut status_items = by_status.into_iter().collect::<Vec<_>>();
    status_items.sort_by_key(|(_, c)| std::cmp::Reverse(*c));
    status_items.truncate(10);
    out.top_status = status_items;

    let mut model_items = by_model.into_iter().collect::<Vec<_>>();
    model_items.sort_by_key(|(_, (_, tok))| std::cmp::Reverse(*tok));
    model_items.truncate(10);
    out.top_models_by_tokens = model_items;

    let mut path_items = by_path.into_iter().collect::<Vec<_>>();
    path_items.sort_by_key(|(_, (_, _, tok))| std::cmp::Reverse(*tok));
    path_items.truncate(10);
    out.top_paths_by_tokens = path_items;

    out
}

fn fmt_pct(num: u64, den: u64) -> String {
    if den == 0 {
        return "-".to_string();
    }
    format!("{:.1}%", (num as f64) * 100.0 / (den as f64))
}

fn fmt_avg_ms(total_ms: u64, n: u64) -> String {
    if n == 0 {
        return "-".to_string();
    }
    format!("{}ms", total_ms / n)
}

fn fmt_usage_line(u: &UsageMetrics) -> String {
    format!(
        "tokens in/out/rsn/ttl: {}/{}/{}/{}",
        tokens_short(u.input_tokens),
        tokens_short(u.output_tokens),
        tokens_short(u.reasoning_tokens),
        tokens_short(u.total_tokens)
    )
}

pub(in crate::tui) fn selected_stats_target(
    ui: &UiState,
    snapshot: &super::model::Snapshot,
) -> Option<StatsTarget> {
    match ui.stats_focus {
        StatsFocus::Configs => snapshot
            .usage_rollup
            .by_config
            .get(ui.selected_stats_config_idx)
            .map(|(k, _)| StatsTarget::Config(k.clone())),
        StatsFocus::Providers => snapshot
            .usage_rollup
            .by_provider
            .get(ui.selected_stats_provider_idx)
            .map(|(k, _)| StatsTarget::Provider(k.clone())),
    }
}

pub(in crate::tui) fn build_stats_report(
    ui: &UiState,
    snapshot: &super::model::Snapshot,
    now_ms: u64,
) -> Option<String> {
    let target = selected_stats_target(ui, snapshot)?;

    let (since_start_bucket, window_series) = match &target {
        StatsTarget::Config(name) => {
            let since = snapshot
                .usage_rollup
                .by_config
                .iter()
                .find(|(k, _)| k == name)
                .map(|(_, b)| b.clone())
                .unwrap_or_default();
            let series = snapshot
                .usage_rollup
                .by_config_day
                .get(name)
                .cloned()
                .unwrap_or_default();
            (since, series)
        }
        StatsTarget::Provider(name) => {
            let since = snapshot
                .usage_rollup
                .by_provider
                .iter()
                .find(|(k, _)| k == name)
                .map(|(_, b)| b.clone())
                .unwrap_or_default();
            let series = snapshot
                .usage_rollup
                .by_provider_day
                .get(name)
                .cloned()
                .unwrap_or_default();
            (since, series)
        }
    };

    let window_bucket = sum_buckets(&window_series);
    let recent = compute_recent_breakdown(ui, snapshot, &target);

    let (kind, name) = match &target {
        StatsTarget::Config(n) => ("config", n.as_str()),
        StatsTarget::Provider(n) => ("provider", n.as_str()),
    };

    let mut out = String::new();
    out.push_str("codex-helper TUI Stats report\n");
    out.push_str(&format!("generated_at_ms: {now_ms}\n"));
    out.push_str(&format!("service: {}\n", ui.service_name));
    out.push_str(&format!("target: {kind} {name}\n"));
    out.push_str(&format!("window_days: {}\n", ui.stats_days));
    out.push_str(&format!(
        "recent_filter: errors_only={}\n",
        ui.stats_errors_only
    ));
    out.push('\n');

    out.push_str("[window rollup]\n");
    out.push_str(&format!(
        "requests: {} (errors {} / {})  avg {}\n",
        window_bucket.requests_total,
        window_bucket.requests_error,
        fmt_pct(window_bucket.requests_error, window_bucket.requests_total),
        fmt_avg_ms(
            window_bucket.duration_ms_total,
            window_bucket.requests_total
        ),
    ));
    out.push_str(&format!("{}\n", fmt_usage_line(&window_bucket.usage)));
    out.push('\n');

    out.push_str("[since start rollup]\n");
    out.push_str(&format!(
        "requests: {} (errors {} / {})  avg {}\n",
        since_start_bucket.requests_total,
        since_start_bucket.requests_error,
        fmt_pct(
            since_start_bucket.requests_error,
            since_start_bucket.requests_total
        ),
        fmt_avg_ms(
            since_start_bucket.duration_ms_total,
            since_start_bucket.requests_total
        ),
    ));
    out.push_str(&format!("{}\n", fmt_usage_line(&since_start_bucket.usage)));
    out.push('\n');

    out.push_str("[recent breakdown (<=200)]\n");
    out.push_str(&format!(
        "requests: {}  errors: {}  2xx/3xx/4xx/5xx: {}/{}/{}/{}\n",
        recent.total,
        recent.err,
        recent.class_2xx,
        recent.class_3xx,
        recent.class_4xx,
        recent.class_5xx
    ));
    if !recent.top_status.is_empty() {
        out.push_str("top_status:\n");
        for (s, c) in &recent.top_status {
            out.push_str(&format!("  - {s}: {c}\n"));
        }
    }
    if !recent.top_models_by_tokens.is_empty() {
        out.push_str("top_models_by_tokens:\n");
        for (m, (c, tok)) in &recent.top_models_by_tokens {
            out.push_str(&format!("  - {}: {} req / {}\n", m, c, tokens_short(*tok)));
        }
    }
    if !recent.top_paths_by_tokens.is_empty() {
        out.push_str("top_paths_by_tokens:\n");
        for (path, (c, e, tok)) in &recent.top_paths_by_tokens {
            out.push_str(&format!(
                "  - {}: {} req (err {}) / {}\n",
                path,
                c,
                e,
                tokens_short(*tok)
            ));
        }
    }

    Some(out)
}