Codex Handbook
tui/src/chatwidget/tokens/chart.rs 474 lines
//! Renders account token usage summaries and activity charts for `/usage`.//!//! This module owns the chart data bucketing and ratatui line construction. The//! async card lifecycle stays in the parent `tokens` module so chart rendering//! remains a pure transformation from a loaded usage response.mod palette;use std::collections::BTreeMap;use chrono::Datelike;use chrono::Duration;use chrono::NaiveDate;use codex_app_server_protocol::GetAccountTokenUsageResponse;use ratatui::style::Style;use ratatui::style::Stylize;use ratatui::text::Line;use ratatui::text::Span;use crate::render::highlight::foreground_style_for_scopes;use crate::status::format_tokens_compact;use palette::TokenActivityPalette;const WEEK_COUNT: usize = 52;const DAY_COUNT: usize = 7;const CELL_COUNT: usize = WEEK_COUNT * DAY_COUNT;const CHART_LEFT_WIDTH: usize = 4;const SUMMARY_INDENT: &str = " ";const SUMMARY_INDENT_WIDTH: u16 = 1;/// Selects the aggregation represented by the token activity chart.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub(crate) enum TokenActivityView {    Daily,    Weekly,    Cumulative,}impl TokenActivityView {    /// Parses the optional `/usage` argument into a supported chart view.    ///    /// An empty argument selects the daily view so `/usage` and `/usage daily`    /// behave identically. Returning `None` lets the slash-command dispatcher    /// report unsupported arguments instead of silently choosing a view.    pub(in crate::chatwidget) fn parse(value: &str) -> Option<Self> {        match value.trim().to_ascii_lowercase().as_str() {            "" | "day" | "daily" => Some(Self::Daily),            "week" | "weekly" => Some(Self::Weekly),            "cumulative" => Some(Self::Cumulative),            _ => None,        }    }    pub(super) fn label(self) -> &'static str {        match self {            Self::Daily => "Daily",            Self::Weekly => "Weekly",            Self::Cumulative => "Cumulative",        }    }}pub(super) fn loaded_lines(    view: TokenActivityView,    response: &GetAccountTokenUsageResponse,    today: NaiveDate,    width: u16,) -> Vec<Line<'static>> {    let mut lines = vec![        vec![            Span::from(" Token activity").bold(),            Span::styled("   last 12 months", label_style()),        ]        .into(),    ];    lines.extend(summary_lines(response, graph_width(width)));    // Separate the headline numbers from the calendar below.    lines.push(Line::default());    let Some(buckets) = response.daily_usage_buckets.as_ref() else {        lines.push("   Token activity history unavailable".dim().into());        return lines;    };    lines.extend(chart_lines(view, buckets, today, width));    lines}fn chart_lines(    view: TokenActivityView,    buckets: &[codex_app_server_protocol::AccountTokenUsageDailyBucket],    today: NaiveDate,    width: u16,) -> Vec<Line<'static>> {    let mut lines = Vec::new();    let values = daily_values(buckets, today);    let shown_columns = shown_columns(width);    if shown_columns == 0 {        lines.push("   Widen terminal to show activity graph".dim().into());        return lines;    }    let palette = TokenActivityPalette::current();    let levels = levels_for_view(&values, view);    let first_column = WEEK_COUNT - shown_columns;    lines.push(month_labels(today, first_column, shown_columns));    for row in 0..DAY_COUNT {        let mut spans = vec![weekday_label(view, row)];        for column in first_column..WEEK_COUNT {            if column > first_column {                spans.push(" ".into());            }            let index = column * DAY_COUNT + row;            if view == TokenActivityView::Daily                && cell_date(today, index).is_some_and(|date| date > today)            {                spans.push(" ".into());            } else {                let style = if view == TokenActivityView::Daily {                    palette.for_level(levels[index])                } else {                    palette.for_bar_level(levels[index])                };                spans.push(Span::styled(palette.glyph(view, levels[index]), style));            }        }        lines.push(spans.into());    }    // Separate the calendar from the legend/footer below.    lines.push(Line::default());    match view {        TokenActivityView::Daily => lines.push(legend_line(&palette)),        TokenActivityView::Weekly | TokenActivityView::Cumulative => {            lines.push(bar_caption(view, &values))        }    }    lines.push(view_footer(view));    lines}fn shown_columns(width: u16) -> usize {    (usize::from(width)        .saturating_sub(CHART_LEFT_WIDTH)        .saturating_add(/*rhs*/ 1)        / 2)    .min(WEEK_COUNT)}fn graph_width(width: u16) -> u16 {    if width == u16::MAX {        return width;    }    (CHART_LEFT_WIDTH + shown_columns(width) * 2 - 1) as u16}fn summary_lines(response: &GetAccountTokenUsageResponse, width: u16) -> Vec<Line<'static>> {    let summary = &response.summary;    let fields = [        ("Lifetime", format_optional_tokens(summary.lifetime_tokens)),        ("Peak", format_optional_tokens(summary.peak_daily_tokens)),        (            "Streak",            format_streak(summary.current_streak_days, summary.longest_streak_days),        ),        (            "Longest task",            format_optional_duration(summary.longest_running_turn_sec),        ),    ];    pack_fields(&fields, width)        .into_iter()        .map(|group| align_summary_line(summary_line(&fields, &group), width))        .collect()}/// Greedily pack summary fields into as few lines as fit `width`,/// keeping field order. `u16::MAX` (raw/copy mode) always yields one line.fn pack_fields(fields: &[(&str, String)], width: u16) -> Vec<Vec<usize>> {    if width == u16::MAX {        return vec![(0..fields.len()).collect()];    }    let max = usize::from(width.saturating_sub(SUMMARY_INDENT_WIDTH));    let mut groups: Vec<Vec<usize>> = Vec::new();    let mut current: Vec<usize> = Vec::new();    for index in 0..fields.len() {        let mut candidate = current.clone();        candidate.push(index);        if !current.is_empty() && summary_line(fields, &candidate).width() > max {            groups.push(std::mem::take(&mut current));            current.push(index);        } else {            current = candidate;        }    }    if !current.is_empty() {        groups.push(current);    }    groups}fn summary_line(fields: &[(&str, String)], indexes: &[usize]) -> Line<'static> {    let mut spans = Vec::new();    for (index, field_index) in indexes.iter().enumerate() {        if index > 0 {            spans.push(Span::styled(" · ", label_style()));        }        let (label, value) = &fields[*field_index];        spans.push(Span::styled(format!("{label} "), label_style()));        spans.push(Span::styled(value.clone(), numeric_style()));    }    spans.into()}fn align_summary_line(mut line: Line<'static>, width: u16) -> Line<'static> {    if width == u16::MAX {        return line;    }    line.spans.insert(/*index*/ 0, SUMMARY_INDENT.into());    line}fn format_optional_tokens(value: Option<i64>) -> String {    value        .map(format_tokens_compact)        .unwrap_or_else(|| "-".to_string())}/// Combine the current and longest streak into one field: a bare `54d` when/// they match, otherwise `12d (best 54d)`.fn format_streak(current: Option<i64>, longest: Option<i64>) -> String {    match (current, longest) {        (Some(current), Some(longest)) if current == longest => format!("{current}d"),        (Some(current), Some(longest)) => format!("{current}d (best {longest}d)"),        (Some(current), None) => format!("{current}d"),        (None, Some(longest)) => format!("- (best {longest}d)"),        (None, None) => "-".to_string(),    }}fn format_optional_duration(value: Option<i64>) -> String {    value.map_or_else(        || "-".to_string(),        |seconds| {            let seconds = seconds.max(/*other*/ 0);            let hours = seconds / 3600;            let minutes = (seconds % 3600) / 60;            match (hours, minutes) {                (0, 0) => format!("{seconds}s"),                (0, minutes) => format!("{minutes}m"),                (hours, 0) => format!("{hours}h"),                (hours, minutes) => format!("{hours}h {minutes}m"),            }        },    )}fn numeric_style() -> Style {    foreground_style_for_scopes(&["constant.numeric", "constant"])        .unwrap_or_else(|| Style::default().green())}fn label_style() -> Style {    foreground_style_for_scopes(&["comment"]).unwrap_or_else(|| Style::default().dim())}fn weekday_label(view: TokenActivityView, row: usize) -> Span<'static> {    if view != TokenActivityView::Daily {        // Bar views fill from the bottom (row 6) upward, so the gutter doubles        // as a coarse Y-axis: peak at the top, baseline at the bottom.        return Span::styled(            match row {                0 => "max ",                6 => "  0 ",                _ => "    ",            },            label_style(),        );    }    Span::styled(        match row {            0 => " Su ",            1 => " Mo ",            2 => " Tu ",            3 => " We ",            4 => " Th ",            5 => " Fr ",            6 => " Sa ",            _ => "    ",        },        label_style(),    )}fn legend_line(palette: &TokenActivityPalette) -> Line<'static> {    let mut spans = vec![Span::styled("   Less ", label_style())];    for level in 0..=4 {        if level > 0 {            spans.push(" ".into());        }        spans.push(Span::styled(            palette.glyph(TokenActivityView::Daily, level),            palette.for_level(level),        ));    }    spans.push(Span::styled(" More", label_style()));    spans.into()}/// Caption for the bar-chart views, where the 5-step daily legend would be/// misleading. States what each bar represents and the peak it is scaled to.fn bar_caption(view: TokenActivityView, values: &[i64]) -> Line<'static> {    let weeks = weekly_totals(values);    let (lead, peak) = match view {        TokenActivityView::Weekly => (            "Each column = 1 week · tallest ",            weeks.iter().copied().max().unwrap_or(/*default*/ 0),        ),        TokenActivityView::Cumulative => ("Running total · top ", weeks.iter().sum::<i64>()),        TokenActivityView::Daily => ("", 0),    };    if peak <= 0 {        return Span::styled("   No token activity in the last 12 months", label_style()).into();    }    vec![        Span::styled(format!("   {lead}"), label_style()),        Span::styled(format_tokens_compact(peak), numeric_style()),    ]    .into()}/// Dim footer that surfaces the other `/usage` views and emphasizes the/// active one, so the weekly/cumulative modes are discoverable from the card.fn view_footer(active: TokenActivityView) -> Line<'static> {    let mut spans = vec![Span::styled("   ", label_style())];    let views = [        (TokenActivityView::Daily, "daily"),        (TokenActivityView::Weekly, "weekly"),        (TokenActivityView::Cumulative, "cumulative"),    ];    for (index, (view, name)) in views.into_iter().enumerate() {        if index > 0 {            spans.push(Span::styled(" · ", label_style()));        }        let style = if view == active {            numeric_style().bold()        } else {            label_style()        };        spans.push(Span::styled(name, style));    }    spans.into()}fn month_labels(today: NaiveDate, first_column: usize, shown_columns: usize) -> Line<'static> {    let mut cells = vec![' '; shown_columns * 2 - 1];    let start = chart_start(today);    let mut last_end = 0;    for column in first_column..WEEK_COUNT {        let date = start + Duration::days((column * DAY_COUNT) as i64);        if date.day() > 7 {            continue;        }        let label = date.format("%b").to_string();        let offset = (column - first_column) * 2;        if offset < last_end || offset + label.len() > cells.len() {            continue;        }        for (index, ch) in label.chars().enumerate() {            cells[offset + index] = ch;        }        last_end = offset + label.len() + 1;    }    vec![        "    ".into(),        Span::styled(cells.into_iter().collect::<String>(), label_style()),    ]    .into()}/// Normalizes backend daily buckets into the fixed 52-week display window.////// The returned vector is ordered by chart cell, starting with the oldest Sunday./// Invalid, out-of-window, and future dates are ignored. Duplicate dates are/// accumulated and negative token values do not reduce activity.fn daily_values(    buckets: &[codex_app_server_protocol::AccountTokenUsageDailyBucket],    today: NaiveDate,) -> Vec<i64> {    let start = chart_start(today);    let end = start + Duration::days(CELL_COUNT as i64);    let mut by_date = BTreeMap::new();    for bucket in buckets {        let Ok(date) = NaiveDate::parse_from_str(&bucket.start_date, "%Y-%m-%d") else {            continue;        };        if date < start || date >= end || date > today {            continue;        }        *by_date.entry(date).or_insert(/*default*/ 0) += bucket.tokens.max(/*other*/ 0);    }    (0..CELL_COUNT)        .map(|offset| {            by_date                .get(&(start + Duration::days(offset as i64)))                .copied()                .unwrap_or(/*default*/ 0)        })        .collect()}fn levels_for_view(values: &[i64], view: TokenActivityView) -> Vec<usize> {    match view {        TokenActivityView::Daily => graded_levels(values),        TokenActivityView::Weekly => bar_levels(&weekly_totals(values)),        TokenActivityView::Cumulative => {            let cumulative = weekly_totals(values)                .into_iter()                .scan(/*initial_state*/ 0, |sum, value| {                    *sum += value;                    Some(*sum)                })                .collect::<Vec<_>>();            bar_levels(&cumulative)        }    }}fn graded_levels(values: &[i64]) -> Vec<usize> {    let max = values.iter().copied().max().unwrap_or(/*default*/ 0);    values        .iter()        .map(|value| match (*value, max) {            (0, _) | (_, 0) => 0,            (value, max) if value * 4 > max * 3 => 4,            (value, max) if value * 2 > max => 3,            (value, max) if value * 4 > max => 2,            _ => 1,        })        .collect()}fn weekly_totals(values: &[i64]) -> Vec<i64> {    values        .chunks(DAY_COUNT)        .map(|week| week.iter().sum())        .collect()}fn bar_levels(totals: &[i64]) -> Vec<usize> {    let max = totals.iter().copied().max().unwrap_or(/*default*/ 0);    totals        .iter()        .flat_map(|value| {            let height = if *value <= 0 || max <= 0 {                0            } else {                ((*value * DAY_COUNT as i64 + max - 1) / max) as usize            };            (0..DAY_COUNT).map(move |row| if DAY_COUNT - row <= height { 4 } else { 0 })        })        .collect()}fn chart_start(today: NaiveDate) -> NaiveDate {    let week_start = today - Duration::days(i64::from(today.weekday().num_days_from_sunday()));    week_start - Duration::weeks((WEEK_COUNT - 1) as i64)}fn cell_date(today: NaiveDate, index: usize) -> Option<NaiveDate> {    chart_start(today).checked_add_signed(Duration::days(index as i64))}#[cfg(test)]#[path = "chart_tests.rs"]mod tests;