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 if vendor == VendorId::Anthropic {
74 let accounts = config.anthropic.all_accounts();
75 if config.anthropic.show_default_account || accounts.is_empty() {
79 tabs.push(TabId::vendor(vendor));
80 }
81 for acct in accounts {
82 tabs.push(TabId::account(acct.label));
83 }
84 } else {
85 tabs.push(TabId::vendor(vendor));
86 }
87 }
88 tabs
89}
90
91#[derive(Debug)]
92pub struct App {
93 pub tabs_meta: Vec<TabId>,
94 pub active: usize,
95 pub tabs: Vec<TabState>,
96 pub tab_generation: u64,
100 pub overview: bool,
103 pub overview_vendors: Option<Vec<VendorId>>,
105 pub theme: Theme,
106 pub quit: bool,
107 pub settings: Option<crate::tui::settings::SettingsState>,
109 pub context_enabled: bool,
112 pub context_generation: u64,
115 pub context: Option<crate::tui::context::ContextState>,
117 pub vendor_box: crate::config::VendorBoxStyle,
119}
120
121impl App {
122 pub fn new(tabs_meta: Vec<TabId>) -> Self {
123 Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
126 }
127
128 pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
134 let n = tabs_meta.len();
135 Self {
136 tabs_meta,
137 active: 0,
138 tabs: vec![TabState::Loading; n],
139 tab_generation: 0,
140 overview: false,
141 overview_vendors: None,
142 theme,
143 quit: false,
144 settings: None,
145 context_enabled: false,
146 context_generation: 0,
147 context: None,
148 vendor_box: crate::config::VendorBoxStyle::Sidebar,
149 }
150 }
151
152 pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
156 let mut app = Self::new(tabs_meta);
157 if primary.is_some() {
160 app.select_primary(primary);
161 } else {
162 app.overview = true;
163 }
164 app
165 }
166
167 pub fn active_tab_id(&self) -> Option<&TabId> {
168 self.tabs_meta.get(self.active)
169 }
170
171 pub fn active_vendor(&self) -> Option<VendorId> {
172 self.tabs_meta.get(self.active).map(|t| t.vendor)
173 }
174
175 pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
182 let selected = self.active_tab_id().cloned();
183 let fallback = self.active.min(tabs_meta.len().saturating_sub(1));
184 self.tab_generation = self.tab_generation.wrapping_add(1);
185 self.active = selected
186 .as_ref()
187 .and_then(|tab| tabs_meta.iter().position(|candidate| candidate == tab))
188 .unwrap_or(fallback);
189 self.tabs = vec![TabState::Loading; tabs_meta.len()];
190 self.tabs_meta = tabs_meta;
191 }
192
193 pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
198 if generation != self.tab_generation {
199 return false;
200 }
201 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
202 return false;
203 };
204 self.tabs[index] = state;
205 true
206 }
207
208 pub fn select_primary(&mut self, primary: Option<VendorId>) {
211 if let Some(p) = primary
212 && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor == p)
213 {
214 self.active = idx;
215 self.overview = false;
216 }
217 }
218
219 pub fn next_tab(&mut self) {
222 if self.overview {
223 if !self.tabs_meta.is_empty() {
224 self.overview = false;
225 self.active = 0;
226 }
227 } else if self.active + 1 < self.tabs_meta.len() {
228 self.active += 1;
229 } else {
230 self.overview = true;
231 }
232 }
233
234 pub fn prev_tab(&mut self) {
235 if self.overview {
236 if !self.tabs_meta.is_empty() {
237 self.overview = false;
238 self.active = self.tabs_meta.len() - 1;
239 }
240 } else if self.active > 0 {
241 self.active -= 1;
242 } else {
243 self.overview = true;
244 }
245 }
246
247 pub fn overview_tabs(&self) -> Vec<usize> {
250 match &self.overview_vendors {
251 None => (0..self.tabs_meta.len()).collect(),
252 Some(wanted) => wanted
253 .iter()
254 .flat_map(|v| {
255 self.tabs_meta
256 .iter()
257 .enumerate()
258 .filter(move |(_, t)| t.vendor == *v)
259 .map(|(i, _)| i)
260 })
261 .collect(),
262 }
263 }
264}
265
266pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
268 match build_outcome(client, config, tab).await {
269 Ok(outcome) => {
270 let now = Utc::now();
275 let fetched_at = outcome
276 .cache_age
277 .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
278 TabState::Ready(Box::new(ReadyTab {
279 snapshot: outcome.snapshot,
280 stale: outcome.stale,
281 last_error: outcome.last_error.map(|(code, message)| {
282 (code, crate::display::sanitize_untrusted_field(&message))
283 }),
284 fetched_at,
285 }))
286 }
287 Err(e) => TabState::Error(crate::display::sanitize_untrusted_field(&e.to_string())),
288 }
289}
290
291async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
292 match tab.vendor {
293 VendorId::Anthropic => {
294 let (creds_target, cache) = match tab.account.as_deref() {
300 Some(label) => config.anthropic.account_target(label)?,
301 None => {
302 let target = match config.anthropic.credentials_path.clone() {
303 Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
304 None => crate::anthropic::creds::CredsTarget::Default(
305 crate::anthropic::creds::default_path().unwrap_or_default(),
306 ),
307 };
308 (target, crate::cache::Cache::for_vendor("anthropic")?)
309 }
310 };
311 let endpoints = crate::anthropic::fetch::Endpoints::default();
312 let outcome = crate::anthropic::fetch_snapshot(
313 client,
314 &creds_target,
315 &cache,
316 &endpoints,
317 DEFAULT_TTL,
318 )
319 .await?;
320 Ok(crate::vendor::VendorOutcome {
321 snapshot: crate::usage::VendorSnapshot::Anthropic(outcome.snapshot),
322 stale: outcome.stale,
323 last_error: outcome.last_error,
324 cache_age: outcome.cache_age,
325 })
326 }
327 VendorId::AnthropicApi => {
328 let key = crate::config::resolve_api_key(
329 "Anthropic_API",
330 &config.anthropic_api.api_key_env,
331 config.anthropic_api.api_key.as_deref(),
332 )?;
333 let cache = crate::cache::Cache::for_vendor("anthropic_api")?;
334 let endpoints = crate::anthropic_api::fetch::Endpoints::default();
335 let outcome = crate::anthropic_api::fetch_snapshot(
336 client,
337 &key,
338 &cache,
339 &endpoints,
340 DEFAULT_TTL,
341 config.anthropic_api.monthly_limit,
342 )
343 .await?;
344 Ok(outcome.into())
345 }
346 VendorId::Openrouter => {
347 let api_key = crate::config::resolve_api_key(
348 "OpenRouter",
349 &config.openrouter.api_key_env,
350 config.openrouter.api_key.as_deref(),
351 )?;
352 let cache = crate::cache::Cache::for_vendor("openrouter")?;
353 let endpoints = crate::openrouter::fetch::Endpoints::default();
354 let outcome = crate::openrouter::fetch_snapshot(
355 client,
356 &api_key,
357 &cache,
358 &endpoints,
359 DEFAULT_TTL,
360 )
361 .await?;
362 Ok(outcome.into())
363 }
364 VendorId::Zai => {
365 let api_key = crate::config::resolve_api_key(
366 "Zai",
367 &config.zai.api_key_env,
368 config.zai.api_key.as_deref(),
369 )?;
370 let cache = crate::cache::Cache::for_vendor("zai")?;
371 let endpoints = crate::zai::fetch::Endpoints::default();
372 let outcome = crate::zai::fetch_snapshot(
373 client,
374 &api_key,
375 &cache,
376 &endpoints,
377 DEFAULT_TTL,
378 config.zai.plan_tier.as_deref(),
379 )
380 .await?;
381 Ok(outcome.into())
382 }
383 VendorId::Openai => {
384 let cache = crate::cache::Cache::for_vendor("openai")?;
385 let creds_path = config
386 .openai
387 .codex_auth_path
388 .clone()
389 .unwrap_or_else(|| crate::openai::creds::default_path().unwrap_or_default());
390 let endpoints = crate::openai::fetch::Endpoints::default();
391 let outcome =
392 crate::openai::fetch_snapshot(client, &creds_path, &cache, &endpoints, DEFAULT_TTL)
393 .await?;
394 Ok(outcome.into())
395 }
396 VendorId::Deepseek => {
397 let api_key = crate::config::resolve_api_key(
398 "DeepSeek",
399 &config.deepseek.api_key_env,
400 config.deepseek.api_key.as_deref(),
401 )?;
402 let cache = crate::cache::Cache::for_vendor("deepseek")?;
403 let endpoints = crate::deepseek::fetch::Endpoints::default();
404 let outcome =
405 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
406 .await?;
407 Ok(outcome.into())
408 }
409 VendorId::Kimi => {
410 let api_key = crate::config::resolve_api_key(
411 "Kimi",
412 &config.kimi.api_key_env,
413 config.kimi.api_key.as_deref(),
414 )?;
415 let cache = crate::cache::Cache::for_vendor("kimi")?;
416 let endpoints = crate::kimi::fetch::Endpoints::default();
417 let outcome =
418 crate::kimi::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
419 .await?;
420 Ok(outcome.into())
421 }
422 VendorId::Kilo => {
423 let api_key = crate::config::resolve_api_key(
424 "Kilo",
425 &config.kilo.api_key_env,
426 config.kilo.api_key.as_deref(),
427 )?;
428 let cache = crate::cache::Cache::for_vendor("kilo")?;
429 let endpoints = crate::kilo::fetch::Endpoints::default();
430 let outcome = crate::kilo::fetch_snapshot(
431 client,
432 &api_key,
433 &cache,
434 &endpoints,
435 DEFAULT_TTL,
436 config.kilo.organization_id.as_deref(),
437 )
438 .await?;
439 Ok(outcome.into())
440 }
441 VendorId::Novita => {
442 let api_key = crate::config::resolve_api_key(
443 "Novita",
444 &config.novita.api_key_env,
445 config.novita.api_key.as_deref(),
446 )?;
447 let cache = crate::cache::Cache::for_vendor("novita")?;
448 let endpoints = crate::novita::fetch::Endpoints::default();
449 let outcome =
450 crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
451 .await?;
452 Ok(outcome.into())
453 }
454 VendorId::Moonshot => {
455 let api_key = crate::config::resolve_api_key(
456 "Moonshot",
457 &config.moonshot.api_key_env,
458 config.moonshot.api_key.as_deref(),
459 )?;
460 let cache = crate::cache::Cache::for_vendor("moonshot")?;
461 let (endpoints, currency) =
462 crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
463 let outcome = crate::moonshot::fetch_snapshot(
464 client,
465 &api_key,
466 &cache,
467 &endpoints,
468 DEFAULT_TTL,
469 currency,
470 )
471 .await?;
472 Ok(outcome.into())
473 }
474 VendorId::Grok => {
475 let key = crate::config::resolve_api_key(
476 "Grok",
477 &config.grok.api_key_env,
478 config.grok.api_key.as_deref(),
479 )?;
480 let cache = crate::cache::Cache::for_vendor("grok")?;
481 let endpoints = crate::grok::fetch::Endpoints::default();
482 let outcome = crate::grok::fetch_snapshot(
483 client,
484 &key,
485 &cache,
486 &endpoints,
487 DEFAULT_TTL,
488 config.grok.team_id.as_deref(),
489 )
490 .await?;
491 Ok(outcome.into())
492 }
493 VendorId::Antigravity => {
494 let cache = crate::cache::Cache::for_vendor("antigravity")?;
496 let outcome = crate::antigravity::fetch_snapshot(client, &cache, DEFAULT_TTL).await?;
497 Ok(outcome.into())
498 }
499 VendorId::Minimax => {
500 let api_key = crate::config::resolve_api_key(
501 "MiniMax",
502 &config.minimax.api_key_env,
503 config.minimax.api_key.as_deref(),
504 )?;
505 let cache = crate::cache::Cache::for_vendor("minimax")?;
506 let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
507 let outcome =
508 crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
509 .await?;
510 Ok(outcome.into())
511 }
512 VendorId::Cursor => {
513 let cache = crate::cache::Cache::for_vendor("cursor")?;
514 let db_path = config
515 .cursor
516 .db_path
517 .clone()
518 .map(Ok)
519 .unwrap_or_else(crate::cursor::db::default_db_path)?;
520 let endpoints = crate::cursor::fetch::Endpoints::default();
521 let outcome =
522 crate::cursor::fetch_snapshot(client, &db_path, &cache, &endpoints, DEFAULT_TTL)
523 .await?;
524 Ok(outcome.into())
525 }
526 }
527}
528
529pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
532
533pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
540
541pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
547 let mut anthropic_seen: u32 = 0;
548 tabs.iter()
549 .map(|tab| {
550 if tab.vendor == VendorId::Anthropic {
551 let delay = step * anthropic_seen;
552 anthropic_seen += 1;
553 delay
554 } else {
555 Duration::ZERO
556 }
557 })
558 .collect()
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use chrono::TimeZone;
565
566 #[test]
570 fn refresh_stagger_spaces_out_anthropic_tabs_only() {
571 let step = Duration::from_millis(800);
572 let tabs = vec![
573 TabId::vendor(VendorId::Anthropic), TabId::account("work"),
575 TabId::account("personal"),
576 TabId::vendor(VendorId::Openai),
577 TabId::vendor(VendorId::Zai),
578 ];
579 let delays = refresh_stagger(&tabs, step);
580 assert_eq!(
581 delays,
582 vec![
583 Duration::ZERO, step, step * 2, Duration::ZERO, Duration::ZERO, ]
589 );
590 }
591
592 #[test]
593 fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
594 let tabs = vec![
596 TabId::vendor(VendorId::Anthropic),
597 TabId::vendor(VendorId::Openrouter),
598 ];
599 assert!(
600 refresh_stagger(&tabs, Duration::from_millis(800))
601 .iter()
602 .all(|d| d.is_zero())
603 );
604 }
605
606 #[test]
607 fn select_primary_moves_to_enabled_vendor() {
608 let mut app = App::with_theme(
609 vec![
610 TabId::vendor(VendorId::Anthropic),
611 TabId::vendor(VendorId::Openrouter),
612 ],
613 Theme::default(),
614 );
615 app.select_primary(Some(VendorId::Openrouter));
616 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
617 }
618
619 #[test]
620 fn select_primary_ignores_disabled_vendor() {
621 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
622 app.select_primary(Some(VendorId::Openai));
623 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
624 }
625
626 #[test]
627 fn nav_ring_wraps_through_the_overview_at_both_ends() {
628 let mut app = App::with_theme(
629 vec![
630 TabId::vendor(VendorId::Anthropic),
631 TabId::vendor(VendorId::Openai),
632 ],
633 Theme::default(),
634 );
635 app.overview = true;
636
637 app.next_tab(); assert!(!app.overview);
639 assert_eq!(app.active, 0);
640 app.next_tab();
641 assert_eq!(app.active, 1);
642 app.next_tab(); assert!(app.overview);
644
645 app.prev_tab(); assert!(!app.overview);
647 assert_eq!(app.active, 1);
648 app.prev_tab();
649 assert_eq!(app.active, 0);
650 app.prev_tab(); assert!(app.overview);
652 }
653
654 #[test]
655 fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
656 let mut app = App::with_theme(
657 vec![
658 TabId::vendor(VendorId::Anthropic),
659 TabId::vendor(VendorId::Openai),
660 TabId::vendor(VendorId::Zai),
661 ],
662 Theme::default(),
663 );
664 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
665
666 app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
668 assert_eq!(app.overview_tabs(), vec![2, 0]);
669
670 app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
672 assert_eq!(app.overview_tabs(), vec![1]);
673 }
674
675 fn config_with_accounts(labels: &[&str]) -> Config {
676 let mut config = Config::default();
677 config.openai.enabled = false;
680 config.zai.enabled = false;
681 config.openrouter.enabled = false;
682 config.anthropic.accounts = labels
683 .iter()
684 .map(|l| crate::config::AnthropicAccount {
685 label: (*l).to_string(),
686 credentials_path: format!("/creds/{l}.json").into(),
687 })
688 .collect();
689 config
690 }
691
692 #[test]
693 fn show_default_account_false_hides_the_unnamed_claude_tab() {
694 let mut config = config_with_accounts(&["work", "personal"]);
697 config.anthropic.show_default_account = false;
698 assert_eq!(
699 tabs_from_config(&config),
700 vec![TabId::account("work"), TabId::account("personal")]
701 );
702
703 let mut empty = Config::default();
706 empty.openai.enabled = false;
707 empty.zai.enabled = false;
708 empty.openrouter.enabled = false;
709 empty.anthropic.show_default_account = false;
710 assert_eq!(
711 tabs_from_config(&empty),
712 vec![TabId::vendor(VendorId::Anthropic)]
713 );
714 }
715
716 #[test]
717 fn tabs_expand_anthropic_accounts_after_default() {
718 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
720 assert_eq!(
721 tabs,
722 vec![
723 TabId::vendor(VendorId::Anthropic),
724 TabId::account("work"),
725 TabId::account("personal"),
726 ]
727 );
728 }
729
730 #[test]
731 fn tabs_without_accounts_are_just_enabled_vendors() {
732 let config = Config::default();
734 let tabs = tabs_from_config(&config);
735 let vendors: Vec<VendorId> = tabs.iter().map(|t| t.vendor).collect();
736 assert_eq!(vendors, config.enabled_vendors());
737 assert!(tabs.iter().all(|t| t.account.is_none()));
738 }
739
740 #[test]
741 fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
742 let td = tempfile::tempdir().unwrap();
745 for label in ["work", "personal"] {
746 let dir = td.path().join(label);
747 std::fs::create_dir_all(&dir).unwrap();
748 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
749 }
750 let mut config = Config::default();
751 config.openai.enabled = false;
752 config.zai.enabled = false;
753 config.openrouter.enabled = false;
754 config.anthropic.accounts_dir = Some(td.path().to_path_buf());
755
756 let tabs = tabs_from_config(&config);
757 assert_eq!(
758 tabs,
759 vec![
760 TabId::vendor(VendorId::Anthropic),
761 TabId::account("personal"), TabId::account("work"),
763 ]
764 );
765 }
766
767 #[test]
768 fn set_tabs_resets_states_and_clamps_selection() {
769 let mut app = App::with_theme(
773 tabs_from_config(&config_with_accounts(&["work", "personal"])),
774 Theme::default(),
775 );
776 app.active = 2; app.tabs[0] = TabState::Error("old".into());
778
779 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
780 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
781 assert_eq!(app.active, 0, "selection clamped after shrink");
782 assert!(matches!(app.tabs[0], TabState::Loading));
783 }
784
785 #[test]
786 fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
787 let mut app = App::with_theme(
788 vec![
789 TabId::vendor(VendorId::Anthropic),
790 TabId::vendor(VendorId::Openai),
791 ],
792 Theme::default(),
793 );
794 app.active = 1;
795
796 app.set_tabs(vec![
797 TabId::vendor(VendorId::Anthropic),
798 TabId::account("work"),
799 TabId::vendor(VendorId::Openai),
800 ]);
801
802 assert_eq!(app.active, 2);
803 assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
804 }
805
806 #[test]
807 fn refresh_from_old_generation_is_discarded() {
808 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
809 let old_generation = app.tab_generation;
810 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
811
812 assert!(!app.apply_refresh(
813 old_generation,
814 &TabId::vendor(VendorId::Anthropic),
815 TabState::Error("old result".into()),
816 ));
817 assert!(matches!(app.tabs[0], TabState::Loading));
818 }
819
820 #[test]
821 fn refresh_identity_mismatch_is_discarded() {
822 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
823 let generation = app.tab_generation;
824
825 assert!(!app.apply_refresh(
826 generation,
827 &TabId::vendor(VendorId::Openai),
828 TabState::Error("wrong tab".into()),
829 ));
830 assert!(matches!(app.tabs[0], TabState::Loading));
831 }
832
833 #[test]
834 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
835 let anthropic = TabId::vendor(VendorId::Anthropic);
836 let openai = TabId::vendor(VendorId::Openai);
837 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
838 let generation = app.tab_generation;
839
840 app.tabs_meta.swap(0, 1);
843 app.tabs.swap(0, 1);
844 assert!(app.apply_refresh(generation, &anthropic, TabState::Error("ready".into())));
845 assert!(matches!(app.tabs[0], TabState::Loading));
846 assert!(matches!(&app.tabs[1], TabState::Error(message) if message == "ready"));
847 }
848
849 fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
850 TabState::Ready(Box::new(ReadyTab {
851 snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
852 label: "test".into(),
853 total_credits: 0.0,
854 total_usage: 0.0,
855 usage_daily: 0.0,
856 usage_weekly: 0.0,
857 usage_monthly: 0.0,
858 is_free_tier: false,
859 limit: None,
860 limit_remaining: None,
861 }),
862 stale: false,
863 last_error: None,
864 fetched_at: Some(fetched_at),
865 }))
866 }
867
868 #[test]
869 fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
870 let anthropic = TabId::vendor(VendorId::Anthropic);
876 let openai = TabId::vendor(VendorId::Openai);
877 let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
878 let generation = app.tab_generation;
879 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
880
881 assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
882 match &app.tabs[0] {
883 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
884 other => panic!("expected Anthropic tab Ready, got {other:?}"),
885 }
886 assert!(matches!(app.tabs[1], TabState::Loading));
887 }
888
889 #[test]
890 fn select_primary_lands_on_default_account_tab() {
891 let app = {
894 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
895 let mut a = App::with_theme(tabs, Theme::default());
896 a.select_primary(Some(VendorId::Anthropic));
897 a
898 };
899 assert_eq!(app.active, 0);
900 assert_eq!(
901 app.active_tab_id(),
902 Some(&TabId::vendor(VendorId::Anthropic))
903 );
904 }
905}