Vectrum
Sign In
926 lines · 35.5 KB · Rust
//! The Usage page, laid out like T3 Code's: tokens and cost over a range with
//! a daily chart and a breakdown by model or day, and the plan's limits. Also
//! the "Usage providers" section of the Providers page, for adding hubs.

use std::rc::Rc;

use chrono::{DateTime, Local};
use gpui_kit::component::{
    Icon, IconName, Sizable as _, chart::AreaChart, h_flex,
    input::{Input, InputState},
    menu::{DropdownMenu as _, PopupMenuItem},
    v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use serde_json::Value;

use crate::settings::{self, UsageHub};
use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette};
use crate::ui::pickers::CLAUDE_ORANGE;
use crate::ui::settings::{card, section, section_title};
use crate::usage::{self, Limit, Line, Span, Summary, UsageReport, UsageSources, format_cost, format_tokens};

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Tab {
    Cost,
    Tokens,
    Limits,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Breakdown {
    Model,
    Day,
}

/// One Claude instance's limits, from its `get_usage` reply.
struct PlanLimits {
    label: SharedString,
    plan: Option<String>,
    limits: Vec<Limit>,
}

pub struct UsageView {
    sources: Option<UsageSources>,
    report: Option<Rc<UsageReport>>,
    loading: bool,
    tab: Tab,
    span: Span,
    /// `None` shows all providers.
    provider: Option<String>,
    breakdown: Breakdown,
    limits: Vec<PlanLimits>,
    /// The summary of the current report, range and provider, until one changes.
    summary: Option<Rc<Summary>>,
    _collect: Option<Task<()>>,
}

impl UsageView {
    pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
        Self {
            sources: None,
            report: None,
            loading: false,
            tab: Tab::Tokens,
            span: Span::Month,
            provider: None,
            breakdown: Breakdown::Model,
            limits: Vec::new(),
            summary: None,
            _collect: None,
        }
    }

    /// Reads usage again from `sources` in the background. The last report
    /// stays up until the new one is in.
    pub fn refresh(&mut self, sources: UsageSources, cx: &mut Context<Self>) {
        self.sources = Some(sources.clone());
        self.loading = true;
        // Always read the longest range, so switching ranges needs no reload.
        let since = Span::Quarter.since(Local::now());
        let task = cx.background_executor().spawn(async move { usage::collect(&sources, since) });
        self._collect = Some(cx.spawn(async move |this, cx| {
            let report = task.await;
            let _ = this.update(cx, |this, cx| {
                this.loading = false;
                if this.provider.as_ref().is_some_and(|provider| !report.providers().contains(provider)) {
                    this.provider = None;
                }
                this.report = Some(Rc::new(report));
                this.summary = None;
                cx.notify();
            });
        }));
        cx.notify();
    }

    /// Takes each Claude instance's reply to `get_usage`, labelled.
    pub fn set_limits(&mut self, limits: Vec<(SharedString, Value)>, cx: &mut Context<Self>) {
        self.limits = limits
            .into_iter()
            .map(|(label, reply)| PlanLimits {
                label,
                plan: reply.get("subscription_type").and_then(Value::as_str).map(plan_name),
                limits: usage::limits_from_claude(&reply),
            })
            .collect();
        cx.notify();
    }

    fn reload(&mut self, cx: &mut Context<Self>) {
        if let Some(sources) = self.sources.clone() {
            self.refresh(sources, cx);
        }
    }

    fn summary(&mut self) -> Rc<Summary> {
        let report = self.report.clone().unwrap_or_default();
        self.summary
            .get_or_insert_with(|| {
                Rc::new(usage::summarize(&report, self.span, self.provider.as_deref(), Local::now().naive_local()))
            })
            .clone()
    }

    // ---- header --------------------------------------------------------------

    fn render_header(&self, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let view = cx.weak_entity();
        let providers = self.report.as_ref().map(|report| report.providers()).unwrap_or_default();
        let current = self.provider.clone();
        let provider_menu = MetalButton::new("usage-provider")
            .ghost()
            .compact()
            .label(current.clone().unwrap_or_else(|| "All providers".to_string()))
            .caret()
            .dropdown_menu_with_anchor(Anchor::TopLeft, move |mut menu, _, _| {
                let pick = |provider: Option<String>| {
                    let view = view.clone();
                    move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
                        let provider = provider.clone();
                        let _ = view.update(cx, |this, cx| {
                            this.provider = provider;
                            this.summary = None;
                            cx.notify();
                        });
                    }
                };
                menu = menu
                    .min_w(px(200.))
                    .item(PopupMenuItem::new("All providers").checked(current.is_none()).on_click(pick(None)));
                if !providers.is_empty() {
                    menu = menu.separator();
                }
                for provider in providers.clone() {
                    let checked = current.as_deref() == Some(provider.as_str());
                    menu = menu.item(PopupMenuItem::new(provider.clone()).checked(checked).on_click(pick(Some(provider))));
                }
                menu
            });

        let view = cx.weak_entity();
        let tabs = metal::segmented(
            "usage-tab",
            &[(Tab::Cost, "Cost"), (Tab::Tokens, "Tokens"), (Tab::Limits, "Limits")],
            self.tab,
            p,
            move |tab, _, cx| {
                let _ = view.update(cx, |this, cx| {
                    this.tab = tab;
                    cx.notify();
                });
            },
        );
        let view = cx.weak_entity();
        let spans: Vec<(Span, &'static str)> = Span::ALL.iter().map(|span| (*span, span.label())).collect();
        let ranges = metal::segmented("usage-range", &spans, self.span, p, move |span, _, cx| {
            let _ = view.update(cx, |this, cx| {
                this.span = span;
                this.summary = None;
                cx.notify();
            });
        });

        let updated = self.report.as_ref().and_then(|report| report.collected_at).filter(|_| !self.loading);
        let breadcrumb = h_flex()
            .gap(px(8.))
            .child(div().pl(px(4.)).text_size(px(14.)).font_weight(FontWeight::SEMIBOLD).child("Usage"))
            .child(div().text_size(px(14.)).text_color(p.ink3).child("/"))
            .map(|this| {
                if self.tab == Tab::Limits {
                    this.child(div().text_size(px(14.)).text_color(p.ink2).child("Plan limits"))
                } else {
                    this.child(provider_menu)
                }
            })
            .child(div().flex_1())
            .when(self.loading, |this| {
                this.child(
                    h_flex()
                        .gap(px(6.))
                        .child(metal::led("usage-loading", Led::Waiting, true, p))
                        .child(metal::engraved("Reading", p)),
                )
            })
            .when_some(updated, |this, at| this.child(metal::engraved(format!("Updated {}", at.format("%H:%M")), p)))
            .child(
                MetalButton::new("usage-refresh")
                    .compact()
                    .icon(IconName::RefreshCw)
                    .label("Refresh")
                    .disabled(self.loading || self.sources.is_none())
                    .on_click(cx.listener(|this, _, _, cx| this.reload(cx))),
            );

        v_flex()
            .gap(px(14.))
            .child(breadcrumb)
            .child(
                h_flex()
                    .flex_wrap()
                    .gap(px(10.))
                    .child(tabs)
                    .child(div().flex_1())
                    .when(self.tab != Tab::Limits, |this| this.child(ranges)),
            )
    }

    // ---- cost and tokens -------------------------------------------------------

    fn render_totals_page(&mut self, cx: &mut Context<Self>) -> Vec<AnyElement> {
        let p = metal::palette(cx);
        let summary = self.summary();
        let cost_tab = self.tab == Tab::Cost;
        let s = &*summary;

        if self.report.is_none() {
            let note = if self.loading { "Reading the session logs…" } else { "Nothing read yet." };
            return vec![card(vec![empty_row(note, p)], p).into_any_element()];
        }

        let total_cost = s.cost.total();
        let total_tokens = s.tokens.total();
        let headline = if cost_tab { format_cost(total_cost) } else { format_tokens(total_tokens) };
        let sessions = match s.sessions {
            1 => "1 session".to_string(),
            n => format!("{n} sessions"),
        };
        let mut caption = format!("{sessions} · {}", self.span.label().to_lowercase());
        if cost_tab && s.unpriced > 0 {
            caption.push_str(&format!(" · {} tokens without a price", format_tokens(s.unpriced)));
        }

        let legend: Vec<AnyElement> = s
            .providers
            .iter()
            .enumerate()
            .map(|(ix, provider)| {
                let share = if cost_tab {
                    ratio(provider.cost, total_cost)
                } else {
                    ratio(provider.tokens as f64, total_tokens as f64)
                };
                let detail = if cost_tab {
                    format!("{:.1}% of cost · {} tokens", share * 100., format_tokens(provider.tokens))
                } else {
                    format!("{:.1}% of tokens · {}", share * 100., format_cost(provider.cost))
                };
                h_flex()
                    .gap(px(10.))
                    .child(div().flex_none().size(px(8.)).rounded_full().bg(provider_color(ix, &provider.label, p)))
                    .child(div().text_size(px(13.5)).font_weight(FontWeight::MEDIUM).child(provider.label.clone()))
                    .child(div().text_size(px(12.5)).text_color(p.ink3).child(match provider.sessions {
                        1 => "1 session".to_string(),
                        n => format!("{n} sessions"),
                    }))
                    .child(div().flex_1())
                    .child(div().font_family(metal::MONO).text_size(px(12.5)).child(if cost_tab {
                        format_cost(provider.cost)
                    } else {
                        format_tokens(provider.tokens)
                    }))
                    .child(div().w(px(200.)).text_size(px(12.5)).text_color(p.ink3).text_right().child(detail))
                    .into_any_element()
            })
            .collect();

        let hero = v_flex()
            .px(px(22.))
            .py(px(20.))
            .gap(px(18.))
            .rounded(px(18.))
            .raised(p)
            .child(
                v_flex()
                    .gap(px(6.))
                    .child(metal::engraved(if cost_tab { "Total cost" } else { "Processed tokens" }, p))
                    .child(div().font_family(metal::PIXEL).text_size(px(40.)).line_height(px(44.)).child(headline))
                    .child(div().text_size(px(12.5)).text_color(p.ink3).child(caption)),
            )
            .map(|this| {
                if legend.is_empty() {
                    this.child(div().text_size(px(13.)).text_color(p.ink3).child("No usage in this range."))
                } else {
                    this.child(v_flex().gap(px(10.)).children(legend))
                }
            });

        let chart_title = match (self.span.hourly(), cost_tab) {
            (true, true) => "Hourly cost",
            (true, false) => "Hourly processed tokens",
            (false, true) => "Daily cost",
            (false, false) => "Daily processed tokens",
        };
        let chart = v_flex()
            .px(px(18.))
            .pt(px(16.))
            .pb(px(12.))
            .gap(px(12.))
            .rounded(px(18.))
            .raised(p)
            .child(
                h_flex()
                    .child(div().flex_1().text_size(px(13.5)).font_weight(FontWeight::MEDIUM).child(chart_title))
                    .child(metal::engraved(self.span.label(), p)),
            )
            .child(div().h(px(200.)).child(self.chart(&summary, cost_tab)));

        let totals = if cost_tab {
            vec![
                stat("Total cost", format_cost(total_cost), p),
                stat("Input", format_cost(s.cost.input), p),
                stat("Cache write", format_cost(s.cost.cache_write), p),
                stat("Cache read", format_cost(s.cost.cache_read), p),
                stat("Output", format_cost(s.cost.output), p),
            ]
        } else {
            vec![
                stat("Processed tokens", format_tokens(total_tokens), p),
                stat("Cached input", format_tokens(s.tokens.cache_read), p),
                stat("Uncached input", format_tokens(s.tokens.input + s.tokens.cache_write), p),
                stat("Output", format_tokens(s.tokens.output), p),
                stat("Cache savings", format_cost(s.savings), p),
            ]
        };
        let totals = v_flex()
            .gap(px(10.))
            .child(div().px(px(4.)).child(section_title("Totals", p)))
            .child(
                h_flex()
                    .rounded(px(18.))
                    .raised(p)
                    .children(totals.into_iter().enumerate().flat_map(|(ix, stat)| {
                        let rule = (ix > 0).then(|| div().flex_none().w(px(1.)).h(px(36.)).bg(p.rule).into_any_element());
                        rule.into_iter().chain(std::iter::once(stat))
                    })),
            );

        let parts = if cost_tab {
            [
                ("Input", s.cost.input),
                ("Cache read", s.cost.cache_read),
                ("Cache write", s.cost.cache_write),
                ("Output", s.cost.output),
            ]
        } else {
            [
                ("Input", s.tokens.input as f64),
                ("Cache read", s.tokens.cache_read as f64),
                ("Cache write", s.tokens.cache_write as f64),
                ("Output", s.tokens.output as f64),
            ]
        };
        let by_type = section(
            if cost_tab { "Cost by type" } else { "Tokens by type" },
            None,
            vec![type_bar(&parts, cost_tab, p)],
            p,
        );

        vec![
            hero.into_any_element(),
            chart.into_any_element(),
            totals.into_any_element(),
            by_type,
            self.render_breakdown(&summary, cx),
        ]
    }

    fn chart(&self, summary: &Summary, cost_tab: bool) -> AnyElement {
        let orange: Hsla = rgba(CLAUDE_ORANGE).into();
        let ticks = match self.span {
            Span::Day => 6,
            Span::Week => 7,
            Span::Month => 6,
            Span::Quarter => 7,
        };
        let value = move |point: &usage::Point| if cost_tab { point.cost } else { point.tokens as f64 };
        let format = move |value: f64| -> SharedString {
            if cost_tab { format_cost(value).into() } else { format_tokens(value.max(0.) as u64).into() }
        };
        AreaChart::new(summary.series.clone())
            .id("usage-chart")
            .appear_key((self.tab, self.span))
            .x(|point| SharedString::from(point.label.clone()))
            .y(value)
            .name(if cost_tab { "Cost" } else { "Tokens" })
            .stroke(orange)
            .fill(linear_gradient(
                180.,
                linear_color_stop(orange.opacity(0.22), 0.),
                linear_color_stop(orange.opacity(0.02), 1.),
            ))
            .natural()
            .x_tick_count(ticks)
            .y_axis(true)
            .y_tick_count(4)
            .y_tick_format(format)
            .tooltip_title(|point| point.title.clone().into())
            .tooltip_value(move |_, _, value| format(value))
            .into_any_element()
    }

    fn render_breakdown(&self, summary: &Summary, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let cost_tab = self.tab == Tab::Cost;
        let lines: &[Line] = match self.breakdown {
            Breakdown::Model => &summary.models,
            Breakdown::Day => &summary.days,
        };
        let total = if cost_tab { summary.cost.total() } else { summary.tokens.total() as f64 };
        let orange: Hsla = rgba(CLAUDE_ORANGE).into();
        let view = cx.weak_entity();

        let head = h_flex()
            .px(px(18.))
            .pt(px(12.))
            .pb(px(8.))
            .gap(px(12.))
            .child(metal::engraved("#", p).w(px(20.)))
            .child(metal::engraved(if self.breakdown == Breakdown::Model { "Model" } else { "Day" }, p).flex_1())
            .child(metal::engraved("Cost", p).w(px(84.)).text_right())
            .child(metal::engraved("Share", p).w(px(60.)).text_right())
            .child(metal::engraved("Tokens", p).w(px(70.)).text_right());

        let mut rows = vec![head.into_any_element()];
        if lines.is_empty() {
            rows.push(empty_row("No usage in this range.", p));
        }
        rows.extend(lines.iter().enumerate().map(|(ix, line)| {
            let share = if cost_tab {
                ratio(line.cost.unwrap_or(0.), total)
            } else {
                ratio(line.tokens as f64, total)
            };
            h_flex()
                .px(px(18.))
                .py(px(10.))
                .gap(px(12.))
                .text_size(px(13.))
                .child(div().w(px(20.)).font_family(metal::MONO).text_size(px(11.5)).text_color(p.ink3).child((ix + 1).to_string()))
                .child(
                    v_flex()
                        .flex_1()
                        .min_w_0()
                        .gap(px(5.))
                        .child(div().truncate().font_weight(FontWeight::MEDIUM).child(line.label.clone()))
                        .child(
                            div()
                                .h(px(3.))
                                .w_full()
                                .rounded_full()
                                .bg(p.rule)
                                .child(div().h_full().rounded_full().bg(orange).w(relative(share as f32))),
                        ),
                )
                .child(
                    div()
                        .w(px(84.))
                        .text_right()
                        .font_family(metal::MONO)
                        .text_size(px(12.))
                        .text_color(if line.cost.is_some() { p.ink } else { p.ink3 })
                        .child(line.cost.map(format_cost).unwrap_or_else(|| "Unpriced".to_string())),
                )
                .child(
                    div()
                        .w(px(60.))
                        .text_right()
                        .font_family(metal::MONO)
                        .text_size(px(12.))
                        .text_color(p.ink2)
                        .child(format!("{:.1}%", share * 100.)),
                )
                .child(
                    div()
                        .w(px(70.))
                        .text_right()
                        .font_family(metal::MONO)
                        .text_size(px(12.))
                        .child(format_tokens(line.tokens)),
                )
                .into_any_element()
        }));

        v_flex()
            .gap(px(10.))
            .child(
                h_flex()
                    .px(px(4.))
                    .child(div().flex_1().child(section_title("Breakdown", p)))
                    .child(metal::segmented(
                        "usage-breakdown",
                        &[(Breakdown::Model, "Model"), (Breakdown::Day, "Day")],
                        self.breakdown,
                        p,
                        move |breakdown, _, cx| {
                            let _ = view.update(cx, |this, cx| {
                                this.breakdown = breakdown;
                                cx.notify();
                            });
                        },
                    )),
            )
            .child(card(rows, p))
            .into_any_element()
    }

    // ---- limits ----------------------------------------------------------------

    fn render_limits(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
        let p = metal::palette(cx);
        if self.limits.is_empty() {
            return vec![section(
                "Plan limits",
                None,
                vec![empty_row("Limits show once Claude Code has reported them.", p)],
                p,
            )];
        }
        let now = Local::now();
        self.limits
            .iter()
            .map(|plan| {
                let rows = if plan.limits.is_empty() {
                    vec![empty_row("This account reports no limits.", p)]
                } else {
                    plan.limits.iter().map(|limit| limit_row(limit, now, p)).collect()
                };
                v_flex()
                    .gap(px(10.))
                    .child(
                        h_flex()
                            .px(px(4.))
                            .gap(px(8.))
                            .child(
                                div()
                                    .text_size(px(14.))
                                    .font_weight(FontWeight::MEDIUM)
                                    .text_color(p.ink2)
                                    .child(plan.label.clone()),
                            )
                            .when_some(plan.plan.clone(), |this, plan| this.child(metal::tag(plan, p))),
                    )
                    .child(card(rows, p))
                    .into_any_element()
            })
            .collect()
    }
}

impl Render for UsageView {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let errors = self.report.as_ref().map(|report| report.errors.clone()).unwrap_or_default();
        let body = match self.tab {
            Tab::Limits => self.render_limits(cx),
            Tab::Cost | Tab::Tokens => self.render_totals_page(cx),
        };
        v_flex()
            .w_full()
            .gap(px(24.))
            .child(self.render_header(cx))
            .when(!errors.is_empty() && self.tab != Tab::Limits, |this| {
                this.child(card(
                    errors
                        .into_iter()
                        .map(|error| {
                            h_flex()
                                .px(px(18.))
                                .py(px(12.))
                                .gap(px(10.))
                                .child(Icon::new(IconName::TriangleAlert).size(px(14.)).text_color(p.red))
                                .child(div().flex_1().min_w_0().text_size(px(12.5)).text_color(p.ink2).child(error))
                                .into_any_element()
                        })
                        .collect(),
                    p,
                ))
            })
            .children(body)
    }
}

// ---- building blocks ---------------------------------------------------------

fn ratio(part: f64, whole: f64) -> f64 {
    if whole > 0. { (part / whole).clamp(0., 1.) } else { 0. }
}

/// Claude Code in Claude's orange, everything else in MetalUI's tints.
fn provider_color(ix: usize, label: &str, p: &Palette) -> Hsla {
    if label.starts_with("Claude") {
        rgba(CLAUDE_ORANGE).into()
    } else {
        p.tints[(ix + 2) % p.tints.len()]
    }
}

/// `team` → `Team plan`.
fn plan_name(kind: &str) -> String {
    let mut chars = kind.chars();
    match chars.next() {
        Some(first) => format!("{}{} plan", first.to_uppercase(), chars.as_str()),
        None => "Plan".to_string(),
    }
}

fn empty_row(text: &'static str, p: &Palette) -> AnyElement {
    div().px(px(18.)).py(px(22.)).text_size(px(13.)).text_color(p.ink3).child(text).into_any_element()
}

/// One figure in the totals strip.
fn stat(label: &'static str, value: String, p: &Palette) -> AnyElement {
    v_flex()
        .flex_1()
        .min_w_0()
        .px(px(18.))
        .py(px(14.))
        .gap(px(6.))
        .child(metal::engraved(label, p))
        .child(div().font_family(metal::MONO).text_size(px(16.)).font_weight(FontWeight::MEDIUM).child(value))
        .into_any_element()
}

/// The stacked bar of input, cache reads, cache writes and output, with its legend.
fn type_bar(parts: &[(&'static str, f64); 4], cost: bool, p: &Palette) -> AnyElement {
    let total: f64 = parts.iter().map(|(_, value)| value).sum();
    let colors = [p.tints[4], p.tints[2], p.tints[5], rgba(CLAUDE_ORANGE).into()];
    v_flex()
        .px(px(18.))
        .py(px(16.))
        .gap(px(14.))
        .child(
            h_flex()
                .h(px(10.))
                .w_full()
                .rounded_full()
                .overflow_hidden()
                .well(p)
                .children(parts.iter().zip(colors).filter(|((_, value), _)| *value > 0.).map(|((_, value), color)| {
                    div().h_full().w(relative(ratio(*value, total) as f32)).bg(color)
                })),
        )
        .child(h_flex().flex_wrap().gap_x(px(22.)).gap_y(px(8.)).children(parts.iter().zip(colors).map(
            |((label, value), color)| {
                h_flex()
                    .gap(px(7.))
                    .text_size(px(12.5))
                    .child(div().size(px(8.)).rounded_full().bg(color))
                    .child(div().text_color(p.ink2).child(*label))
                    .child(div().font_family(metal::MONO).text_size(px(12.)).child(if cost {
                        format_cost(*value)
                    } else {
                        format_tokens(*value as u64)
                    }))
                    .child(div().text_color(p.ink3).child(format!("{:.1}%", ratio(*value, total) * 100.)))
            },
        )))
        .into_any_element()
}

fn limit_row(limit: &Limit, now: DateTime<Local>, p: &Palette) -> AnyElement {
    let color = match limit.severity.as_str() {
        "critical" => p.red,
        "warning" => p.amber,
        _ => p.green_deep,
    };
    let resets = limit.resets_at.map(|at| {
        let left = at.signed_duration_since(now);
        let when = at.format("%a, %b %-d, %H:%M");
        if left.num_seconds() <= 0 {
            format!("Reset {when}")
        } else if left.num_hours() >= 24 {
            format!("Resets in {}d {}h · {when}", left.num_days(), left.num_hours() % 24)
        } else if left.num_minutes() >= 60 {
            format!("Resets in {}h {}m · {when}", left.num_hours(), left.num_minutes() % 60)
        } else {
            format!("Resets in {}m · {when}", left.num_minutes().max(1))
        }
    });
    v_flex()
        .px(px(18.))
        .py(px(14.))
        .gap(px(9.))
        .child(
            h_flex()
                .gap(px(8.))
                .child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).child(limit.label.clone()))
                .when(limit.active, |this| this.child(metal::tag("Active", p)))
                .child(div().flex_1())
                .child(
                    div()
                        .font_family(metal::MONO)
                        .text_size(px(12.5))
                        .child(format!("{}% used", limit.percent.round())),
                ),
        )
        .child(
            div().h(px(8.)).w_full().rounded_full().well(p).child(
                div().h_full().rounded_full().bg(color).w(relative((limit.percent / 100.).clamp(0., 1.) as f32)),
            ),
        )
        .when_some(resets, |this, resets| {
            this.child(div().text_size(px(12.5)).text_color(p.ink3).child(resets))
        })
        .into_any_element()
}

// ---- usage hubs -----------------------------------------------------------------

/// The "Usage providers" section of the Providers page: hubs the Usage page
/// also reads, and a form to add one.
pub struct HubSettings {
    adding: bool,
    name: Entity<InputState>,
    url: Entity<InputState>,
    key: Entity<InputState>,
    error: Option<SharedString>,
}

impl HubSettings {
    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
        Self {
            adding: false,
            name: cx.new(|cx| InputState::new(window, cx).placeholder("CLIProxyAPI")),
            url: cx.new(|cx| InputState::new(window, cx).placeholder("http://localhost:8317")),
            key: cx.new(|cx| InputState::new(window, cx).placeholder("Management key").masked(true)),
            error: None,
        }
    }

    fn start_adding(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        for input in [&self.name, &self.url, &self.key] {
            input.update(cx, |input, cx| input.set_value("", window, cx));
        }
        self.adding = true;
        self.error = None;
        self.name.update(cx, |input, cx| input.focus(window, cx));
        cx.notify();
    }

    fn save(&mut self, cx: &mut Context<Self>) {
        let value = |input: &Entity<InputState>| input.read(cx).value().trim().to_string();
        let (name, url, key) = (value(&self.name), value(&self.url), value(&self.key));
        if url.is_empty() {
            self.error = Some("The hub needs a URL.".into());
            cx.notify();
            return;
        }
        let name = if name.is_empty() { "CLIProxyAPI".to_string() } else { name };
        settings::update(|settings| {
            settings.usage_hubs.push(UsageHub { name, kind: "cliproxy".into(), url, key });
        });
        self.adding = false;
        self.error = None;
        cx.notify();
    }

    fn remove(&mut self, ix: usize, cx: &mut Context<Self>) {
        settings::update(|settings| {
            if ix < settings.usage_hubs.len() {
                settings.usage_hubs.remove(ix);
            }
        });
        cx.notify();
    }

    fn render_form(&self, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let field = |label: &'static str, input: &Entity<InputState>| {
            h_flex()
                .gap(px(16.))
                .child(div().w(px(120.)).text_size(px(13.)).font_weight(FontWeight::MEDIUM).child(label))
                .child(
                    h_flex()
                        .flex_1()
                        .h(px(32.))
                        .px(px(11.))
                        .rounded(px(12.))
                        .well(p)
                        .text_size(px(12.5))
                        .text_color(p.ink)
                        .child(div().flex_1().min_w_0().child(Input::new(input).appearance(false).small())),
                )
        };
        v_flex()
            .px(px(18.))
            .py(px(16.))
            .gap(px(10.))
            .child(field("Name", &self.name))
            .child(field("URL", &self.url))
            .child(field("Management key", &self.key))
            .child(
                h_flex()
                    .gap(px(16.))
                    .child(div().w(px(120.)).text_size(px(13.)).font_weight(FontWeight::MEDIUM).child("Kind"))
                    .child(metal::tag("CLI Proxy", p)),
            )
            .child(
                h_flex()
                    .pt(px(4.))
                    .gap(px(8.))
                    .when_some(self.error.clone(), |this, error| {
                        this.child(div().text_size(px(12.5)).text_color(p.red).child(error))
                    })
                    .child(div().flex_1())
                    .child(
                        MetalButton::new("hub-cancel")
                            .ghost()
                            .compact()
                            .label("Cancel")
                            .on_click(cx.listener(|this, _, _, cx| {
                                this.adding = false;
                                this.error = None;
                                cx.notify();
                            })),
                    )
                    .child(
                        MetalButton::new("hub-save")
                            .primary()
                            .compact()
                            .label("Save")
                            .on_click(cx.listener(|this, _, _, cx| this.save(cx))),
                    ),
            )
            .into_any_element()
    }
}

impl Render for HubSettings {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let hubs = settings::get(|settings| settings.usage_hubs.clone());
        let mut rows: Vec<AnyElement> = hubs
            .iter()
            .enumerate()
            .map(|(ix, hub)| {
                let kind = if hub.kind == "cliproxy" || hub.kind.is_empty() { "CLI Proxy" } else { hub.kind.as_str() };
                h_flex()
                    .px(px(18.))
                    .py(px(14.))
                    .gap(px(12.))
                    .child(Icon::new(IconName::Network).size(px(16.)).text_color(p.ink2))
                    .child(
                        v_flex()
                            .flex_1()
                            .min_w_0()
                            .gap(px(3.))
                            .child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).child(hub.name.clone()))
                            .child(
                                div()
                                    .truncate()
                                    .text_size(px(12.5))
                                    .text_color(p.ink3)
                                    .child(format!("{kind} · {}", hub.url)),
                            ),
                    )
                    .child(
                        MetalButton::new(("remove-hub", ix))
                            .ghost()
                            .compact()
                            .label("Remove")
                            .on_click(cx.listener(move |this, _, _, cx| this.remove(ix, cx))),
                    )
                    .into_any_element()
            })
            .collect();
        if self.adding {
            rows.push(self.render_form(cx));
        } else if rows.is_empty() {
            rows.push(empty_row("No hubs configured.", p));
        }

        v_flex()
            .gap(px(10.))
            .child(
                h_flex()
                    .px(px(16.))
                    .gap(px(8.))
                    .child(
                        v_flex()
                            .flex_1()
                            .gap(px(3.))
                            .child(h_flex().min_h(px(28.)).child(section_title("Usage providers", p)))
                            .child(
                                div()
                                    .text_size(px(12.))
                                    .line_height(px(18.))
                                    .text_color(p.ink3)
                                    .child("Hubs whose usage the Usage page adds to the session logs."),
                            ),
                    )
                    .child(
                        MetalButton::new("add-hub")
                            .compact()
                            .icon(IconName::Plus)
                            .label("Add hub")
                            .disabled(self.adding)
                            .on_click(cx.listener(|this, _, window, cx| this.start_adding(window, cx))),
                    ),
            )
            .child(card(rows, p))
    }
}