use std::time::Instant;
use costroid_config::Config;
use costroid_core::{
active_alerts, anomalies_view, budget_view, forecast_view, AdvisoryAlerts, Alert,
AnomaliesView, BudgetView, ForecastView, ProviderCapabilityView, ProviderStatus,
};
use crate::anomalies;
use crate::banner;
use crate::budget;
use crate::forecast;
use crate::format;
use crate::glyph;
use crate::overview::{self, NowBreakdown, OverviewModel};
use crate::providers;
use crate::refresh::{
due_for_refresh, Loaded, Phase, RefreshState, RefreshWorker, REFRESH_INTERVAL,
};
use crate::severity::{most_constrained_available, Constraint};
use crate::tabs::{self, Tab};
use crate::tray::{self, TrayAction, TrayController};
pub fn run() -> anyhow::Result<()> {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("Costroid")
.with_inner_size([380.0, 420.0])
.with_min_inner_size([320.0, 240.0]),
persist_window: true,
..Default::default()
};
eframe::run_native(
"costroid-bar",
options,
Box::new(|cc| Ok(Box::new(BarApp::new(cc)) as Box<dyn eframe::App>)),
)
.map_err(|err| anyhow::anyhow!("failed to start the taskbar: {err}"))
}
pub fn self_check() -> anyhow::Result<()> {
let env = costroid_core::HostEnv::detect();
let snapshot = costroid_core::collect_local_snapshot(&env)
.map_err(|err| anyhow::anyhow!("could not read local data: {err}"))?;
let summary = costroid_core::now_summary(&snapshot, costroid_core::NowOptions::default());
let loaded = Loaded { snapshot, summary };
let (config, config_status) = load_config();
let cockpit = Cockpit::build(&loaded, &config);
#[cfg(feature = "connect")]
let connections = providers::gather_connections().len();
#[cfg(not(feature = "connect"))]
let connections = 0usize;
println!(
"costroid-bar self-check ok — {} limit window(s), {} active alert(s), {} connection \
entr(ies), connect={}{}",
cockpit.overview.meters.len(),
cockpit.alerts.len(),
connections,
cfg!(feature = "connect"),
match config_status {
Some(status) => format!(" ({status})"),
None => String::new(),
},
);
Ok(())
}
pub(crate) fn color_of(rgba: [u8; 4]) -> egui::Color32 {
egui::Color32::from_rgba_unmultiplied(rgba[0], rgba[1], rgba[2], rgba[3])
}
const CARBON: [u8; 4] = [0x0b, 0x0c, 0x0e, 0xff];
const SLATE: [u8; 4] = [0x16, 0x18, 0x1c, 0xff];
pub(crate) const BONE: [u8; 4] = [0xe9, 0xe7, 0xdf, 0xff];
pub(crate) const ASH: [u8; 4] = [0x88, 0x87, 0x80, 0xff];
pub(crate) const SIGNAL: [u8; 4] = [0xc8, 0xff, 0x3d, 0xff];
pub(crate) const DATA_CYAN: [u8; 4] = [0x37, 0x8a, 0xdd, 0xff];
fn carbon_visuals() -> egui::Visuals {
let mut visuals = egui::Visuals::dark();
visuals.panel_fill = color_of(CARBON);
visuals.window_fill = color_of(SLATE);
visuals.override_text_color = Some(color_of(BONE));
visuals
}
pub(crate) struct Cockpit {
overview: OverviewModel,
breakdown: NowBreakdown,
alerts: Vec<Alert>,
budget: BudgetView,
forecast: ForecastView,
anomalies: AnomaliesView,
capabilities: Vec<ProviderCapabilityView>,
statuses: Vec<ProviderStatus>,
#[cfg(feature = "connect")]
connections: Vec<providers::ConnectionEntry>,
}
impl Cockpit {
fn build(loaded: &Loaded, config: &Config) -> Cockpit {
let summary = &loaded.summary;
let snapshot = &loaded.snapshot;
let budget = budget_view(snapshot, &config.budget_targets());
let forecast = forecast_view(snapshot);
let anomalies = anomalies_view(snapshot);
let alerts = if config.alerts_enabled() {
let advisory = AdvisoryAlerts {
forecast: config.alerts_forecast_enabled().then_some(&forecast),
anomalies: config.alerts_anomalies_enabled().then_some(&anomalies),
};
active_alerts(summary, &budget, &config.alert_thresholds(), advisory)
} else {
Vec::new()
};
Cockpit {
overview: OverviewModel::from_summary(summary),
breakdown: NowBreakdown::from_summary(summary),
alerts,
budget,
forecast,
anomalies,
capabilities: snapshot.capabilities.clone(),
statuses: snapshot.providers.clone(),
#[cfg(feature = "connect")]
connections: Vec::new(),
}
}
}
struct ShellView<'a> {
step: u8,
status: String,
config_status: Option<String>,
tab: Tab,
cockpit: Option<&'a Cockpit>,
}
#[derive(Default)]
struct ShellAction {
refresh_clicked: bool,
tab_clicked: Option<Tab>,
}
struct BarApp {
worker: RefreshWorker,
state: RefreshState,
tray: TrayController,
actions: std::sync::mpsc::Receiver<TrayAction>,
visible: bool,
quitting: bool,
config: Config,
config_status: Option<String>,
tab: Tab,
cockpit: Option<Cockpit>,
#[cfg(feature = "connect")]
connections: Vec<providers::ConnectionEntry>,
}
impl BarApp {
fn new(cc: &eframe::CreationContext<'_>) -> Self {
let ctx = cc.egui_ctx.clone();
crate::fonts::install(&ctx);
ctx.set_visuals(carbon_visuals());
let worker = RefreshWorker::spawn(ctx.clone());
let mut state = RefreshState::new();
worker.request();
state.mark_requested();
let idle = glyph::render_tray(0);
let tray = tray::spawn(&idle, &format::tooltip(None));
let actions = tray::spawn_event_bridge(ctx);
let (config, config_status) = load_config();
Self {
worker,
state,
tray,
actions,
visible: true,
quitting: false,
config,
config_status,
tab: Tab::Overview,
cockpit: None,
#[cfg(feature = "connect")]
connections: providers::gather_connections(),
}
}
fn constraint(&self) -> Option<Constraint> {
self.state
.loaded()
.and_then(|loaded| most_constrained_available(&loaded.summary))
}
fn shell_view(&self) -> ShellView<'_> {
ShellView {
step: self.constraint().as_ref().map_or(0, Constraint::step),
status: self.status_line(),
config_status: self.config_status.clone(),
tab: self.tab,
cockpit: self.cockpit.as_ref(),
}
}
fn status_line(&self) -> String {
let generated = self
.state
.loaded()
.map(|loaded| loaded.snapshot.generated_at.format("%H:%M").to_string());
status_text(
self.state.error(),
self.state.has_data(),
self.state.phase(),
generated,
)
}
fn rebuild_cockpit(&mut self) {
if let Some(loaded) = self.state.loaded() {
#[cfg_attr(not(feature = "connect"), allow(unused_mut))]
let mut cockpit = Cockpit::build(loaded, &self.config);
#[cfg(feature = "connect")]
{
cockpit.connections = self.connections.clone();
}
self.cockpit = Some(cockpit);
}
}
fn sync_tray(&self) {
let constraint = self.constraint();
let step = constraint.as_ref().map_or(0, Constraint::step);
self.tray.update(
&glyph::render_tray(step),
&format::tooltip(constraint.as_ref()),
);
}
fn request_auto_refresh(&mut self) {
self.worker.request();
self.state.mark_requested();
}
fn request_manual_refresh(&mut self) {
let (config, config_status) = load_config();
self.config = config;
self.config_status = config_status;
#[cfg(feature = "connect")]
{
self.connections = providers::gather_connections();
}
self.request_auto_refresh();
}
fn set_visible(&mut self, ctx: &egui::Context, visible: bool) {
self.visible = visible;
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(visible));
if visible {
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
self.request_manual_refresh();
}
}
fn handle_action(&mut self, action: TrayAction, ctx: &egui::Context) {
match action {
TrayAction::Toggle => self.set_visible(ctx, !self.visible),
TrayAction::Show => self.set_visible(ctx, true),
TrayAction::Refresh => self.request_manual_refresh(),
TrayAction::Quit => self.quit(ctx),
}
}
fn quit_or_hide(&mut self, ctx: &egui::Context) {
if self.tray.is_active() {
self.set_visible(ctx, false);
} else {
self.quit(ctx);
}
}
fn quit(&mut self, ctx: &egui::Context) {
self.quitting = true;
self.tray.shutdown();
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
fn handle_keys(&mut self, ctx: &egui::Context) {
let mut nav: Option<Tab> = None;
let mut refresh = false;
let mut hide = false;
ctx.input_mut(|input| {
use egui::{Key, Modifiers};
let digits = [
(Key::Num1, 1usize),
(Key::Num2, 2),
(Key::Num3, 3),
(Key::Num4, 4),
(Key::Num5, 5),
];
for (key, digit) in digits {
if input.consume_key(Modifiers::NONE, key) {
if let Some(tab) = Tab::from_digit(digit) {
nav = Some(tab);
}
}
}
if input.consume_key(Modifiers::NONE, Key::ArrowRight)
|| input.consume_key(Modifiers::NONE, Key::Tab)
{
nav = Some(self.tab.next());
}
if input.consume_key(Modifiers::NONE, Key::ArrowLeft)
|| input.consume_key(Modifiers::SHIFT, Key::Tab)
{
nav = Some(self.tab.prev());
}
if input.consume_key(Modifiers::NONE, Key::R) {
refresh = true;
}
if input.consume_key(Modifiers::NONE, Key::Q) {
hide = true;
}
});
if let Some(tab) = nav {
self.tab = tab;
}
if refresh {
self.request_manual_refresh();
}
if hide {
self.quit_or_hide(ctx);
}
}
}
impl eframe::App for BarApp {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let mut got_outcome = false;
while let Some(outcome) = self.worker.poll() {
self.state.apply(outcome, Instant::now());
got_outcome = true;
}
if got_outcome {
self.rebuild_cockpit();
self.sync_tray();
}
while let Ok(action) = self.actions.try_recv() {
self.handle_action(action, ctx);
}
if ctx.input(|i| i.viewport().close_requested()) && self.tray.is_active() && !self.quitting
{
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.set_visible(ctx, false);
}
if due_for_refresh(
self.state.phase(),
self.state.since_last_completed(),
REFRESH_INTERVAL,
) {
self.request_auto_refresh();
}
ctx.request_repaint_after(REFRESH_INTERVAL);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
self.handle_keys(&ctx);
let view = self.shell_view();
let action = draw_shell(ui, &view);
if let Some(tab) = action.tab_clicked {
self.tab = tab;
}
if action.refresh_clicked {
self.request_manual_refresh();
}
}
}
fn load_config() -> (Config, Option<String>) {
match costroid_config::load() {
Ok(config) => (config, None),
Err(error) => (Config::default(), Some(format!("config: {error}"))),
}
}
fn status_text(
error: Option<&str>,
has_data: bool,
phase: Phase,
generated_hhmm: Option<String>,
) -> String {
if let Some(reason) = error {
return format!("refresh failed — {reason}");
}
match (has_data, generated_hhmm) {
(true, Some(stamp)) => format!("updated {stamp} · estimates"),
(true, None) => "updated · estimates".to_owned(),
(false, _) if phase == Phase::InFlight => "refreshing…".to_owned(),
(false, _) => "starting…".to_owned(),
}
}
fn draw_shell(ui: &mut egui::Ui, view: &ShellView) -> ShellAction {
let mut action = ShellAction::default();
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.add_space(8.0);
draw_mark(ui, view.step);
ui.add_space(10.0);
ui.vertical(|ui| {
ui.label(egui::RichText::new("costroid").strong().size(20.0));
ui.label(egui::RichText::new(&view.status).color(color_of(ASH)));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(8.0);
if draw_refresh_button(ui) {
action.refresh_clicked = true;
}
});
});
if let Some(config_status) = &view.config_status {
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label(
egui::RichText::new(config_status)
.monospace()
.size(11.0)
.color(color_of(ASH)),
);
});
}
ui.add_space(8.0);
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| match view.cockpit {
None => {
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label(egui::RichText::new("loading local data…").color(color_of(ASH)));
});
}
Some(cockpit) => {
overview::draw(ui, &cockpit.overview);
banner::draw(ui, &cockpit.alerts);
ui.add_space(6.0);
ui.separator();
ui.add_space(4.0);
if let Some(tab) = tabs::draw_strip(ui, view.tab) {
action.tab_clicked = Some(tab);
}
ui.add_space(6.0);
draw_panel(ui, view.tab, cockpit);
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label(
egui::RichText::new("1-5 tabs · r refresh · q hide")
.monospace()
.size(10.0)
.color(color_of(ASH)),
);
});
}
});
action
}
fn draw_panel(ui: &mut egui::Ui, tab: Tab, cockpit: &Cockpit) {
match tab {
Tab::Overview => overview::draw_breakdown(ui, &cockpit.breakdown),
Tab::Budget => budget::draw(ui, &cockpit.budget),
Tab::Forecast => forecast::draw(ui, &cockpit.forecast),
Tab::Anomalies => anomalies::draw(ui, &cockpit.anomalies),
Tab::Providers => {
providers::draw(ui, &cockpit.capabilities, &cockpit.statuses);
#[cfg(feature = "connect")]
providers::draw_connection_lane(ui, &cockpit.connections);
}
}
}
fn draw_refresh_button(ui: &mut egui::Ui) -> bool {
let side = 22.0;
let (rect, response) = ui.allocate_exact_size(egui::Vec2::splat(side), egui::Sense::click());
let response = response.on_hover_text("Refresh now");
response
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Refresh now"));
let painter = ui.painter_at(rect);
let ink = color_of(if response.hovered() { BONE } else { ASH });
let center = rect.center();
let radius = side * 0.30;
let start = 150.0_f32.to_radians();
let sweep = 290.0_f32.to_radians();
let steps = 24;
let points: Vec<egui::Pos2> = (0..=steps)
.map(|i| {
let theta = start + sweep * (i as f32 / steps as f32);
center + egui::vec2(theta.cos(), theta.sin()) * radius
})
.collect();
painter.add(egui::Shape::line(points, egui::Stroke::new(1.6, ink)));
let end = start + sweep;
let at = center + egui::vec2(end.cos(), end.sin()) * radius;
let tangent = egui::vec2(-end.sin(), end.cos());
let radial = egui::vec2(end.cos(), end.sin());
let head = side * 0.18;
let tip = at + tangent * head;
let left = at - tangent * (head * 0.2) + radial * (head * 0.7);
let right = at - tangent * (head * 0.2) - radial * (head * 0.7);
painter.add(egui::Shape::convex_polygon(
vec![tip, left, right],
ink,
egui::Stroke::NONE,
));
response.clicked()
}
fn draw_mark(ui: &mut egui::Ui, step: u8) {
let side = 44.0;
let (rect, response) = ui.allocate_exact_size(egui::Vec2::splat(side), egui::Sense::hover());
response.widget_info(|| {
egui::WidgetInfo::labeled(
egui::WidgetType::Label,
true,
format!("Costroid — most-constrained limit, severity {step} of 8"),
)
});
let painter = ui.painter_at(rect);
painter.text(
rect.left_center() + egui::vec2(rect.width() * 0.16, 0.0),
egui::Align2::CENTER_CENTER,
"C",
egui::FontId::monospace(rect.height() * 0.72),
color_of(glyph::MARK_INK),
);
let filled = glyph::dots_filled(step);
let mut lit = [false; 9];
for &idx in glyph::FILL_ORDER.iter().take(filled) {
lit[idx] = true;
}
let fill = color_of(glyph::step_fill_color(step));
let empty = color_of(glyph::EMPTY_DOT);
let radius = glyph::DOT_RADIUS * rect.width().min(rect.height());
for (i, (u, v)) in glyph::dot_centers().iter().enumerate() {
let center = rect.min + egui::vec2(u * rect.width(), v * rect.height());
painter.circle_filled(center, radius, if lit[i] { fill } else { empty });
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{DateTime, Utc};
use costroid_core::{
EngineSnapshot, GroupBy, LimitAvailability, LimitKind, LimitMeasure, LimitSummary,
NowOptions, NowSummary, PeriodRange, ProviderId,
};
fn ts(secs: i64) -> DateTime<Utc> {
match DateTime::from_timestamp(secs, 0) {
Some(dt) => dt,
None => panic!("invalid test timestamp"),
}
}
#[test]
fn color_of_opaque_matches_rgb() {
assert_eq!(color_of(BONE), egui::Color32::from_rgb(0xe9, 0xe7, 0xdf));
assert_eq!(color_of(CARBON), egui::Color32::from_rgb(0x0b, 0x0c, 0x0e));
}
#[test]
fn status_text_prioritizes_error() {
let s = status_text(
Some("could not read local data: boom"),
true,
Phase::Idle,
Some("12:00".to_owned()),
);
assert_eq!(s, "refresh failed — could not read local data: boom");
}
#[test]
fn status_text_shows_freshness_when_loaded() {
let s = status_text(None, true, Phase::Idle, Some("09:41".to_owned()));
assert_eq!(s, "updated 09:41 · estimates");
}
#[test]
fn status_text_first_load_states() {
assert_eq!(
status_text(None, false, Phase::InFlight, None),
"refreshing…"
);
assert_eq!(status_text(None, false, Phase::Idle, None), "starting…");
}
fn sample_loaded() -> Loaded {
let at = ts(1_900_000_000);
let summary = NowSummary {
generated_at: at,
cost_period: PeriodRange { start: at, end: at },
group_by: GroupBy::Model,
limits: vec![LimitSummary {
tool: ProviderId::ClaudeCode,
plan: None,
kind: LimitKind::FiveHour,
label: None,
captured_at: at,
availability: LimitAvailability::Available {
measure: LimitMeasure::TokenFraction(0.5),
resets_at: at,
reset_in_seconds: 3600,
},
}],
current_costs: Vec::new(),
providers: Vec::new(),
};
let snapshot = EngineSnapshot {
generated_at: at,
usage_events: Vec::new(),
focus_rows: Vec::new(),
limit_windows: Vec::new(),
providers: Vec::new(),
capabilities: Vec::new(),
};
Loaded { snapshot, summary }
}
fn sample_cockpit() -> Cockpit {
Cockpit::build(&sample_loaded(), &Config::default())
}
#[test]
fn cockpit_build_with_default_config_raises_no_alerts() {
let cockpit = sample_cockpit();
assert!(cockpit.alerts.is_empty());
assert_eq!(cockpit.overview.meters.len(), 1);
}
#[test]
fn draw_shell_headless_tick_does_not_panic() {
let ctx = egui::Context::default();
crate::fonts::install(&ctx);
let cockpit = sample_cockpit();
for cockpit_ref in [None, Some(&cockpit)] {
for tab in Tab::ALL {
let view = ShellView {
step: 4,
status: "updated 12:00 · estimates".to_owned(),
config_status: Some("config: invalid config ...".to_owned()),
tab,
cockpit: cockpit_ref,
};
let _ = ctx.run_ui(egui::RawInput::default(), |ui| {
let _ = draw_shell(ui, &view);
});
}
}
}
#[test]
fn accesskit_tree_announces_the_painted_widgets() {
use costroid_core::{Alert, AlertLevel, LimitKind, ProviderId};
let ctx = egui::Context::default();
crate::fonts::install(&ctx);
ctx.enable_accesskit();
let mut cockpit = sample_cockpit();
cockpit.alerts.push(Alert::Quota {
tool: ProviderId::ClaudeCode,
kind: LimitKind::FiveHour,
level: AlertLevel::Critical,
fraction: 0.97,
reset_in_seconds: 3600,
});
let view = ShellView {
step: 7,
status: "updated 12:00 · estimates".to_owned(),
config_status: None,
tab: Tab::Overview,
cockpit: Some(&cockpit),
};
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
let _ = draw_shell(ui, &view);
});
let update = match output.platform_output.accesskit_update {
Some(update) => update,
None => panic!("the AccessKit tree must build when accesskit is enabled"),
};
assert!(
update.nodes.len() > 5,
"expected a populated a11y tree, got {} nodes",
update.nodes.len()
);
let mut text = String::new();
for (_, node) in &update.nodes {
if let Some(label) = node.label() {
text.push_str(label);
text.push('\n');
}
if let Some(value) = node.value() {
text.push_str(value);
text.push('\n');
}
}
assert!(
text.contains("most-constrained limit"),
"the painted mark must be named:\n{text}"
);
assert!(
text.contains("Refresh now"),
"the painted refresh button must be named:\n{text}"
);
assert!(
text.contains("claude code"),
"the painted quota meter must carry its line:\n{text}"
);
assert!(
text.contains("critical alert"),
"the painted alert badge must announce its severity:\n{text}"
);
}
#[test]
fn now_options_default_is_weekly() {
assert_eq!(
NowOptions::default().cost_period,
costroid_core::Period::Week
);
}
}