//! 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);
}
}
/// Reads usage from every provider instance's home folder and the usage
/// hubs, and shows each Claude instance's plan limits from its last
/// health check.
pub fn refresh_all(&mut self, cx: &mut Context<Self>) {
let catalog = crate::models::Catalog::global(cx);
let mut sources = UsageSources { hubs: settings::get(|settings| settings.usage_hubs.clone()), ..Default::default() };
let mut limits = Vec::new();
for state in &catalog.instances {
use crate::models::Provider;
let home = match (state.config.home.trim(), state.kind()) {
("", Provider::Claude) => settings::expand_home("~/.claude"),
("", Provider::Pi) => settings::expand_home("~/.pi/agent"),
(home, _) => settings::expand_home(home),
};
let label = match state.kind() {
Provider::Claude if state.label() == "Claude" => "Claude Code".to_string(),
_ => state.label(),
};
match state.kind() {
Provider::Claude => sources.claude_dirs.push((label.clone(), home)),
Provider::Pi => sources.pi_dirs.push((label.clone(), home)),
// jcode keeps no usage logs this page reads.
Provider::Jcode => {}
}
if let Some(usage) = &state.usage {
limits.push((SharedString::from(label), usage.clone()));
}
}
// Instances sharing a home folder share their logs; read them once.
sources.claude_dirs.dedup_by(|a, b| a.1 == b.1);
sources.pi_dirs.dedup_by(|a, b| a.1 == b.1);
self.refresh(sources, cx);
self.set_limits(limits, 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.))
.map(|this| {
if self.tab == Tab::Limits {
this.child(div().pl(px(4.)).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))
}
}