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,
282 fetched_at,
283 }))
284 }
285 Err(e) => TabState::Error(e.to_string()),
286 }
287}
288
289async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
290 match tab.vendor {
291 VendorId::Anthropic => {
292 let (creds_target, cache) = match tab.account.as_deref() {
298 Some(label) => config.anthropic.account_target(label)?,
299 None => {
300 let target = match config.anthropic.credentials_path.clone() {
301 Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
302 None => crate::anthropic::creds::CredsTarget::Default(
303 crate::anthropic::creds::default_path().unwrap_or_default(),
304 ),
305 };
306 (target, crate::cache::Cache::for_vendor("anthropic")?)
307 }
308 };
309 let endpoints = crate::anthropic::fetch::Endpoints::default();
310 let outcome = crate::anthropic::fetch_snapshot(
311 client,
312 &creds_target,
313 &cache,
314 &endpoints,
315 DEFAULT_TTL,
316 )
317 .await?;
318 Ok(crate::vendor::VendorOutcome {
319 snapshot: crate::usage::VendorSnapshot::Anthropic(outcome.snapshot),
320 stale: outcome.stale,
321 last_error: outcome.last_error,
322 cache_age: outcome.cache_age,
323 })
324 }
325 VendorId::AnthropicApi => {
326 let key = crate::config::resolve_api_key(
327 "Anthropic_API",
328 &config.anthropic_api.api_key_env,
329 config.anthropic_api.api_key.as_deref(),
330 )?;
331 let cache = crate::cache::Cache::for_vendor("anthropic_api")?;
332 let endpoints = crate::anthropic_api::fetch::Endpoints::default();
333 let outcome = crate::anthropic_api::fetch_snapshot(
334 client,
335 &key,
336 &cache,
337 &endpoints,
338 DEFAULT_TTL,
339 config.anthropic_api.monthly_limit,
340 )
341 .await?;
342 Ok(outcome.into())
343 }
344 VendorId::Openrouter => {
345 let api_key = crate::config::resolve_api_key(
346 "OpenRouter",
347 &config.openrouter.api_key_env,
348 config.openrouter.api_key.as_deref(),
349 )?;
350 let cache = crate::cache::Cache::for_vendor("openrouter")?;
351 let endpoints = crate::openrouter::fetch::Endpoints::default();
352 let outcome = crate::openrouter::fetch_snapshot(
353 client,
354 &api_key,
355 &cache,
356 &endpoints,
357 DEFAULT_TTL,
358 )
359 .await?;
360 Ok(outcome.into())
361 }
362 VendorId::Zai => {
363 let api_key = crate::config::resolve_api_key(
364 "Zai",
365 &config.zai.api_key_env,
366 config.zai.api_key.as_deref(),
367 )?;
368 let cache = crate::cache::Cache::for_vendor("zai")?;
369 let endpoints = crate::zai::fetch::Endpoints::default();
370 let outcome = crate::zai::fetch_snapshot(
371 client,
372 &api_key,
373 &cache,
374 &endpoints,
375 DEFAULT_TTL,
376 config.zai.plan_tier.as_deref(),
377 )
378 .await?;
379 Ok(outcome.into())
380 }
381 VendorId::Openai => {
382 let cache = crate::cache::Cache::for_vendor("openai")?;
383 let creds_path = config
384 .openai
385 .codex_auth_path
386 .clone()
387 .unwrap_or_else(|| crate::openai::creds::default_path().unwrap_or_default());
388 let endpoints = crate::openai::fetch::Endpoints::default();
389 let outcome =
390 crate::openai::fetch_snapshot(client, &creds_path, &cache, &endpoints, DEFAULT_TTL)
391 .await?;
392 Ok(outcome.into())
393 }
394 VendorId::Deepseek => {
395 let api_key = crate::config::resolve_api_key(
396 "DeepSeek",
397 &config.deepseek.api_key_env,
398 config.deepseek.api_key.as_deref(),
399 )?;
400 let cache = crate::cache::Cache::for_vendor("deepseek")?;
401 let endpoints = crate::deepseek::fetch::Endpoints::default();
402 let outcome =
403 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
404 .await?;
405 Ok(outcome.into())
406 }
407 VendorId::Kimi => {
408 let api_key = crate::config::resolve_api_key(
409 "Kimi",
410 &config.kimi.api_key_env,
411 config.kimi.api_key.as_deref(),
412 )?;
413 let cache = crate::cache::Cache::for_vendor("kimi")?;
414 let endpoints = crate::kimi::fetch::Endpoints::default();
415 let outcome =
416 crate::kimi::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
417 .await?;
418 Ok(outcome.into())
419 }
420 VendorId::Kilo => {
421 let api_key = crate::config::resolve_api_key(
422 "Kilo",
423 &config.kilo.api_key_env,
424 config.kilo.api_key.as_deref(),
425 )?;
426 let cache = crate::cache::Cache::for_vendor("kilo")?;
427 let endpoints = crate::kilo::fetch::Endpoints::default();
428 let outcome = crate::kilo::fetch_snapshot(
429 client,
430 &api_key,
431 &cache,
432 &endpoints,
433 DEFAULT_TTL,
434 config.kilo.organization_id.as_deref(),
435 )
436 .await?;
437 Ok(outcome.into())
438 }
439 VendorId::Novita => {
440 let api_key = crate::config::resolve_api_key(
441 "Novita",
442 &config.novita.api_key_env,
443 config.novita.api_key.as_deref(),
444 )?;
445 let cache = crate::cache::Cache::for_vendor("novita")?;
446 let endpoints = crate::novita::fetch::Endpoints::default();
447 let outcome =
448 crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
449 .await?;
450 Ok(outcome.into())
451 }
452 VendorId::Moonshot => {
453 let api_key = crate::config::resolve_api_key(
454 "Moonshot",
455 &config.moonshot.api_key_env,
456 config.moonshot.api_key.as_deref(),
457 )?;
458 let cache = crate::cache::Cache::for_vendor("moonshot")?;
459 let (endpoints, currency) =
460 crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
461 let outcome = crate::moonshot::fetch_snapshot(
462 client,
463 &api_key,
464 &cache,
465 &endpoints,
466 DEFAULT_TTL,
467 currency,
468 )
469 .await?;
470 Ok(outcome.into())
471 }
472 VendorId::Grok => {
473 let key = crate::config::resolve_api_key(
474 "Grok",
475 &config.grok.api_key_env,
476 config.grok.api_key.as_deref(),
477 )?;
478 let cache = crate::cache::Cache::for_vendor("grok")?;
479 let endpoints = crate::grok::fetch::Endpoints::default();
480 let outcome = crate::grok::fetch_snapshot(
481 client,
482 &key,
483 &cache,
484 &endpoints,
485 DEFAULT_TTL,
486 config.grok.team_id.as_deref(),
487 )
488 .await?;
489 Ok(outcome.into())
490 }
491 VendorId::Antigravity => {
492 let cache = crate::cache::Cache::for_vendor("antigravity")?;
494 let outcome = crate::antigravity::fetch_snapshot(client, &cache, DEFAULT_TTL).await?;
495 Ok(outcome.into())
496 }
497 VendorId::Minimax => {
498 let api_key = crate::config::resolve_api_key(
499 "MiniMax",
500 &config.minimax.api_key_env,
501 config.minimax.api_key.as_deref(),
502 )?;
503 let cache = crate::cache::Cache::for_vendor("minimax")?;
504 let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
505 let outcome =
506 crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
507 .await?;
508 Ok(outcome.into())
509 }
510 VendorId::Cursor => {
511 let cache = crate::cache::Cache::for_vendor("cursor")?;
512 let db_path = config
513 .cursor
514 .db_path
515 .clone()
516 .map(Ok)
517 .unwrap_or_else(crate::cursor::db::default_db_path)?;
518 let endpoints = crate::cursor::fetch::Endpoints::default();
519 let outcome =
520 crate::cursor::fetch_snapshot(client, &db_path, &cache, &endpoints, DEFAULT_TTL)
521 .await?;
522 Ok(outcome.into())
523 }
524 }
525}
526
527pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
530
531pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
538
539pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
545 let mut anthropic_seen: u32 = 0;
546 tabs.iter()
547 .map(|tab| {
548 if tab.vendor == VendorId::Anthropic {
549 let delay = step * anthropic_seen;
550 anthropic_seen += 1;
551 delay
552 } else {
553 Duration::ZERO
554 }
555 })
556 .collect()
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use chrono::TimeZone;
563
564 #[test]
568 fn refresh_stagger_spaces_out_anthropic_tabs_only() {
569 let step = Duration::from_millis(800);
570 let tabs = vec![
571 TabId::vendor(VendorId::Anthropic), TabId::account("work"),
573 TabId::account("personal"),
574 TabId::vendor(VendorId::Openai),
575 TabId::vendor(VendorId::Zai),
576 ];
577 let delays = refresh_stagger(&tabs, step);
578 assert_eq!(
579 delays,
580 vec![
581 Duration::ZERO, step, step * 2, Duration::ZERO, Duration::ZERO, ]
587 );
588 }
589
590 #[test]
591 fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
592 let tabs = vec![
594 TabId::vendor(VendorId::Anthropic),
595 TabId::vendor(VendorId::Openrouter),
596 ];
597 assert!(
598 refresh_stagger(&tabs, Duration::from_millis(800))
599 .iter()
600 .all(|d| d.is_zero())
601 );
602 }
603
604 #[test]
605 fn select_primary_moves_to_enabled_vendor() {
606 let mut app = App::with_theme(
607 vec![
608 TabId::vendor(VendorId::Anthropic),
609 TabId::vendor(VendorId::Openrouter),
610 ],
611 Theme::default(),
612 );
613 app.select_primary(Some(VendorId::Openrouter));
614 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
615 }
616
617 #[test]
618 fn select_primary_ignores_disabled_vendor() {
619 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
620 app.select_primary(Some(VendorId::Openai));
621 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
622 }
623
624 #[test]
625 fn nav_ring_wraps_through_the_overview_at_both_ends() {
626 let mut app = App::with_theme(
627 vec![
628 TabId::vendor(VendorId::Anthropic),
629 TabId::vendor(VendorId::Openai),
630 ],
631 Theme::default(),
632 );
633 app.overview = true;
634
635 app.next_tab(); assert!(!app.overview);
637 assert_eq!(app.active, 0);
638 app.next_tab();
639 assert_eq!(app.active, 1);
640 app.next_tab(); assert!(app.overview);
642
643 app.prev_tab(); assert!(!app.overview);
645 assert_eq!(app.active, 1);
646 app.prev_tab();
647 assert_eq!(app.active, 0);
648 app.prev_tab(); assert!(app.overview);
650 }
651
652 #[test]
653 fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
654 let mut app = App::with_theme(
655 vec![
656 TabId::vendor(VendorId::Anthropic),
657 TabId::vendor(VendorId::Openai),
658 TabId::vendor(VendorId::Zai),
659 ],
660 Theme::default(),
661 );
662 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
663
664 app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
666 assert_eq!(app.overview_tabs(), vec![2, 0]);
667
668 app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
670 assert_eq!(app.overview_tabs(), vec![1]);
671 }
672
673 fn config_with_accounts(labels: &[&str]) -> Config {
674 let mut config = Config::default();
675 config.openai.enabled = false;
678 config.zai.enabled = false;
679 config.openrouter.enabled = false;
680 config.anthropic.accounts = labels
681 .iter()
682 .map(|l| crate::config::AnthropicAccount {
683 label: (*l).to_string(),
684 credentials_path: format!("/creds/{l}.json").into(),
685 })
686 .collect();
687 config
688 }
689
690 #[test]
691 fn show_default_account_false_hides_the_unnamed_claude_tab() {
692 let mut config = config_with_accounts(&["work", "personal"]);
695 config.anthropic.show_default_account = false;
696 assert_eq!(
697 tabs_from_config(&config),
698 vec![TabId::account("work"), TabId::account("personal")]
699 );
700
701 let mut empty = Config::default();
704 empty.openai.enabled = false;
705 empty.zai.enabled = false;
706 empty.openrouter.enabled = false;
707 empty.anthropic.show_default_account = false;
708 assert_eq!(
709 tabs_from_config(&empty),
710 vec![TabId::vendor(VendorId::Anthropic)]
711 );
712 }
713
714 #[test]
715 fn tabs_expand_anthropic_accounts_after_default() {
716 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
718 assert_eq!(
719 tabs,
720 vec![
721 TabId::vendor(VendorId::Anthropic),
722 TabId::account("work"),
723 TabId::account("personal"),
724 ]
725 );
726 }
727
728 #[test]
729 fn tabs_without_accounts_are_just_enabled_vendors() {
730 let config = Config::default();
732 let tabs = tabs_from_config(&config);
733 let vendors: Vec<VendorId> = tabs.iter().map(|t| t.vendor).collect();
734 assert_eq!(vendors, config.enabled_vendors());
735 assert!(tabs.iter().all(|t| t.account.is_none()));
736 }
737
738 #[test]
739 fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
740 let td = tempfile::tempdir().unwrap();
743 for label in ["work", "personal"] {
744 let dir = td.path().join(label);
745 std::fs::create_dir_all(&dir).unwrap();
746 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
747 }
748 let mut config = Config::default();
749 config.openai.enabled = false;
750 config.zai.enabled = false;
751 config.openrouter.enabled = false;
752 config.anthropic.accounts_dir = Some(td.path().to_path_buf());
753
754 let tabs = tabs_from_config(&config);
755 assert_eq!(
756 tabs,
757 vec![
758 TabId::vendor(VendorId::Anthropic),
759 TabId::account("personal"), TabId::account("work"),
761 ]
762 );
763 }
764
765 #[test]
766 fn set_tabs_resets_states_and_clamps_selection() {
767 let mut app = App::with_theme(
771 tabs_from_config(&config_with_accounts(&["work", "personal"])),
772 Theme::default(),
773 );
774 app.active = 2; app.tabs[0] = TabState::Error("old".into());
776
777 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
778 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
779 assert_eq!(app.active, 0, "selection clamped after shrink");
780 assert!(matches!(app.tabs[0], TabState::Loading));
781 }
782
783 #[test]
784 fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
785 let mut app = App::with_theme(
786 vec![
787 TabId::vendor(VendorId::Anthropic),
788 TabId::vendor(VendorId::Openai),
789 ],
790 Theme::default(),
791 );
792 app.active = 1;
793
794 app.set_tabs(vec![
795 TabId::vendor(VendorId::Anthropic),
796 TabId::account("work"),
797 TabId::vendor(VendorId::Openai),
798 ]);
799
800 assert_eq!(app.active, 2);
801 assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
802 }
803
804 #[test]
805 fn refresh_from_old_generation_is_discarded() {
806 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
807 let old_generation = app.tab_generation;
808 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
809
810 assert!(!app.apply_refresh(
811 old_generation,
812 &TabId::vendor(VendorId::Anthropic),
813 TabState::Error("old result".into()),
814 ));
815 assert!(matches!(app.tabs[0], TabState::Loading));
816 }
817
818 #[test]
819 fn refresh_identity_mismatch_is_discarded() {
820 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
821 let generation = app.tab_generation;
822
823 assert!(!app.apply_refresh(
824 generation,
825 &TabId::vendor(VendorId::Openai),
826 TabState::Error("wrong tab".into()),
827 ));
828 assert!(matches!(app.tabs[0], TabState::Loading));
829 }
830
831 #[test]
832 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
833 let anthropic = TabId::vendor(VendorId::Anthropic);
834 let openai = TabId::vendor(VendorId::Openai);
835 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
836 let generation = app.tab_generation;
837
838 app.tabs_meta.swap(0, 1);
841 app.tabs.swap(0, 1);
842 assert!(app.apply_refresh(generation, &anthropic, TabState::Error("ready".into())));
843 assert!(matches!(app.tabs[0], TabState::Loading));
844 assert!(matches!(&app.tabs[1], TabState::Error(message) if message == "ready"));
845 }
846
847 fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
848 TabState::Ready(Box::new(ReadyTab {
849 snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
850 label: "test".into(),
851 total_credits: 0.0,
852 total_usage: 0.0,
853 usage_daily: 0.0,
854 usage_weekly: 0.0,
855 usage_monthly: 0.0,
856 is_free_tier: false,
857 limit: None,
858 limit_remaining: None,
859 }),
860 stale: false,
861 last_error: None,
862 fetched_at: Some(fetched_at),
863 }))
864 }
865
866 #[test]
867 fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
868 let anthropic = TabId::vendor(VendorId::Anthropic);
874 let openai = TabId::vendor(VendorId::Openai);
875 let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
876 let generation = app.tab_generation;
877 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
878
879 assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
880 match &app.tabs[0] {
881 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
882 other => panic!("expected Anthropic tab Ready, got {other:?}"),
883 }
884 assert!(matches!(app.tabs[1], TabState::Loading));
885 }
886
887 #[test]
888 fn select_primary_lands_on_default_account_tab() {
889 let app = {
892 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
893 let mut a = App::with_theme(tabs, Theme::default());
894 a.select_primary(Some(VendorId::Anthropic));
895 a
896 };
897 assert_eq!(app.active, 0);
898 assert_eq!(
899 app.active_tab_id(),
900 Some(&TabId::vendor(VendorId::Anthropic))
901 );
902 }
903}