use std::time::Duration;
use chrono::Utc;
use reqwest::Client;
use crate::cache::DEFAULT_TTL;
use crate::config::Config;
use crate::error::Result;
use crate::theme::Theme;
use crate::vendor::{VendorId, VendorOutcome};
#[derive(Debug, Clone)]
pub enum TabState {
Loading,
Ready(Box<ReadyTab>),
Error(String),
}
#[derive(Debug, Clone)]
pub struct ReadyTab {
pub snapshot: crate::usage::VendorSnapshot,
pub stale: bool,
pub last_error: Option<(u16, String)>,
pub fetched_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TabId {
pub vendor: VendorId,
pub account: Option<String>,
}
impl TabId {
pub fn vendor(vendor: VendorId) -> Self {
Self {
vendor,
account: None,
}
}
pub fn account(label: impl Into<String>) -> Self {
Self {
vendor: VendorId::Anthropic,
account: Some(label.into()),
}
}
}
pub fn tabs_from_config(config: &Config) -> Vec<TabId> {
let mut tabs = Vec::new();
for vendor in config.enabled_vendors() {
if vendor == VendorId::Anthropic {
let accounts = config.anthropic.all_accounts();
if config.anthropic.show_default_account || accounts.is_empty() {
tabs.push(TabId::vendor(vendor));
}
for acct in accounts {
tabs.push(TabId::account(acct.label));
}
} else {
tabs.push(TabId::vendor(vendor));
}
}
tabs
}
#[derive(Debug)]
pub struct App {
pub tabs_meta: Vec<TabId>,
pub active: usize,
pub tabs: Vec<TabState>,
pub tab_generation: u64,
pub overview: bool,
pub overview_vendors: Option<Vec<VendorId>>,
pub theme: Theme,
pub quit: bool,
pub settings: Option<crate::tui::settings::SettingsState>,
pub context_enabled: bool,
pub context_generation: u64,
pub context: Option<crate::tui::context::ContextState>,
pub vendor_box: crate::config::VendorBoxStyle,
}
impl App {
pub fn new(tabs_meta: Vec<TabId>) -> Self {
Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
}
pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
let n = tabs_meta.len();
Self {
tabs_meta,
active: 0,
tabs: vec![TabState::Loading; n],
tab_generation: 0,
overview: false,
overview_vendors: None,
theme,
quit: false,
settings: None,
context_enabled: false,
context_generation: 0,
context: None,
vendor_box: crate::config::VendorBoxStyle::Sidebar,
}
}
pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
let mut app = Self::new(tabs_meta);
if primary.is_some() {
app.select_primary(primary);
} else {
app.overview = true;
}
app
}
pub fn active_tab_id(&self) -> Option<&TabId> {
self.tabs_meta.get(self.active)
}
pub fn active_vendor(&self) -> Option<VendorId> {
self.tabs_meta.get(self.active).map(|t| t.vendor)
}
pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
let selected = self.active_tab_id().cloned();
let fallback = self.active.min(tabs_meta.len().saturating_sub(1));
self.tab_generation = self.tab_generation.wrapping_add(1);
self.active = selected
.as_ref()
.and_then(|tab| tabs_meta.iter().position(|candidate| candidate == tab))
.unwrap_or(fallback);
self.tabs = vec![TabState::Loading; tabs_meta.len()];
self.tabs_meta = tabs_meta;
}
pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
if generation != self.tab_generation {
return false;
}
let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
return false;
};
self.tabs[index] = state;
true
}
pub fn select_primary(&mut self, primary: Option<VendorId>) {
if let Some(p) = primary
&& let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor == p)
{
self.active = idx;
self.overview = false;
}
}
pub fn next_tab(&mut self) {
if self.overview {
if !self.tabs_meta.is_empty() {
self.overview = false;
self.active = 0;
}
} else if self.active + 1 < self.tabs_meta.len() {
self.active += 1;
} else {
self.overview = true;
}
}
pub fn prev_tab(&mut self) {
if self.overview {
if !self.tabs_meta.is_empty() {
self.overview = false;
self.active = self.tabs_meta.len() - 1;
}
} else if self.active > 0 {
self.active -= 1;
} else {
self.overview = true;
}
}
pub fn overview_tabs(&self) -> Vec<usize> {
match &self.overview_vendors {
None => (0..self.tabs_meta.len()).collect(),
Some(wanted) => wanted
.iter()
.flat_map(|v| {
self.tabs_meta
.iter()
.enumerate()
.filter(move |(_, t)| t.vendor == *v)
.map(|(i, _)| i)
})
.collect(),
}
}
}
pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
match build_outcome(client, config, tab).await {
Ok(outcome) => {
let now = Utc::now();
let fetched_at = outcome
.cache_age
.map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
TabState::Ready(Box::new(ReadyTab {
snapshot: outcome.snapshot,
stale: outcome.stale,
last_error: outcome.last_error.map(|(code, message)| {
(code, crate::display::sanitize_untrusted_field(&message))
}),
fetched_at,
}))
}
Err(e) => TabState::Error(crate::display::sanitize_untrusted_field(&e.to_string())),
}
}
async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
match tab.vendor {
VendorId::Anthropic => {
let (creds_target, cache) = match tab.account.as_deref() {
Some(label) => config.anthropic.account_target(label)?,
None => {
let target = match config.anthropic.credentials_path.clone() {
Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
None => crate::anthropic::creds::CredsTarget::Default(
crate::anthropic::creds::default_path().unwrap_or_default(),
),
};
(target, crate::cache::Cache::for_vendor("anthropic")?)
}
};
let endpoints = crate::anthropic::fetch::Endpoints::default();
let outcome = crate::anthropic::fetch_snapshot(
client,
&creds_target,
&cache,
&endpoints,
DEFAULT_TTL,
)
.await?;
Ok(crate::vendor::VendorOutcome {
snapshot: crate::usage::VendorSnapshot::Anthropic(outcome.snapshot),
stale: outcome.stale,
last_error: outcome.last_error,
cache_age: outcome.cache_age,
})
}
VendorId::AnthropicApi => {
let key = crate::config::resolve_api_key(
"Anthropic_API",
&config.anthropic_api.api_key_env,
config.anthropic_api.api_key.as_deref(),
)?;
let cache = crate::cache::Cache::for_vendor("anthropic_api")?;
let endpoints = crate::anthropic_api::fetch::Endpoints::default();
let outcome = crate::anthropic_api::fetch_snapshot(
client,
&key,
&cache,
&endpoints,
DEFAULT_TTL,
config.anthropic_api.monthly_limit,
)
.await?;
Ok(outcome.into())
}
VendorId::Openrouter => {
let api_key = crate::config::resolve_api_key(
"OpenRouter",
&config.openrouter.api_key_env,
config.openrouter.api_key.as_deref(),
)?;
let cache = crate::cache::Cache::for_vendor("openrouter")?;
let endpoints = crate::openrouter::fetch::Endpoints::default();
let outcome = crate::openrouter::fetch_snapshot(
client,
&api_key,
&cache,
&endpoints,
DEFAULT_TTL,
)
.await?;
Ok(outcome.into())
}
VendorId::Zai => {
let api_key = crate::config::resolve_api_key(
"Zai",
&config.zai.api_key_env,
config.zai.api_key.as_deref(),
)?;
let cache = crate::cache::Cache::for_vendor("zai")?;
let endpoints = crate::zai::fetch::Endpoints::default();
let outcome = crate::zai::fetch_snapshot(
client,
&api_key,
&cache,
&endpoints,
DEFAULT_TTL,
config.zai.plan_tier.as_deref(),
)
.await?;
Ok(outcome.into())
}
VendorId::Openai => {
let cache = crate::cache::Cache::for_vendor("openai")?;
let creds_path = config
.openai
.codex_auth_path
.clone()
.unwrap_or_else(|| crate::openai::creds::default_path().unwrap_or_default());
let endpoints = crate::openai::fetch::Endpoints::default();
let outcome =
crate::openai::fetch_snapshot(client, &creds_path, &cache, &endpoints, DEFAULT_TTL)
.await?;
Ok(outcome.into())
}
VendorId::Deepseek => {
let api_key = crate::config::resolve_api_key(
"DeepSeek",
&config.deepseek.api_key_env,
config.deepseek.api_key.as_deref(),
)?;
let cache = crate::cache::Cache::for_vendor("deepseek")?;
let endpoints = crate::deepseek::fetch::Endpoints::default();
let outcome =
crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
.await?;
Ok(outcome.into())
}
VendorId::Kimi => {
let api_key = crate::config::resolve_api_key(
"Kimi",
&config.kimi.api_key_env,
config.kimi.api_key.as_deref(),
)?;
let cache = crate::cache::Cache::for_vendor("kimi")?;
let endpoints = crate::kimi::fetch::Endpoints::default();
let outcome =
crate::kimi::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
.await?;
Ok(outcome.into())
}
VendorId::Kilo => {
let api_key = crate::config::resolve_api_key(
"Kilo",
&config.kilo.api_key_env,
config.kilo.api_key.as_deref(),
)?;
let cache = crate::cache::Cache::for_vendor("kilo")?;
let endpoints = crate::kilo::fetch::Endpoints::default();
let outcome = crate::kilo::fetch_snapshot(
client,
&api_key,
&cache,
&endpoints,
DEFAULT_TTL,
config.kilo.organization_id.as_deref(),
)
.await?;
Ok(outcome.into())
}
VendorId::Novita => {
let api_key = crate::config::resolve_api_key(
"Novita",
&config.novita.api_key_env,
config.novita.api_key.as_deref(),
)?;
let cache = crate::cache::Cache::for_vendor("novita")?;
let endpoints = crate::novita::fetch::Endpoints::default();
let outcome =
crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
.await?;
Ok(outcome.into())
}
VendorId::Moonshot => {
let api_key = crate::config::resolve_api_key(
"Moonshot",
&config.moonshot.api_key_env,
config.moonshot.api_key.as_deref(),
)?;
let cache = crate::cache::Cache::for_vendor("moonshot")?;
let (endpoints, currency) =
crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
let outcome = crate::moonshot::fetch_snapshot(
client,
&api_key,
&cache,
&endpoints,
DEFAULT_TTL,
currency,
)
.await?;
Ok(outcome.into())
}
VendorId::Grok => {
let key = crate::config::resolve_api_key(
"Grok",
&config.grok.api_key_env,
config.grok.api_key.as_deref(),
)?;
let cache = crate::cache::Cache::for_vendor("grok")?;
let endpoints = crate::grok::fetch::Endpoints::default();
let outcome = crate::grok::fetch_snapshot(
client,
&key,
&cache,
&endpoints,
DEFAULT_TTL,
config.grok.team_id.as_deref(),
)
.await?;
Ok(outcome.into())
}
VendorId::Antigravity => {
let cache = crate::cache::Cache::for_vendor("antigravity")?;
let outcome = crate::antigravity::fetch_snapshot(client, &cache, DEFAULT_TTL).await?;
Ok(outcome.into())
}
VendorId::Minimax => {
let api_key = crate::config::resolve_api_key(
"MiniMax",
&config.minimax.api_key_env,
config.minimax.api_key.as_deref(),
)?;
let cache = crate::cache::Cache::for_vendor("minimax")?;
let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
let outcome =
crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
.await?;
Ok(outcome.into())
}
VendorId::Cursor => {
let cache = crate::cache::Cache::for_vendor("cursor")?;
let db_path = config
.cursor
.db_path
.clone()
.map(Ok)
.unwrap_or_else(crate::cursor::db::default_db_path)?;
let endpoints = crate::cursor::fetch::Endpoints::default();
let outcome =
crate::cursor::fetch_snapshot(client, &db_path, &cache, &endpoints, DEFAULT_TTL)
.await?;
Ok(outcome.into())
}
}
}
pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
let mut anthropic_seen: u32 = 0;
tabs.iter()
.map(|tab| {
if tab.vendor == VendorId::Anthropic {
let delay = step * anthropic_seen;
anthropic_seen += 1;
delay
} else {
Duration::ZERO
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn refresh_stagger_spaces_out_anthropic_tabs_only() {
let step = Duration::from_millis(800);
let tabs = vec![
TabId::vendor(VendorId::Anthropic), TabId::account("work"),
TabId::account("personal"),
TabId::vendor(VendorId::Openai),
TabId::vendor(VendorId::Zai),
];
let delays = refresh_stagger(&tabs, step);
assert_eq!(
delays,
vec![
Duration::ZERO, step, step * 2, Duration::ZERO, Duration::ZERO, ]
);
}
#[test]
fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
let tabs = vec![
TabId::vendor(VendorId::Anthropic),
TabId::vendor(VendorId::Openrouter),
];
assert!(
refresh_stagger(&tabs, Duration::from_millis(800))
.iter()
.all(|d| d.is_zero())
);
}
#[test]
fn select_primary_moves_to_enabled_vendor() {
let mut app = App::with_theme(
vec![
TabId::vendor(VendorId::Anthropic),
TabId::vendor(VendorId::Openrouter),
],
Theme::default(),
);
app.select_primary(Some(VendorId::Openrouter));
assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
}
#[test]
fn select_primary_ignores_disabled_vendor() {
let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
app.select_primary(Some(VendorId::Openai));
assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
}
#[test]
fn nav_ring_wraps_through_the_overview_at_both_ends() {
let mut app = App::with_theme(
vec![
TabId::vendor(VendorId::Anthropic),
TabId::vendor(VendorId::Openai),
],
Theme::default(),
);
app.overview = true;
app.next_tab(); assert!(!app.overview);
assert_eq!(app.active, 0);
app.next_tab();
assert_eq!(app.active, 1);
app.next_tab(); assert!(app.overview);
app.prev_tab(); assert!(!app.overview);
assert_eq!(app.active, 1);
app.prev_tab();
assert_eq!(app.active, 0);
app.prev_tab(); assert!(app.overview);
}
#[test]
fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
let mut app = App::with_theme(
vec![
TabId::vendor(VendorId::Anthropic),
TabId::vendor(VendorId::Openai),
TabId::vendor(VendorId::Zai),
],
Theme::default(),
);
assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
assert_eq!(app.overview_tabs(), vec![2, 0]);
app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
assert_eq!(app.overview_tabs(), vec![1]);
}
fn config_with_accounts(labels: &[&str]) -> Config {
let mut config = Config::default();
config.openai.enabled = false;
config.zai.enabled = false;
config.openrouter.enabled = false;
config.anthropic.accounts = labels
.iter()
.map(|l| crate::config::AnthropicAccount {
label: (*l).to_string(),
credentials_path: format!("/creds/{l}.json").into(),
})
.collect();
config
}
#[test]
fn show_default_account_false_hides_the_unnamed_claude_tab() {
let mut config = config_with_accounts(&["work", "personal"]);
config.anthropic.show_default_account = false;
assert_eq!(
tabs_from_config(&config),
vec![TabId::account("work"), TabId::account("personal")]
);
let mut empty = Config::default();
empty.openai.enabled = false;
empty.zai.enabled = false;
empty.openrouter.enabled = false;
empty.anthropic.show_default_account = false;
assert_eq!(
tabs_from_config(&empty),
vec![TabId::vendor(VendorId::Anthropic)]
);
}
#[test]
fn tabs_expand_anthropic_accounts_after_default() {
let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
assert_eq!(
tabs,
vec![
TabId::vendor(VendorId::Anthropic),
TabId::account("work"),
TabId::account("personal"),
]
);
}
#[test]
fn tabs_without_accounts_are_just_enabled_vendors() {
let config = Config::default();
let tabs = tabs_from_config(&config);
let vendors: Vec<VendorId> = tabs.iter().map(|t| t.vendor).collect();
assert_eq!(vendors, config.enabled_vendors());
assert!(tabs.iter().all(|t| t.account.is_none()));
}
#[test]
fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
let td = tempfile::tempdir().unwrap();
for label in ["work", "personal"] {
let dir = td.path().join(label);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
}
let mut config = Config::default();
config.openai.enabled = false;
config.zai.enabled = false;
config.openrouter.enabled = false;
config.anthropic.accounts_dir = Some(td.path().to_path_buf());
let tabs = tabs_from_config(&config);
assert_eq!(
tabs,
vec![
TabId::vendor(VendorId::Anthropic),
TabId::account("personal"), TabId::account("work"),
]
);
}
#[test]
fn set_tabs_resets_states_and_clamps_selection() {
let mut app = App::with_theme(
tabs_from_config(&config_with_accounts(&["work", "personal"])),
Theme::default(),
);
app.active = 2; app.tabs[0] = TabState::Error("old".into());
app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
assert_eq!(app.active, 0, "selection clamped after shrink");
assert!(matches!(app.tabs[0], TabState::Loading));
}
#[test]
fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
let mut app = App::with_theme(
vec![
TabId::vendor(VendorId::Anthropic),
TabId::vendor(VendorId::Openai),
],
Theme::default(),
);
app.active = 1;
app.set_tabs(vec![
TabId::vendor(VendorId::Anthropic),
TabId::account("work"),
TabId::vendor(VendorId::Openai),
]);
assert_eq!(app.active, 2);
assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
}
#[test]
fn refresh_from_old_generation_is_discarded() {
let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
let old_generation = app.tab_generation;
app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
assert!(!app.apply_refresh(
old_generation,
&TabId::vendor(VendorId::Anthropic),
TabState::Error("old result".into()),
));
assert!(matches!(app.tabs[0], TabState::Loading));
}
#[test]
fn refresh_identity_mismatch_is_discarded() {
let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
let generation = app.tab_generation;
assert!(!app.apply_refresh(
generation,
&TabId::vendor(VendorId::Openai),
TabState::Error("wrong tab".into()),
));
assert!(matches!(app.tabs[0], TabState::Loading));
}
#[test]
fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
let anthropic = TabId::vendor(VendorId::Anthropic);
let openai = TabId::vendor(VendorId::Openai);
let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
let generation = app.tab_generation;
app.tabs_meta.swap(0, 1);
app.tabs.swap(0, 1);
assert!(app.apply_refresh(generation, &anthropic, TabState::Error("ready".into())));
assert!(matches!(app.tabs[0], TabState::Loading));
assert!(matches!(&app.tabs[1], TabState::Error(message) if message == "ready"));
}
fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
TabState::Ready(Box::new(ReadyTab {
snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
label: "test".into(),
total_credits: 0.0,
total_usage: 0.0,
usage_daily: 0.0,
usage_weekly: 0.0,
usage_monthly: 0.0,
is_free_tier: false,
limit: None,
limit_remaining: None,
}),
stale: false,
last_error: None,
fetched_at: Some(fetched_at),
}))
}
#[test]
fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
let anthropic = TabId::vendor(VendorId::Anthropic);
let openai = TabId::vendor(VendorId::Openai);
let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
let generation = app.tab_generation;
let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
match &app.tabs[0] {
TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
other => panic!("expected Anthropic tab Ready, got {other:?}"),
}
assert!(matches!(app.tabs[1], TabState::Loading));
}
#[test]
fn select_primary_lands_on_default_account_tab() {
let app = {
let tabs = tabs_from_config(&config_with_accounts(&["work"]));
let mut a = App::with_theme(tabs, Theme::default());
a.select_primary(Some(VendorId::Anthropic));
a
};
assert_eq!(app.active, 0);
assert_eq!(
app.active_tab_id(),
Some(&TabId::vendor(VendorId::Anthropic))
);
}
}