1use std::time::Duration;
4
5use chrono::Utc;
6use reqwest::Client;
7
8use crate::cache::DEFAULT_TTL;
9use crate::config::Config;
10use crate::error::Result;
11use crate::theme::Theme;
12use crate::vendor::{VendorId, VendorOutcome};
13
14#[derive(Debug, Clone)]
20pub enum TabState {
21 Loading,
22 Ready(Box<ReadyTab>),
23 Error(String),
24}
25
26#[derive(Debug, Clone)]
27pub struct ReadyTab {
28 pub snapshot: crate::usage::VendorSnapshot,
29 pub stale: bool,
30 pub last_error: Option<(u16, String)>,
31 pub fetched_at: Option<chrono::DateTime<chrono::Utc>>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct TabId {
43 pub vendor: VendorId,
44 pub account: Option<String>,
45}
46
47impl TabId {
48 pub fn vendor(vendor: VendorId) -> Self {
50 Self {
51 vendor,
52 account: None,
53 }
54 }
55
56 pub fn account(label: impl Into<String>) -> Self {
58 Self {
59 vendor: VendorId::Anthropic,
60 account: Some(label.into()),
61 }
62 }
63}
64
65pub fn tabs_from_config(config: &Config) -> Vec<TabId> {
71 let mut tabs = Vec::new();
72 for vendor in config.enabled_vendors() {
73 tabs.push(TabId::vendor(vendor));
74 if vendor == VendorId::Anthropic {
75 for acct in &config.anthropic.accounts {
76 tabs.push(TabId::account(acct.label.clone()));
77 }
78 }
79 }
80 tabs
81}
82
83#[derive(Debug)]
84pub struct App {
85 pub tabs_meta: Vec<TabId>,
86 pub active: usize,
87 pub tabs: Vec<TabState>,
88 pub tab_generation: u64,
92 pub theme: Theme,
93 pub quit: bool,
94 pub settings: Option<crate::tui::settings::SettingsState>,
96 pub context_enabled: bool,
99 pub context_generation: u64,
102 pub context: Option<crate::tui::context::ContextState>,
104}
105
106impl App {
107 pub fn new(tabs_meta: Vec<TabId>) -> Self {
108 Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
111 }
112
113 pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
119 let n = tabs_meta.len();
120 Self {
121 tabs_meta,
122 active: 0,
123 tabs: vec![TabState::Loading; n],
124 tab_generation: 0,
125 theme,
126 quit: false,
127 settings: None,
128 context_enabled: false,
129 context_generation: 0,
130 context: None,
131 }
132 }
133
134 pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
138 let mut app = Self::new(tabs_meta);
139 app.select_primary(primary);
140 app
141 }
142
143 pub fn active_tab_id(&self) -> Option<&TabId> {
144 self.tabs_meta.get(self.active)
145 }
146
147 pub fn active_vendor(&self) -> Option<VendorId> {
148 self.tabs_meta.get(self.active).map(|t| t.vendor)
149 }
150
151 pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
157 self.tab_generation = self.tab_generation.wrapping_add(1);
158 self.active = self.active.min(tabs_meta.len().saturating_sub(1));
159 self.tabs = vec![TabState::Loading; tabs_meta.len()];
160 self.tabs_meta = tabs_meta;
161 }
162
163 pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
168 if generation != self.tab_generation {
169 return false;
170 }
171 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
172 return false;
173 };
174 self.tabs[index] = state;
175 true
176 }
177
178 pub fn select_primary(&mut self, primary: Option<VendorId>) {
181 if let Some(p) = primary
182 && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor == p)
183 {
184 self.active = idx;
185 }
186 }
187
188 pub fn next_tab(&mut self) {
189 if !self.tabs_meta.is_empty() {
190 self.active = (self.active + 1) % self.tabs_meta.len();
191 }
192 }
193
194 pub fn prev_tab(&mut self) {
195 if !self.tabs_meta.is_empty() {
196 self.active = (self.active + self.tabs_meta.len() - 1) % self.tabs_meta.len();
197 }
198 }
199}
200
201pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
203 match build_outcome(client, config, tab).await {
204 Ok(outcome) => {
205 let now = Utc::now();
210 let fetched_at = outcome
211 .cache_age
212 .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
213 TabState::Ready(Box::new(ReadyTab {
214 snapshot: outcome.snapshot,
215 stale: outcome.stale,
216 last_error: outcome.last_error,
217 fetched_at,
218 }))
219 }
220 Err(e) => TabState::Error(e.to_string()),
221 }
222}
223
224async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
225 match tab.vendor {
226 VendorId::Anthropic => {
227 let (creds_target, cache) = match tab.account.as_deref() {
233 Some(label) => config.anthropic.account_target(label)?,
234 None => {
235 let target = match config.anthropic.credentials_path.clone() {
236 Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
237 None => crate::anthropic::creds::CredsTarget::Default(
238 crate::anthropic::creds::default_path().unwrap_or_default(),
239 ),
240 };
241 (target, crate::cache::Cache::for_vendor("anthropic")?)
242 }
243 };
244 let endpoints = crate::anthropic::fetch::Endpoints::default();
245 let outcome = crate::anthropic::fetch_snapshot(
246 client,
247 &creds_target,
248 &cache,
249 &endpoints,
250 DEFAULT_TTL,
251 )
252 .await?;
253 Ok(crate::vendor::VendorOutcome {
254 snapshot: crate::usage::VendorSnapshot::Anthropic(outcome.snapshot),
255 stale: outcome.stale,
256 last_error: outcome.last_error,
257 cache_age: outcome.cache_age,
258 })
259 }
260 VendorId::AnthropicApi => {
261 let key = crate::config::resolve_api_key(
262 "Anthropic_API",
263 &config.anthropic_api.api_key_env,
264 config.anthropic_api.api_key.as_deref(),
265 )?;
266 let cache = crate::cache::Cache::for_vendor("anthropic_api")?;
267 let endpoints = crate::anthropic_api::fetch::Endpoints::default();
268 let outcome = crate::anthropic_api::fetch_snapshot(
269 client,
270 &key,
271 &cache,
272 &endpoints,
273 DEFAULT_TTL,
274 config.anthropic_api.monthly_limit,
275 )
276 .await?;
277 Ok(outcome.into())
278 }
279 VendorId::Openrouter => {
280 let api_key = crate::config::resolve_api_key(
281 "OpenRouter",
282 &config.openrouter.api_key_env,
283 config.openrouter.api_key.as_deref(),
284 )?;
285 let cache = crate::cache::Cache::for_vendor("openrouter")?;
286 let endpoints = crate::openrouter::fetch::Endpoints::default();
287 let outcome = crate::openrouter::fetch_snapshot(
288 client,
289 &api_key,
290 &cache,
291 &endpoints,
292 DEFAULT_TTL,
293 )
294 .await?;
295 Ok(outcome.into())
296 }
297 VendorId::Zai => {
298 let api_key = crate::config::resolve_api_key(
299 "Zai",
300 &config.zai.api_key_env,
301 config.zai.api_key.as_deref(),
302 )?;
303 let cache = crate::cache::Cache::for_vendor("zai")?;
304 let endpoints = crate::zai::fetch::Endpoints::default();
305 let outcome = crate::zai::fetch_snapshot(
306 client,
307 &api_key,
308 &cache,
309 &endpoints,
310 DEFAULT_TTL,
311 config.zai.plan_tier.as_deref(),
312 )
313 .await?;
314 Ok(outcome.into())
315 }
316 VendorId::Openai => {
317 let cache = crate::cache::Cache::for_vendor("openai")?;
318 let creds_path = config
319 .openai
320 .codex_auth_path
321 .clone()
322 .unwrap_or_else(|| crate::openai::creds::default_path().unwrap_or_default());
323 let endpoints = crate::openai::fetch::Endpoints::default();
324 let outcome =
325 crate::openai::fetch_snapshot(client, &creds_path, &cache, &endpoints, DEFAULT_TTL)
326 .await?;
327 Ok(outcome.into())
328 }
329 VendorId::Deepseek => {
330 let api_key = crate::config::resolve_api_key(
331 "DeepSeek",
332 &config.deepseek.api_key_env,
333 config.deepseek.api_key.as_deref(),
334 )?;
335 let cache = crate::cache::Cache::for_vendor("deepseek")?;
336 let endpoints = crate::deepseek::fetch::Endpoints::default();
337 let outcome =
338 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
339 .await?;
340 Ok(outcome.into())
341 }
342 VendorId::Kimi => {
343 let api_key = crate::config::resolve_api_key(
344 "Kimi",
345 &config.kimi.api_key_env,
346 config.kimi.api_key.as_deref(),
347 )?;
348 let cache = crate::cache::Cache::for_vendor("kimi")?;
349 let endpoints = crate::kimi::fetch::Endpoints::default();
350 let outcome =
351 crate::kimi::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
352 .await?;
353 Ok(outcome.into())
354 }
355 VendorId::Kilo => {
356 let api_key = crate::config::resolve_api_key(
357 "Kilo",
358 &config.kilo.api_key_env,
359 config.kilo.api_key.as_deref(),
360 )?;
361 let cache = crate::cache::Cache::for_vendor("kilo")?;
362 let endpoints = crate::kilo::fetch::Endpoints::default();
363 let outcome = crate::kilo::fetch_snapshot(
364 client,
365 &api_key,
366 &cache,
367 &endpoints,
368 DEFAULT_TTL,
369 config.kilo.organization_id.as_deref(),
370 )
371 .await?;
372 Ok(outcome.into())
373 }
374 VendorId::Novita => {
375 let api_key = crate::config::resolve_api_key(
376 "Novita",
377 &config.novita.api_key_env,
378 config.novita.api_key.as_deref(),
379 )?;
380 let cache = crate::cache::Cache::for_vendor("novita")?;
381 let endpoints = crate::novita::fetch::Endpoints::default();
382 let outcome =
383 crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
384 .await?;
385 Ok(outcome.into())
386 }
387 VendorId::Moonshot => {
388 let api_key = crate::config::resolve_api_key(
389 "Moonshot",
390 &config.moonshot.api_key_env,
391 config.moonshot.api_key.as_deref(),
392 )?;
393 let cache = crate::cache::Cache::for_vendor("moonshot")?;
394 let (endpoints, currency) =
395 crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
396 let outcome = crate::moonshot::fetch_snapshot(
397 client,
398 &api_key,
399 &cache,
400 &endpoints,
401 DEFAULT_TTL,
402 currency,
403 )
404 .await?;
405 Ok(outcome.into())
406 }
407 VendorId::Grok => {
408 let key = crate::config::resolve_api_key(
409 "Grok",
410 &config.grok.api_key_env,
411 config.grok.api_key.as_deref(),
412 )?;
413 let cache = crate::cache::Cache::for_vendor("grok")?;
414 let endpoints = crate::grok::fetch::Endpoints::default();
415 let outcome = crate::grok::fetch_snapshot(
416 client,
417 &key,
418 &cache,
419 &endpoints,
420 DEFAULT_TTL,
421 config.grok.team_id.as_deref(),
422 )
423 .await?;
424 Ok(outcome.into())
425 }
426 VendorId::Antigravity => {
427 let cache = crate::cache::Cache::for_vendor("antigravity")?;
429 let outcome = crate::antigravity::fetch_snapshot(client, &cache, DEFAULT_TTL).await?;
430 Ok(outcome.into())
431 }
432 }
433}
434
435pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use chrono::TimeZone;
443
444 #[test]
448 fn select_primary_moves_to_enabled_vendor() {
449 let mut app = App::with_theme(
450 vec![
451 TabId::vendor(VendorId::Anthropic),
452 TabId::vendor(VendorId::Openrouter),
453 ],
454 Theme::default(),
455 );
456 app.select_primary(Some(VendorId::Openrouter));
457 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
458 }
459
460 #[test]
461 fn select_primary_ignores_disabled_vendor() {
462 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
463 app.select_primary(Some(VendorId::Openai));
464 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
465 }
466
467 fn config_with_accounts(labels: &[&str]) -> Config {
468 let mut config = Config::default();
469 config.openai.enabled = false;
472 config.zai.enabled = false;
473 config.openrouter.enabled = false;
474 config.anthropic.accounts = labels
475 .iter()
476 .map(|l| crate::config::AnthropicAccount {
477 label: (*l).to_string(),
478 credentials_path: format!("/creds/{l}.json").into(),
479 })
480 .collect();
481 config
482 }
483
484 #[test]
485 fn tabs_expand_anthropic_accounts_after_default() {
486 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
488 assert_eq!(
489 tabs,
490 vec![
491 TabId::vendor(VendorId::Anthropic),
492 TabId::account("work"),
493 TabId::account("personal"),
494 ]
495 );
496 }
497
498 #[test]
499 fn tabs_without_accounts_are_just_enabled_vendors() {
500 let config = Config::default();
502 let tabs = tabs_from_config(&config);
503 let vendors: Vec<VendorId> = tabs.iter().map(|t| t.vendor).collect();
504 assert_eq!(vendors, config.enabled_vendors());
505 assert!(tabs.iter().all(|t| t.account.is_none()));
506 }
507
508 #[test]
509 fn set_tabs_resets_states_and_clamps_selection() {
510 let mut app = App::with_theme(
514 tabs_from_config(&config_with_accounts(&["work", "personal"])),
515 Theme::default(),
516 );
517 app.active = 2; app.tabs[0] = TabState::Error("old".into());
519
520 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
521 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
522 assert_eq!(app.active, 0, "selection clamped after shrink");
523 assert!(matches!(app.tabs[0], TabState::Loading));
524 }
525
526 #[test]
527 fn refresh_from_old_generation_is_discarded() {
528 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
529 let old_generation = app.tab_generation;
530 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
531
532 assert!(!app.apply_refresh(
533 old_generation,
534 &TabId::vendor(VendorId::Anthropic),
535 TabState::Error("old result".into()),
536 ));
537 assert!(matches!(app.tabs[0], TabState::Loading));
538 }
539
540 #[test]
541 fn refresh_identity_mismatch_is_discarded() {
542 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
543 let generation = app.tab_generation;
544
545 assert!(!app.apply_refresh(
546 generation,
547 &TabId::vendor(VendorId::Openai),
548 TabState::Error("wrong tab".into()),
549 ));
550 assert!(matches!(app.tabs[0], TabState::Loading));
551 }
552
553 #[test]
554 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
555 let anthropic = TabId::vendor(VendorId::Anthropic);
556 let openai = TabId::vendor(VendorId::Openai);
557 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
558 let generation = app.tab_generation;
559
560 app.tabs_meta.swap(0, 1);
563 app.tabs.swap(0, 1);
564 assert!(app.apply_refresh(generation, &anthropic, TabState::Error("ready".into())));
565 assert!(matches!(app.tabs[0], TabState::Loading));
566 assert!(matches!(&app.tabs[1], TabState::Error(message) if message == "ready"));
567 }
568
569 fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
570 TabState::Ready(Box::new(ReadyTab {
571 snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
572 label: "test".into(),
573 total_credits: 0.0,
574 total_usage: 0.0,
575 usage_daily: 0.0,
576 usage_weekly: 0.0,
577 usage_monthly: 0.0,
578 is_free_tier: false,
579 limit: None,
580 limit_remaining: None,
581 }),
582 stale: false,
583 last_error: None,
584 fetched_at: Some(fetched_at),
585 }))
586 }
587
588 #[test]
589 fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
590 let anthropic = TabId::vendor(VendorId::Anthropic);
596 let openai = TabId::vendor(VendorId::Openai);
597 let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
598 let generation = app.tab_generation;
599 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
600
601 assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
602 match &app.tabs[0] {
603 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
604 other => panic!("expected Anthropic tab Ready, got {other:?}"),
605 }
606 assert!(matches!(app.tabs[1], TabState::Loading));
607 }
608
609 #[test]
610 fn select_primary_lands_on_default_account_tab() {
611 let app = {
614 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
615 let mut a = App::with_theme(tabs, Theme::default());
616 a.select_primary(Some(VendorId::Anthropic));
617 a
618 };
619 assert_eq!(app.active, 0);
620 assert_eq!(
621 app.active_tab_id(),
622 Some(&TabId::vendor(VendorId::Anthropic))
623 );
624 }
625}