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