1use std::collections::HashSet;
4use std::time::Duration;
5
6use chrono::Utc;
7use reqwest::Client;
8
9use crate::cache::DEFAULT_TTL;
10use crate::config::{Config, CustomProviderConfig};
11use crate::error::{AppError, Result};
12use crate::theme::Theme;
13use crate::vendor::{VendorId, VendorOutcome};
14
15#[derive(Debug, Clone)]
21pub enum TabState {
22 Loading,
23 Ready(Box<ReadyTab>),
24 Error {
25 message: String,
26 plan: Option<String>,
30 },
31}
32
33#[derive(Debug, Clone)]
34pub struct ReadyTab {
35 pub snapshot: crate::usage::VendorSnapshot,
36 pub stale: bool,
37 pub last_error: Option<(u16, String)>,
38 pub fetched_at: Option<chrono::DateTime<chrono::Utc>>,
43 pub display: crate::balance::DisplayPrefs,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55pub enum TabSource {
56 Builtin(VendorId),
57 Custom {
58 id: String,
59 name: String,
60 short_name: String,
61 },
62}
63
64impl TabState {
65 pub fn error(message: impl AsRef<str>) -> Self {
66 Self::error_with_plan(message, None)
67 }
68
69 pub fn error_with_plan(message: impl AsRef<str>, plan: Option<String>) -> Self {
70 let plan = plan.and_then(|plan| {
71 let cleaned = crate::display::sanitize_untrusted_field(plan.trim());
72 if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("unknown") {
73 None
74 } else {
75 Some(cleaned)
76 }
77 });
78 Self::Error {
79 message: message.as_ref().to_string(),
80 plan,
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Hash)]
92pub struct TabId {
93 pub source: TabSource,
94 pub account: Option<String>,
95 pub desktop: bool,
96}
97
98impl TabId {
99 pub fn vendor(vendor: VendorId) -> Self {
101 Self {
102 source: TabSource::Builtin(vendor),
103 account: None,
104 desktop: false,
105 }
106 }
107
108 pub fn account(label: impl Into<String>) -> Self {
110 Self::account_for(VendorId::Anthropic, label)
111 }
112
113 pub fn account_for(vendor: VendorId, label: impl Into<String>) -> Self {
115 Self {
116 source: TabSource::Builtin(vendor),
117 account: Some(label.into()),
118 desktop: false,
119 }
120 }
121
122 pub fn desktop_account(label: impl Into<String>) -> Self {
125 Self {
126 source: TabSource::Builtin(VendorId::Anthropic),
127 account: Some(label.into()),
128 desktop: true,
129 }
130 }
131
132 pub fn custom(spec: &CustomProviderConfig) -> Self {
134 Self {
135 source: TabSource::Custom {
136 id: spec.id.clone(),
137 name: spec.name.clone(),
138 short_name: spec.short_name.clone(),
139 },
140 account: None,
141 desktop: false,
142 }
143 }
144
145 pub fn vendor_id(&self) -> Option<VendorId> {
148 match &self.source {
149 TabSource::Builtin(vendor) => Some(*vendor),
150 TabSource::Custom { .. } => None,
151 }
152 }
153}
154
155pub fn tabs_from_config(config: &Config) -> Vec<TabId> {
164 build_tabs(config, &[])
165}
166
167pub fn tabs_with_desktop(config: &Config) -> Vec<TabId> {
171 build_tabs(config, &desktop_profile_labels(config))
172}
173
174fn build_tabs(config: &Config, desktop_labels: &[String]) -> Vec<TabId> {
187 let desktop_set: HashSet<&str> = desktop_labels.iter().map(String::as_str).collect();
188 let mut tabs = Vec::new();
189 for vendor in config.enabled_vendors() {
190 if vendor == VendorId::Anthropic {
191 let accounts: Vec<_> = config
192 .anthropic
193 .all_accounts()
194 .into_iter()
195 .filter(|a| !desktop_set.contains(a.label.as_str()))
196 .collect();
197 if config.anthropic.show_default_account
201 || (accounts.is_empty() && desktop_labels.is_empty())
202 {
203 tabs.push(TabId::vendor(vendor));
204 }
205 for acct in accounts {
206 tabs.push(TabId::account(acct.label));
207 }
208 for label in desktop_labels {
209 tabs.push(TabId::desktop_account(label.clone()));
210 }
211 } else if let Some(accounts) = config.api_key_accounts(vendor) {
212 if config.show_default_api_key_account(vendor) || accounts.is_empty() {
213 tabs.push(TabId::vendor(vendor));
214 }
215 for account in accounts {
216 tabs.push(TabId::account_for(vendor, account.label.clone()));
217 }
218 } else if vendor == VendorId::Openai {
219 if config.openai.show_default_account || config.openai.accounts.is_empty() {
220 tabs.push(TabId::vendor(vendor));
221 }
222 for account in &config.openai.accounts {
223 tabs.push(TabId::account_for(vendor, account.label.clone()));
224 }
225 } else {
226 tabs.push(TabId::vendor(vendor));
227 }
228 }
229 for spec in config.enabled_custom() {
231 tabs.push(TabId::custom(spec));
232 }
233 tabs
234}
235
236#[cfg(target_os = "macos")]
240fn desktop_profile_labels(config: &Config) -> Vec<String> {
241 let Ok(paths) = crate::claude_desktop::Paths::resolve(&config.anthropic) else {
242 return Vec::new();
243 };
244 if !paths.available() {
245 return Vec::new();
246 }
247 crate::claude_desktop::load_profiles(&paths.profiles_dir)
248 .into_iter()
249 .filter(|p| p.has_credentials)
250 .map(|p| p.label)
251 .collect()
252}
253
254#[cfg(not(target_os = "macos"))]
255fn desktop_profile_labels(_config: &Config) -> Vec<String> {
256 Vec::new()
257}
258
259#[derive(Debug)]
260pub struct App {
261 pub tabs_meta: Vec<TabId>,
262 pub active: usize,
263 pub tabs: Vec<TabState>,
264 refreshing_tabs: HashSet<TabId>,
267 pub tab_generation: u64,
271 pub overview: bool,
274 pub overview_vendors: Option<Vec<VendorId>>,
276 pub theme: Theme,
277 pub quit: bool,
278 pub settings: Option<crate::tui::settings::SettingsState>,
280 pub context_enabled: bool,
283 pub context_generation: u64,
286 pub context: Option<crate::tui::context::ContextState>,
288 pub vendor_box: crate::config::VendorBoxStyle,
290}
291
292impl App {
293 pub fn new(tabs_meta: Vec<TabId>) -> Self {
294 Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
297 }
298
299 pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
305 let n = tabs_meta.len();
306 Self {
307 tabs_meta,
308 active: 0,
309 tabs: vec![TabState::Loading; n],
310 refreshing_tabs: HashSet::new(),
311 tab_generation: 0,
312 overview: false,
313 overview_vendors: None,
314 theme,
315 quit: false,
316 settings: None,
317 context_enabled: false,
318 context_generation: 0,
319 context: None,
320 vendor_box: crate::config::VendorBoxStyle::Sidebar,
321 }
322 }
323
324 pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
328 let mut app = Self::new(tabs_meta);
329 if primary.is_some() {
332 app.select_primary(primary);
333 } else {
334 app.overview = true;
335 }
336 app
337 }
338
339 pub fn active_tab_id(&self) -> Option<&TabId> {
340 self.tabs_meta.get(self.active)
341 }
342
343 pub fn active_vendor(&self) -> Option<VendorId> {
344 self.tabs_meta.get(self.active).and_then(TabId::vendor_id)
345 }
346
347 pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
354 let selected = self.active_tab_id().cloned();
355 let fallback = self.active.min(tabs_meta.len().saturating_sub(1));
356 self.tab_generation = self.tab_generation.wrapping_add(1);
357 self.active = selected
358 .as_ref()
359 .and_then(|tab| tabs_meta.iter().position(|candidate| candidate == tab))
360 .unwrap_or(fallback);
361 self.tabs = vec![TabState::Loading; tabs_meta.len()];
362 self.tabs_meta = tabs_meta;
363 self.refreshing_tabs.clear();
364 }
365
366 pub fn begin_refresh(&mut self, tab: &TabId) -> bool {
370 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
371 return false;
372 };
373 if !self.refreshing_tabs.insert(tab.clone()) {
374 return false;
375 }
376 if !matches!(self.tabs[index], TabState::Ready(_)) {
377 self.tabs[index] = TabState::Loading;
378 }
379 true
380 }
381
382 pub fn is_refreshing(&self, tab: &TabId) -> bool {
383 self.refreshing_tabs.contains(tab)
384 }
385
386 pub fn tab_is_refreshing(&self, index: usize) -> bool {
387 self.tabs_meta
388 .get(index)
389 .is_some_and(|tab| self.is_refreshing(tab))
390 }
391
392 pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
397 if generation != self.tab_generation {
398 return false;
399 }
400 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
401 return false;
402 };
403 let was_refreshing = self.refreshing_tabs.remove(tab);
404 if was_refreshing
408 && let TabState::Ready(ready) = &mut self.tabs[index]
409 && let TabState::Error { message, .. } = state
410 {
411 ready.stale = true;
412 ready.last_error = Some((0, message));
413 } else {
414 self.tabs[index] = state;
415 }
416 true
417 }
418
419 pub fn select_primary(&mut self, primary: Option<VendorId>) {
422 if let Some(p) = primary
423 && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor_id() == Some(p))
424 {
425 self.active = idx;
426 self.overview = false;
427 }
428 }
429
430 pub fn next_tab(&mut self) {
433 if self.overview {
434 if !self.tabs_meta.is_empty() {
435 self.overview = false;
436 self.active = 0;
437 }
438 } else if self.active + 1 < self.tabs_meta.len() {
439 self.active += 1;
440 } else {
441 self.overview = true;
442 }
443 }
444
445 pub fn prev_tab(&mut self) {
446 if self.overview {
447 if !self.tabs_meta.is_empty() {
448 self.overview = false;
449 self.active = self.tabs_meta.len() - 1;
450 }
451 } else if self.active > 0 {
452 self.active -= 1;
453 } else {
454 self.overview = true;
455 }
456 }
457
458 pub fn overview_tabs(&self) -> Vec<usize> {
463 match &self.overview_vendors {
464 None => (0..self.tabs_meta.len()).collect(),
465 Some(wanted) => wanted
466 .iter()
467 .flat_map(|v| {
468 self.tabs_meta
469 .iter()
470 .enumerate()
471 .filter(move |(_, t)| t.vendor_id() == Some(*v))
472 .map(|(i, _)| i)
473 })
474 .collect(),
475 }
476 }
477}
478
479pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
481 match build_outcome(client, config, tab).await {
482 Ok(outcome) => {
483 let now = Utc::now();
488 let off_the_wire = outcome.off_the_wire();
489 let fetched_at = outcome
490 .cache_age
491 .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
492 let state = TabState::Ready(Box::new(ReadyTab {
493 snapshot: outcome.snapshot,
494 stale: outcome.stale,
495 last_error: outcome.last_error.map(|(code, message)| {
496 (code, crate::display::sanitize_untrusted_field(&message))
497 }),
498 fetched_at,
499 display: match &tab.source {
500 TabSource::Builtin(vendor) => config.display_prefs(*vendor),
501 TabSource::Custom { .. } => crate::balance::DisplayPrefs::default(),
504 },
505 }));
506 if off_the_wire
512 && config.notifications.enabled
513 && let Some(input) = crate::notify::RefreshInput::from_tab(tab, &state, now)
514 {
515 let _ = crate::notify::run(input, config.notifications.threshold).await;
516 }
517 state
518 }
519 Err(e) => TabState::error_with_plan(
520 crate::display::sanitize_untrusted_field(&e.user_message()),
521 e.plan().map(str::to_string),
522 ),
523 }
524}
525
526fn api_key_and_cache(
530 config: &Config,
531 vendor: VendorId,
532 account: Option<&str>,
533) -> Result<(String, crate::cache::Cache)> {
534 let api_key = config.resolve_account_api_key_for(vendor, account)?;
535 let cache = match account {
536 Some(label) => crate::cache::Cache::for_vendor_account(vendor.slug(), label)?,
537 None => crate::cache::Cache::for_vendor(vendor.slug())?,
538 };
539 Ok((api_key, cache))
540}
541
542async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
543 let vendor = match &tab.source {
544 TabSource::Builtin(vendor) => *vendor,
545 TabSource::Custom { id, .. } => {
546 let spec = config.custom_by_id(id).ok_or_else(|| {
549 AppError::Other(format!("custom provider {id} is not configured"))
550 })?;
551 let api_key = spec.resolve_api_key()?;
552 let cache = crate::cache::Cache::for_vendor_account("custom", id)?;
553 let outcome =
554 crate::custom::fetch_snapshot(client, spec, &api_key, &cache, spec.cache_ttl())
555 .await?;
556 return Ok(outcome.into());
557 }
558 };
559 match vendor {
560 VendorId::Anthropic => {
561 let (creds_target, cache) = match tab.account.as_deref() {
567 Some(label) if tab.desktop => {
568 crate::anthropic::desktop_creds::account_target(config, label)?
569 }
570 Some(label) => config.anthropic.account_target(label)?,
571 None => {
572 let target = match config.anthropic.credentials_path.clone() {
573 Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
574 None => crate::anthropic::creds::CredsTarget::Default(
575 crate::anthropic::creds::default_path().unwrap_or_default(),
576 ),
577 };
578 (target, crate::cache::Cache::for_vendor("anthropic")?)
579 }
580 };
581 let endpoints = crate::anthropic::fetch::Endpoints::default();
582 let outcome = crate::anthropic::fetch_snapshot(
583 client,
584 &creds_target,
585 &cache,
586 &endpoints,
587 DEFAULT_TTL,
588 )
589 .await?;
590 Ok(outcome.map(crate::usage::VendorSnapshot::Anthropic))
591 }
592 VendorId::AnthropicApi => {
593 let key = crate::config::resolve_api_key(
594 "Anthropic_API",
595 &config.anthropic_api.api_key_env,
596 config.anthropic_api.api_key.as_deref(),
597 )?;
598 let cache = crate::cache::Cache::for_vendor("anthropic_api")?;
599 let endpoints = crate::anthropic_api::fetch::Endpoints::default();
600 let outcome = crate::anthropic_api::fetch_snapshot(
601 client,
602 &key,
603 &cache,
604 &endpoints,
605 DEFAULT_TTL,
606 config.anthropic_api.monthly_limit,
607 )
608 .await?;
609 Ok(outcome.into())
610 }
611 VendorId::Openrouter => {
612 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
613 let endpoints = crate::openrouter::fetch::Endpoints::default();
614 let outcome = crate::openrouter::fetch_snapshot(
615 client,
616 &api_key,
617 &cache,
618 &endpoints,
619 DEFAULT_TTL,
620 )
621 .await?;
622 Ok(outcome.into())
623 }
624 VendorId::Zai => {
625 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
626 let endpoints = crate::zai::fetch::Endpoints::default();
627 let outcome = crate::zai::fetch_snapshot(
628 client,
629 &api_key,
630 &cache,
631 &endpoints,
632 DEFAULT_TTL,
633 config.zai.plan_tier.as_deref(),
634 )
635 .await?;
636 Ok(outcome.into())
637 }
638 VendorId::Openai => {
639 let label = tab.account.as_deref();
640 let cache = match label {
641 Some(label) => crate::cache::Cache::for_vendor_account("openai", label)?,
642 None => crate::cache::Cache::for_vendor("openai")?,
643 };
644 let route = || config.openai.fetch_auth_path(label);
645 let endpoints = crate::openai::fetch::Endpoints::default();
646 let outcome = crate::openai::fetch_snapshot_routed(
647 client,
648 route,
649 &cache,
650 &endpoints,
651 DEFAULT_TTL,
652 )
653 .await?;
654 Ok(outcome.into())
655 }
656 VendorId::Copilot => {
657 let token = config.copilot.resolve_token()?;
658 let cache = crate::cache::Cache::for_vendor("copilot")?;
659 let endpoints = crate::copilot::fetch::Endpoints::default();
660 let outcome =
661 crate::copilot::fetch_snapshot(client, &token, &cache, &endpoints, DEFAULT_TTL)
662 .await?;
663 Ok(outcome.into())
664 }
665 VendorId::Deepseek => {
666 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
667 let endpoints = crate::deepseek::fetch::Endpoints::default();
668 let outcome =
669 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
670 .await?;
671 Ok(outcome.into())
672 }
673 VendorId::Kimi => {
674 let (auth, endpoints) = crate::kimi::resolve_auth(&config.kimi)?;
675 let cache = crate::cache::Cache::for_vendor("kimi")?;
676 let outcome = crate::kimi::fetch::fetch_snapshot_with_auth(
677 client,
678 &auth,
679 &cache,
680 &endpoints,
681 DEFAULT_TTL,
682 )
683 .await?;
684 Ok(outcome.into())
685 }
686 VendorId::Kilo => {
687 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
688 let endpoints = crate::kilo::fetch::Endpoints::default();
689 let outcome = crate::kilo::fetch_snapshot(
690 client,
691 &api_key,
692 &cache,
693 &endpoints,
694 DEFAULT_TTL,
695 config.kilo.organization_id.as_deref(),
696 )
697 .await?;
698 Ok(outcome.into())
699 }
700 VendorId::Novita => {
701 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
702 let endpoints = crate::novita::fetch::Endpoints::default();
703 let outcome =
704 crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
705 .await?;
706 Ok(outcome.into())
707 }
708 VendorId::Moonshot => {
709 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
710 let (endpoints, currency) =
711 crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
712 let outcome = crate::moonshot::fetch_snapshot(
713 client,
714 &api_key,
715 &cache,
716 &endpoints,
717 DEFAULT_TTL,
718 currency,
719 )
720 .await?;
721 Ok(outcome.into())
722 }
723 VendorId::Grok => {
724 let (key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
725 let endpoints = crate::grok::fetch::Endpoints::default();
726 let outcome = crate::grok::fetch_snapshot(
727 client,
728 &key,
729 &cache,
730 &endpoints,
731 DEFAULT_TTL,
732 config.grok.team_id.as_deref(),
733 )
734 .await?;
735 Ok(outcome.into())
736 }
737 VendorId::Supergrok => {
738 let cache = crate::cache::Cache::for_vendor("supergrok")?;
739 let scope_paths = crate::supergrok::scope::ScopePaths::with_overrides(
740 config.supergrok.auth_path.as_deref(),
741 config.supergrok.config_path.as_deref(),
742 )?;
743 let outcome = crate::supergrok::fetch_snapshot(
744 &config.supergrok.grok_binary,
745 &scope_paths,
746 &cache,
747 DEFAULT_TTL,
748 )
749 .await?;
750 Ok(outcome.into())
751 }
752 VendorId::Antigravity => {
753 let cache = crate::cache::Cache::for_vendor("antigravity")?;
756 let oauth = crate::antigravity::cloud::OauthClient::from_config(
757 config.antigravity.oauth_client_id.as_deref(),
758 config.antigravity.oauth_client_secret.as_deref(),
759 );
760 let outcome =
761 crate::antigravity::fetch_snapshot(client, &cache, DEFAULT_TTL, oauth.as_ref())
762 .await?;
763 Ok(outcome.into())
764 }
765 VendorId::Grokbot => {
766 let creds = crate::grokbot::resolve_credentials(&config.grokbot)?;
769 let cache = crate::cache::Cache::for_vendor("grokbot")?;
770 let endpoints = crate::grokbot::fetch::Endpoints::default();
771 let outcome = crate::grokbot::fetch::fetch_snapshot_with(
772 client,
773 &creds,
774 &cache,
775 &endpoints,
776 DEFAULT_TTL,
777 )
778 .await?;
779 Ok(outcome.into())
780 }
781 VendorId::ModelStudio => {
782 let creds = crate::modelstudio::resolve_credentials(&config.modelstudio)?;
785 let cache = crate::cache::Cache::for_vendor("modelstudio")?;
786 let endpoints =
787 crate::modelstudio::fetch::Endpoints::for_gateway(creds.region, creds.site);
788 let outcome = crate::modelstudio::fetch_snapshot_with(
789 client,
790 &creds,
791 &cache,
792 &endpoints,
793 DEFAULT_TTL,
794 )
795 .await?;
796 Ok(outcome.into())
797 }
798 VendorId::Minimax => {
799 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
800 let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
801 let outcome =
802 crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
803 .await?;
804 Ok(outcome.into())
805 }
806 VendorId::Cursor => {
807 let cache = crate::cache::Cache::for_vendor("cursor")?;
808 let db_path = config
809 .cursor
810 .db_path
811 .clone()
812 .map(Ok)
813 .unwrap_or_else(crate::cursor::db::default_db_path)?;
814 let agent_auth_path = config
815 .cursor
816 .agent_auth_path
817 .clone()
818 .map(Ok)
819 .unwrap_or_else(crate::cursor::db::default_agent_auth_path)?;
820 let endpoints = crate::cursor::fetch::Endpoints::default();
821 let outcome = crate::cursor::fetch_snapshot(
822 client,
823 &db_path,
824 &agent_auth_path,
825 &cache,
826 &endpoints,
827 DEFAULT_TTL,
828 )
829 .await?;
830 Ok(outcome.into())
831 }
832 VendorId::Kiro => {
833 let cache = crate::cache::Cache::for_vendor("kiro")?;
834 let db_path = config
835 .kiro
836 .db_path
837 .clone()
838 .map(Ok)
839 .unwrap_or_else(crate::kiro::db::default_db_path)?;
840 let outcome =
841 crate::kiro::fetch_snapshot(client, &db_path, &cache, DEFAULT_TTL).await?;
842 Ok(outcome.into())
843 }
844 VendorId::NousResearch => {
845 let store = crate::nous::credentials::CredentialStore::default();
846 let endpoints = crate::nous::fetch::Endpoints::default();
847 let account = crate::nous::fetch::fetch_account_with_refresh(
848 client,
849 &store,
850 &endpoints,
851 Utc::now(),
852 )
853 .await?;
854 Ok(crate::outcome::Outcome::fresh(
856 crate::usage::VendorSnapshot::NousResearch(account),
857 ))
858 }
859 VendorId::OpenCodeGo => {
860 let api_key = crate::config::resolve_api_key(
861 "OpenCode Go",
862 &config.opencode_go.api_key_env,
863 config.opencode_go.api_key.as_deref(),
864 )?;
865 let cache = crate::cache::Cache::for_vendor("opencode-go")?;
866 let endpoints = crate::opencode_go::fetch::Endpoints::default();
867 let outcome = crate::opencode_go::fetch::fetch_snapshot(
868 client,
869 &api_key,
870 &cache,
871 &endpoints,
872 DEFAULT_TTL,
873 )
874 .await?;
875 Ok(outcome.into())
876 }
877 VendorId::CommandCode => {
878 let credential =
879 crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref())?;
880 let cache = crate::cache::Cache::for_vendor("commandcode")?;
881 let endpoints = crate::commandcode::fetch::Endpoints::default();
882 let outcome = crate::commandcode::fetch::fetch_snapshot(
883 client,
884 &credential.token,
885 &cache,
886 &endpoints,
887 DEFAULT_TTL,
888 )
889 .await?;
890 Ok(outcome.into())
891 }
892 VendorId::Ollama => {
893 let api_key = crate::config::resolve_api_key(
894 "Ollama",
895 &config.ollama.api_key_env,
896 config.ollama.api_key.as_deref(),
897 )?;
898 let cache = crate::cache::Cache::for_vendor("ollama")?;
899 let endpoints = crate::ollama::fetch::Endpoints::default();
900 let outcome = crate::ollama::fetch_snapshot(
901 client,
902 &api_key,
903 &config.ollama.plan,
904 &cache,
905 &endpoints,
906 DEFAULT_TTL,
907 )
908 .await?;
909 Ok(outcome.into())
910 }
911 VendorId::OrcaRouter => {
912 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
913 let endpoints = crate::orcarouter::fetch::Endpoints::default();
914 let outcome = crate::orcarouter::fetch_snapshot(
915 client,
916 &api_key,
917 &cache,
918 &endpoints,
919 DEFAULT_TTL,
920 )
921 .await?;
922 Ok(outcome.into())
923 }
924 }
925}
926
927pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
930
931pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
938
939pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
945 let mut anthropic_seen: u32 = 0;
946 tabs.iter()
947 .map(|tab| {
948 if tab.vendor_id() == Some(VendorId::Anthropic) {
949 let delay = step * anthropic_seen;
950 anthropic_seen += 1;
951 delay
952 } else {
953 Duration::ZERO
954 }
955 })
956 .collect()
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use chrono::TimeZone;
963
964 #[test]
968 fn refresh_stagger_spaces_out_anthropic_tabs_only() {
969 let step = Duration::from_millis(800);
970 let tabs = vec![
971 TabId::vendor(VendorId::Anthropic), TabId::account("work"),
973 TabId::account("personal"),
974 TabId::vendor(VendorId::Openai),
975 TabId::vendor(VendorId::Zai),
976 ];
977 let delays = refresh_stagger(&tabs, step);
978 assert_eq!(
979 delays,
980 vec![
981 Duration::ZERO, step, step * 2, Duration::ZERO, Duration::ZERO, ]
987 );
988 }
989
990 #[test]
991 fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
992 let tabs = vec![
994 TabId::vendor(VendorId::Anthropic),
995 TabId::vendor(VendorId::Openrouter),
996 ];
997 assert!(
998 refresh_stagger(&tabs, Duration::from_millis(800))
999 .iter()
1000 .all(|d| d.is_zero())
1001 );
1002 }
1003
1004 #[test]
1005 fn select_primary_moves_to_enabled_vendor() {
1006 let mut app = App::with_theme(
1007 vec![
1008 TabId::vendor(VendorId::Anthropic),
1009 TabId::vendor(VendorId::Openrouter),
1010 ],
1011 Theme::default(),
1012 );
1013 app.select_primary(Some(VendorId::Openrouter));
1014 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
1015 }
1016
1017 #[test]
1018 fn select_primary_ignores_disabled_vendor() {
1019 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1020 app.select_primary(Some(VendorId::Openai));
1021 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
1022 }
1023
1024 #[test]
1025 fn nav_ring_wraps_through_the_overview_at_both_ends() {
1026 let mut app = App::with_theme(
1027 vec![
1028 TabId::vendor(VendorId::Anthropic),
1029 TabId::vendor(VendorId::Openai),
1030 ],
1031 Theme::default(),
1032 );
1033 app.overview = true;
1034
1035 app.next_tab(); assert!(!app.overview);
1037 assert_eq!(app.active, 0);
1038 app.next_tab();
1039 assert_eq!(app.active, 1);
1040 app.next_tab(); assert!(app.overview);
1042
1043 app.prev_tab(); assert!(!app.overview);
1045 assert_eq!(app.active, 1);
1046 app.prev_tab();
1047 assert_eq!(app.active, 0);
1048 app.prev_tab(); assert!(app.overview);
1050 }
1051
1052 #[test]
1053 fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
1054 let mut app = App::with_theme(
1055 vec![
1056 TabId::vendor(VendorId::Anthropic),
1057 TabId::vendor(VendorId::Openai),
1058 TabId::vendor(VendorId::Zai),
1059 ],
1060 Theme::default(),
1061 );
1062 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1063
1064 app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
1066 assert_eq!(app.overview_tabs(), vec![2, 0]);
1067
1068 app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
1070 assert_eq!(app.overview_tabs(), vec![1]);
1071 }
1072
1073 fn config_with_accounts(labels: &[&str]) -> Config {
1074 let mut config = Config::default();
1075 config.openai.enabled = false;
1078 config.zai.enabled = false;
1079 config.openrouter.enabled = false;
1080 config.commandcode.enabled = false;
1081 config.anthropic.accounts = labels
1082 .iter()
1083 .map(|l| crate::config::AnthropicAccount {
1084 label: (*l).to_string(),
1085 credentials_path: format!("/creds/{l}.json").into(),
1086 })
1087 .collect();
1088 config
1089 }
1090
1091 #[test]
1092 fn show_default_account_false_hides_the_unnamed_claude_tab() {
1093 let mut config = config_with_accounts(&["work", "personal"]);
1096 config.anthropic.show_default_account = false;
1097 assert_eq!(
1098 tabs_from_config(&config),
1099 vec![TabId::account("work"), TabId::account("personal")]
1100 );
1101
1102 let mut empty = Config::default();
1105 empty.openai.enabled = false;
1106 empty.zai.enabled = false;
1107 empty.openrouter.enabled = false;
1108 empty.commandcode.enabled = false;
1109 empty.anthropic.show_default_account = false;
1110 assert_eq!(
1111 tabs_from_config(&empty),
1112 vec![TabId::vendor(VendorId::Anthropic)]
1113 );
1114 }
1115
1116 #[test]
1117 fn tabs_expand_anthropic_accounts_after_default() {
1118 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
1120 assert_eq!(
1121 tabs,
1122 vec![
1123 TabId::vendor(VendorId::Anthropic),
1124 TabId::account("work"),
1125 TabId::account("personal"),
1126 ]
1127 );
1128 }
1129
1130 #[test]
1131 fn tabs_without_accounts_are_just_enabled_vendors() {
1132 let config = Config::default();
1134 let tabs = tabs_from_config(&config);
1135 let vendors: Vec<VendorId> = tabs.iter().filter_map(TabId::vendor_id).collect();
1136 assert_eq!(vendors, config.enabled_vendors());
1137 assert!(tabs.iter().all(|t| t.account.is_none()));
1138 }
1139
1140 #[test]
1141 fn tabs_expand_openrouter_accounts_without_changing_other_vendors() {
1142 let mut config = Config::default();
1143 config.anthropic.enabled = false;
1144 config.openai.enabled = false;
1145 config.zai.enabled = false;
1146 config.commandcode.enabled = false;
1147 config.openrouter.accounts = vec![
1148 crate::config::ApiKeyAccount {
1149 label: "work".into(),
1150 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1151 api_key: None,
1152 },
1153 crate::config::ApiKeyAccount {
1154 label: "personal".into(),
1155 api_key_env: None,
1156 api_key: Some("personal-key".into()),
1157 },
1158 ];
1159 assert_eq!(
1160 tabs_from_config(&config),
1161 vec![
1162 TabId::vendor(VendorId::Openrouter),
1163 TabId::account_for(VendorId::Openrouter, "work"),
1164 TabId::account_for(VendorId::Openrouter, "personal"),
1165 ]
1166 );
1167 }
1168
1169 #[test]
1170 fn openai_named_accounts_get_their_own_tabs_after_the_default() {
1171 let mut config = Config::default();
1172 config.anthropic.enabled = false;
1173 config.zai.enabled = false;
1174 config.openrouter.enabled = false;
1175 config.commandcode.enabled = false;
1176 config.openai.accounts.push(crate::config::OpenAiAccount {
1177 label: "work".into(),
1178 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1179 });
1180 assert_eq!(
1181 tabs_from_config(&config),
1182 vec![
1183 TabId::vendor(VendorId::Openai),
1184 TabId::account_for(VendorId::Openai, "work"),
1185 ]
1186 );
1187 }
1188
1189 #[test]
1190 fn openrouter_can_hide_default_only_when_named_accounts_exist() {
1191 let mut config = Config::default();
1192 config.anthropic.enabled = false;
1193 config.openai.enabled = false;
1194 config.zai.enabled = false;
1195 config.commandcode.enabled = false;
1196 config.openrouter.show_default_account = false;
1197 assert_eq!(
1198 tabs_from_config(&config),
1199 vec![TabId::vendor(VendorId::Openrouter)]
1200 );
1201
1202 config
1203 .openrouter
1204 .accounts
1205 .push(crate::config::ApiKeyAccount {
1206 label: "work".into(),
1207 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1208 api_key: None,
1209 });
1210 assert_eq!(
1211 tabs_from_config(&config),
1212 vec![TabId::account_for(VendorId::Openrouter, "work")]
1213 );
1214 }
1215
1216 #[test]
1217 fn every_api_key_account_vendor_fans_out_like_openrouter() {
1218 let config_for = |vendor: VendorId, show_default: bool| -> Config {
1219 let section = vendor.config_section();
1220 let mut text: String = ["anthropic", "openai", "commandcode", "zai", "openrouter"]
1223 .into_iter()
1224 .filter(|other| *other != section)
1225 .map(|other| format!("[{other}]\nenabled = false\n"))
1226 .collect();
1227 text.push_str(&format!(
1228 "[{section}]\nenabled = true\nshow_default_account = {show_default}\n\
1229 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k1\"\n\
1230 [[{section}.accounts]]\nlabel = \"personal\"\napi_key = \"k2\"\n"
1231 ));
1232 let config: Config = toml::from_str(&text).unwrap();
1233 config.validate().unwrap();
1234 config
1235 };
1236 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
1237 let named = vec![
1238 TabId::account_for(vendor, "work"),
1239 TabId::account_for(vendor, "personal"),
1240 ];
1241 let mut with_default = vec![TabId::vendor(vendor)];
1242 with_default.extend(named.clone());
1243 assert_eq!(
1244 tabs_from_config(&config_for(vendor, true)),
1245 with_default,
1246 "{vendor:?}"
1247 );
1248 assert_eq!(
1249 tabs_from_config(&config_for(vendor, false)),
1250 named,
1251 "{vendor:?}"
1252 );
1253 }
1254 }
1255
1256 #[test]
1257 fn codex_can_hide_default_only_when_named_accounts_exist() {
1258 let mut config = Config::default();
1259 config.anthropic.enabled = false;
1260 config.zai.enabled = false;
1261 config.openrouter.enabled = false;
1262 config.commandcode.enabled = false;
1263 config.openai.show_default_account = false;
1264 assert_eq!(
1265 tabs_from_config(&config),
1266 vec![TabId::vendor(VendorId::Openai)]
1267 );
1268
1269 config.openai.accounts.push(crate::config::OpenAiAccount {
1270 label: "work".into(),
1271 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1272 });
1273 assert_eq!(
1274 tabs_from_config(&config),
1275 vec![TabId::account_for(VendorId::Openai, "work")]
1276 );
1277 }
1278
1279 #[test]
1280 fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
1281 let td = tempfile::tempdir().unwrap();
1284 for label in ["work", "personal"] {
1285 let dir = td.path().join(label);
1286 std::fs::create_dir_all(&dir).unwrap();
1287 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1288 }
1289 let mut config = Config::default();
1290 config.openai.enabled = false;
1291 config.zai.enabled = false;
1292 config.openrouter.enabled = false;
1293 config.commandcode.enabled = false;
1294 config.anthropic.accounts_dir = Some(td.path().to_path_buf());
1295
1296 let tabs = tabs_from_config(&config);
1297 assert_eq!(
1298 tabs,
1299 vec![
1300 TabId::vendor(VendorId::Anthropic),
1301 TabId::account("personal"), TabId::account("work"),
1303 ]
1304 );
1305 }
1306
1307 #[test]
1308 fn desktop_labels_become_account_tabs_after_cli_accounts() {
1309 let config = config_with_accounts(&["work"]);
1311 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1312 assert_eq!(
1313 tabs,
1314 vec![
1315 TabId::vendor(VendorId::Anthropic),
1316 TabId::account("work"),
1317 TabId::desktop_account("gmail"),
1318 TabId::desktop_account("hotmail"),
1319 ]
1320 );
1321 }
1322
1323 #[test]
1324 fn a_desktop_profile_wins_a_label_collision_with_a_cli_account() {
1325 let config = config_with_accounts(&["gmail", "work"]);
1330 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1331 assert_eq!(
1332 tabs,
1333 vec![
1334 TabId::vendor(VendorId::Anthropic),
1335 TabId::account("work"),
1336 TabId::desktop_account("gmail"),
1337 TabId::desktop_account("hotmail"),
1338 ]
1339 );
1340 }
1341
1342 #[test]
1343 fn desktop_accounts_suppress_the_default_tab_like_named_ones() {
1344 let mut config = config_with_accounts(&[]);
1347 config.cursor.enabled = false;
1348 config.anthropic.show_default_account = false;
1349
1350 assert_eq!(
1353 build_tabs(&config, &[]),
1354 vec![TabId::vendor(VendorId::Anthropic)]
1355 );
1356 assert_eq!(
1358 build_tabs(&config, &["gmail".into()]),
1359 vec![TabId::desktop_account("gmail")]
1360 );
1361 }
1362
1363 #[test]
1364 fn set_tabs_resets_states_and_clamps_selection() {
1365 let mut app = App::with_theme(
1369 tabs_from_config(&config_with_accounts(&["work", "personal"])),
1370 Theme::default(),
1371 );
1372 app.active = 2; app.tabs[0] = TabState::error("old");
1374 let old_tab = app.tabs_meta[0].clone();
1375 assert!(app.begin_refresh(&old_tab));
1376
1377 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
1378 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
1379 assert_eq!(app.active, 0, "selection clamped after shrink");
1380 assert!(matches!(app.tabs[0], TabState::Loading));
1381 assert!(!app.is_refreshing(&old_tab));
1382 }
1383
1384 #[test]
1385 fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
1386 let mut app = App::with_theme(
1387 vec![
1388 TabId::vendor(VendorId::Anthropic),
1389 TabId::vendor(VendorId::Openai),
1390 ],
1391 Theme::default(),
1392 );
1393 app.active = 1;
1394
1395 app.set_tabs(vec![
1396 TabId::vendor(VendorId::Anthropic),
1397 TabId::account("work"),
1398 TabId::vendor(VendorId::Openai),
1399 ]);
1400
1401 assert_eq!(app.active, 2);
1402 assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
1403 }
1404
1405 #[test]
1406 fn refresh_from_old_generation_is_discarded() {
1407 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1408 let old_generation = app.tab_generation;
1409 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
1410
1411 assert!(!app.apply_refresh(
1412 old_generation,
1413 &TabId::vendor(VendorId::Anthropic),
1414 TabState::error("old result"),
1415 ));
1416 assert!(matches!(app.tabs[0], TabState::Loading));
1417 }
1418
1419 #[test]
1420 fn refresh_identity_mismatch_is_discarded() {
1421 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1422 let generation = app.tab_generation;
1423
1424 assert!(!app.apply_refresh(
1425 generation,
1426 &TabId::vendor(VendorId::Openai),
1427 TabState::error("wrong tab"),
1428 ));
1429 assert!(matches!(app.tabs[0], TabState::Loading));
1430 }
1431
1432 #[test]
1433 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
1434 let anthropic = TabId::vendor(VendorId::Anthropic);
1435 let openai = TabId::vendor(VendorId::Openai);
1436 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
1437 let generation = app.tab_generation;
1438 assert!(app.begin_refresh(&anthropic));
1439
1440 app.tabs_meta.swap(0, 1);
1443 app.tabs.swap(0, 1);
1444 assert!(app.apply_refresh(generation, &anthropic, TabState::error("ready")));
1445 assert!(matches!(app.tabs[0], TabState::Loading));
1446 assert!(matches!(&app.tabs[1], TabState::Error { message, .. } if message == "ready"));
1447 assert!(!app.is_refreshing(&anthropic));
1448 }
1449
1450 fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
1451 TabState::Ready(Box::new(ReadyTab {
1452 snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
1453 label: "test".into(),
1454 total_credits: 0.0,
1455 total_usage: 0.0,
1456 usage_daily: 0.0,
1457 usage_weekly: 0.0,
1458 usage_monthly: 0.0,
1459 is_free_tier: false,
1460 limit: None,
1461 limit_remaining: None,
1462 }),
1463 stale: false,
1464 last_error: None,
1465 fetched_at: Some(fetched_at),
1466 display: Default::default(),
1467 }))
1468 }
1469
1470 #[test]
1471 fn refresh_keeps_ready_snapshot_visible_and_suppresses_duplicates() {
1472 let tab = TabId::vendor(VendorId::Openrouter);
1473 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1474 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1475 app.tabs[0] = ready_at(fetched_at);
1476
1477 assert!(app.begin_refresh(&tab));
1478 assert!(
1479 !app.begin_refresh(&tab),
1480 "duplicate request must be suppressed"
1481 );
1482 assert!(app.is_refreshing(&tab));
1483 match &app.tabs[0] {
1484 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1485 other => panic!("ready snapshot disappeared during refresh: {other:?}"),
1486 }
1487 }
1488
1489 #[test]
1490 fn first_refresh_still_uses_loading_until_data_arrives() {
1491 let tab = TabId::vendor(VendorId::Openrouter);
1492 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1493
1494 assert!(app.begin_refresh(&tab));
1495 assert!(app.is_refreshing(&tab));
1496 assert!(matches!(app.tabs[0], TabState::Loading));
1497
1498 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("not signed in"),));
1499 assert!(!app.is_refreshing(&tab));
1500 assert!(
1501 matches!(&app.tabs[0], TabState::Error { message, .. } if message == "not signed in")
1502 );
1503 }
1504
1505 #[test]
1506 fn successful_revalidation_replaces_snapshot_and_clears_indicator() {
1507 let tab = TabId::vendor(VendorId::Openrouter);
1508 let old_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1509 let new_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 1, 0).unwrap();
1510 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1511 app.tabs[0] = ready_at(old_at);
1512
1513 assert!(app.begin_refresh(&tab));
1514 assert!(app.apply_refresh(app.tab_generation, &tab, ready_at(new_at)));
1515 assert!(!app.is_refreshing(&tab));
1516 match &app.tabs[0] {
1517 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(new_at)),
1518 other => panic!("expected replacement snapshot, got {other:?}"),
1519 }
1520 }
1521
1522 #[test]
1523 fn failed_revalidation_preserves_snapshot_with_visible_warning() {
1524 let tab = TabId::vendor(VendorId::Openrouter);
1525 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1526 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1527 app.tabs[0] = ready_at(fetched_at);
1528
1529 assert!(app.begin_refresh(&tab));
1530 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("refresh failed"),));
1531 assert!(!app.is_refreshing(&tab));
1532 match &app.tabs[0] {
1533 TabState::Ready(ready) => {
1534 assert_eq!(ready.fetched_at, Some(fetched_at));
1535 assert!(ready.stale);
1536 assert_eq!(ready.last_error, Some((0, "refresh failed".into())));
1537 }
1538 other => panic!("last successful snapshot was lost: {other:?}"),
1539 }
1540 let sections = crate::tui::panels::sections_for(&app.tabs[0], Utc::now(), 5);
1541 assert!(sections.iter().any(|section| matches!(
1542 section,
1543 crate::tui::panels::Section::Text { label, value }
1544 if label == "Warning" && value == "refresh failed"
1545 )));
1546 }
1547
1548 #[test]
1549 fn old_generation_result_does_not_clear_current_refresh() {
1550 let tab = TabId::vendor(VendorId::Openrouter);
1551 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1552 let old_generation = app.tab_generation;
1553 app.set_tabs(vec![tab.clone()]);
1554 assert!(app.begin_refresh(&tab));
1555
1556 assert!(!app.apply_refresh(old_generation, &tab, TabState::error("old result"),));
1557 assert!(app.is_refreshing(&tab));
1558 assert!(matches!(app.tabs[0], TabState::Loading));
1559 }
1560
1561 #[test]
1562 fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
1563 let anthropic = TabId::vendor(VendorId::Anthropic);
1569 let openai = TabId::vendor(VendorId::Openai);
1570 let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
1571 let generation = app.tab_generation;
1572 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1573
1574 assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
1575 match &app.tabs[0] {
1576 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1577 other => panic!("expected Anthropic tab Ready, got {other:?}"),
1578 }
1579 assert!(matches!(app.tabs[1], TabState::Loading));
1580 }
1581
1582 #[test]
1583 fn select_primary_lands_on_default_account_tab() {
1584 let app = {
1587 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
1588 let mut a = App::with_theme(tabs, Theme::default());
1589 a.select_primary(Some(VendorId::Anthropic));
1590 a
1591 };
1592 assert_eq!(app.active, 0);
1593 assert_eq!(
1594 app.active_tab_id(),
1595 Some(&TabId::vendor(VendorId::Anthropic))
1596 );
1597 }
1598
1599 fn custom_spec(id: &str, enabled: bool) -> CustomProviderConfig {
1600 CustomProviderConfig {
1601 id: id.into(),
1602 name: "My Tool".into(),
1603 short_name: "myt".into(),
1604 enabled,
1605 ..Default::default()
1606 }
1607 }
1608
1609 #[test]
1610 fn custom_providers_get_tabs_after_every_builtin() {
1611 let config = Config {
1612 custom: vec![custom_spec("mytool", true), custom_spec("other", true)],
1613 ..Default::default()
1614 };
1615 let tabs = tabs_from_config(&config);
1616 let builtin_count = config.enabled_vendors().len();
1617 assert_eq!(tabs.len(), builtin_count + 2);
1618 assert!(
1619 tabs[..builtin_count]
1620 .iter()
1621 .all(|t| matches!(t.source, TabSource::Builtin(_)))
1622 );
1623 assert_eq!(tabs[builtin_count], TabId::custom(&config.custom[0]));
1624 assert_eq!(tabs[builtin_count + 1], TabId::custom(&config.custom[1]));
1625 assert_eq!(
1626 tabs[builtin_count].source,
1627 TabSource::Custom {
1628 id: "mytool".into(),
1629 name: "My Tool".into(),
1630 short_name: "myt".into(),
1631 }
1632 );
1633 assert!(tabs[builtin_count].account.is_none());
1634 assert!(!tabs[builtin_count].desktop);
1635 assert_eq!(tabs[builtin_count].vendor_id(), None);
1636 }
1637
1638 #[test]
1639 fn disabled_custom_provider_has_no_tab() {
1640 let config = Config {
1641 custom: vec![custom_spec("mytool", false)],
1642 ..Default::default()
1643 };
1644 let tabs = tabs_from_config(&config);
1645 assert!(
1646 tabs.iter()
1647 .all(|t| matches!(t.source, TabSource::Builtin(_)))
1648 );
1649 assert_eq!(tabs.len(), config.enabled_vendors().len());
1650 }
1651
1652 #[test]
1653 fn custom_tabs_are_listed_by_the_default_overview_but_not_by_a_vendor_filter() {
1654 let spec = custom_spec("mytool", true);
1655 let mut app = App::with_theme(
1656 vec![
1657 TabId::vendor(VendorId::Anthropic),
1658 TabId::custom(&spec),
1659 TabId::vendor(VendorId::Openai),
1660 ],
1661 Theme::default(),
1662 );
1663 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1664
1665 app.overview_vendors = Some(vec![VendorId::Openai, VendorId::Anthropic]);
1667 assert_eq!(app.overview_tabs(), vec![2, 0]);
1668 }
1669
1670 #[test]
1671 fn a_custom_active_tab_has_no_vendor_and_never_staggers() {
1672 let spec = custom_spec("mytool", true);
1673 let tabs = vec![
1674 TabId::vendor(VendorId::Anthropic),
1675 TabId::custom(&spec),
1676 TabId::account("work"),
1677 ];
1678 let mut app = App::with_theme(tabs.clone(), Theme::default());
1679 app.active = 1;
1680 assert_eq!(app.active_vendor(), None);
1681 assert_eq!(app.active_tab_id(), Some(&TabId::custom(&spec)));
1682
1683 app.select_primary(Some(VendorId::Anthropic));
1685 assert_eq!(app.active, 0);
1686
1687 let step = Duration::from_millis(800);
1688 assert_eq!(
1689 refresh_stagger(&tabs, step),
1690 vec![Duration::ZERO, Duration::ZERO, step]
1691 );
1692 }
1693}