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 vendor == VendorId::Openrouter {
212 if config.openrouter.show_default_account || config.openrouter.accounts.is_empty() {
213 tabs.push(TabId::vendor(vendor));
214 }
215 for account in &config.openrouter.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
526async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
527 let vendor = match &tab.source {
528 TabSource::Builtin(vendor) => *vendor,
529 TabSource::Custom { id, .. } => {
530 let spec = config.custom_by_id(id).ok_or_else(|| {
533 AppError::Other(format!("custom provider {id} is not configured"))
534 })?;
535 let api_key = spec.resolve_api_key()?;
536 let cache = crate::cache::Cache::for_vendor_account("custom", id)?;
537 let outcome =
538 crate::custom::fetch_snapshot(client, spec, &api_key, &cache, spec.cache_ttl())
539 .await?;
540 return Ok(outcome.into());
541 }
542 };
543 match vendor {
544 VendorId::Anthropic => {
545 let (creds_target, cache) = match tab.account.as_deref() {
551 Some(label) if tab.desktop => {
552 crate::anthropic::desktop_creds::account_target(config, label)?
553 }
554 Some(label) => config.anthropic.account_target(label)?,
555 None => {
556 let target = match config.anthropic.credentials_path.clone() {
557 Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
558 None => crate::anthropic::creds::CredsTarget::Default(
559 crate::anthropic::creds::default_path().unwrap_or_default(),
560 ),
561 };
562 (target, crate::cache::Cache::for_vendor("anthropic")?)
563 }
564 };
565 let endpoints = crate::anthropic::fetch::Endpoints::default();
566 let outcome = crate::anthropic::fetch_snapshot(
567 client,
568 &creds_target,
569 &cache,
570 &endpoints,
571 DEFAULT_TTL,
572 )
573 .await?;
574 Ok(outcome.map(crate::usage::VendorSnapshot::Anthropic))
575 }
576 VendorId::AnthropicApi => {
577 let key = crate::config::resolve_api_key(
578 "Anthropic_API",
579 &config.anthropic_api.api_key_env,
580 config.anthropic_api.api_key.as_deref(),
581 )?;
582 let cache = crate::cache::Cache::for_vendor("anthropic_api")?;
583 let endpoints = crate::anthropic_api::fetch::Endpoints::default();
584 let outcome = crate::anthropic_api::fetch_snapshot(
585 client,
586 &key,
587 &cache,
588 &endpoints,
589 DEFAULT_TTL,
590 config.anthropic_api.monthly_limit,
591 )
592 .await?;
593 Ok(outcome.into())
594 }
595 VendorId::Openrouter => {
596 let api_key = config.openrouter.resolve_api_key(tab.account.as_deref())?;
597 let cache = match tab.account.as_deref() {
598 Some(label) => crate::cache::Cache::for_vendor_account("openrouter", label)?,
599 None => crate::cache::Cache::for_vendor("openrouter")?,
600 };
601 let endpoints = crate::openrouter::fetch::Endpoints::default();
602 let outcome = crate::openrouter::fetch_snapshot(
603 client,
604 &api_key,
605 &cache,
606 &endpoints,
607 DEFAULT_TTL,
608 )
609 .await?;
610 Ok(outcome.into())
611 }
612 VendorId::Zai => {
613 let api_key = crate::config::resolve_api_key(
614 "Zai",
615 &config.zai.api_key_env,
616 config.zai.api_key.as_deref(),
617 )?;
618 let cache = crate::cache::Cache::for_vendor("zai")?;
619 let endpoints = crate::zai::fetch::Endpoints::default();
620 let outcome = crate::zai::fetch_snapshot(
621 client,
622 &api_key,
623 &cache,
624 &endpoints,
625 DEFAULT_TTL,
626 config.zai.plan_tier.as_deref(),
627 )
628 .await?;
629 Ok(outcome.into())
630 }
631 VendorId::Openai => {
632 let label = tab.account.as_deref();
633 let cache = match label {
634 Some(label) => crate::cache::Cache::for_vendor_account("openai", label)?,
635 None => crate::cache::Cache::for_vendor("openai")?,
636 };
637 let route = || config.openai.fetch_auth_path(label);
638 let endpoints = crate::openai::fetch::Endpoints::default();
639 let outcome = crate::openai::fetch_snapshot_routed(
640 client,
641 route,
642 &cache,
643 &endpoints,
644 DEFAULT_TTL,
645 )
646 .await?;
647 Ok(outcome.into())
648 }
649 VendorId::Copilot => {
650 let token = config.copilot.resolve_token()?;
651 let cache = crate::cache::Cache::for_vendor("copilot")?;
652 let endpoints = crate::copilot::fetch::Endpoints::default();
653 let outcome =
654 crate::copilot::fetch_snapshot(client, &token, &cache, &endpoints, DEFAULT_TTL)
655 .await?;
656 Ok(outcome.into())
657 }
658 VendorId::Deepseek => {
659 let api_key = crate::config::resolve_api_key(
660 "DeepSeek",
661 &config.deepseek.api_key_env,
662 config.deepseek.api_key.as_deref(),
663 )?;
664 let cache = crate::cache::Cache::for_vendor("deepseek")?;
665 let endpoints = crate::deepseek::fetch::Endpoints::default();
666 let outcome =
667 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
668 .await?;
669 Ok(outcome.into())
670 }
671 VendorId::Kimi => {
672 let (auth, endpoints) = crate::kimi::resolve_auth(&config.kimi)?;
673 let cache = crate::cache::Cache::for_vendor("kimi")?;
674 let outcome = crate::kimi::fetch::fetch_snapshot_with_auth(
675 client,
676 &auth,
677 &cache,
678 &endpoints,
679 DEFAULT_TTL,
680 )
681 .await?;
682 Ok(outcome.into())
683 }
684 VendorId::Kilo => {
685 let api_key = crate::config::resolve_api_key(
686 "Kilo",
687 &config.kilo.api_key_env,
688 config.kilo.api_key.as_deref(),
689 )?;
690 let cache = crate::cache::Cache::for_vendor("kilo")?;
691 let endpoints = crate::kilo::fetch::Endpoints::default();
692 let outcome = crate::kilo::fetch_snapshot(
693 client,
694 &api_key,
695 &cache,
696 &endpoints,
697 DEFAULT_TTL,
698 config.kilo.organization_id.as_deref(),
699 )
700 .await?;
701 Ok(outcome.into())
702 }
703 VendorId::Novita => {
704 let api_key = crate::config::resolve_api_key(
705 "Novita",
706 &config.novita.api_key_env,
707 config.novita.api_key.as_deref(),
708 )?;
709 let cache = crate::cache::Cache::for_vendor("novita")?;
710 let endpoints = crate::novita::fetch::Endpoints::default();
711 let outcome =
712 crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
713 .await?;
714 Ok(outcome.into())
715 }
716 VendorId::Moonshot => {
717 let api_key = crate::config::resolve_api_key(
718 "Moonshot",
719 &config.moonshot.api_key_env,
720 config.moonshot.api_key.as_deref(),
721 )?;
722 let cache = crate::cache::Cache::for_vendor("moonshot")?;
723 let (endpoints, currency) =
724 crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
725 let outcome = crate::moonshot::fetch_snapshot(
726 client,
727 &api_key,
728 &cache,
729 &endpoints,
730 DEFAULT_TTL,
731 currency,
732 )
733 .await?;
734 Ok(outcome.into())
735 }
736 VendorId::Grok => {
737 let key = crate::config::resolve_api_key(
738 "Grok",
739 &config.grok.api_key_env,
740 config.grok.api_key.as_deref(),
741 )?;
742 let cache = crate::cache::Cache::for_vendor("grok")?;
743 let endpoints = crate::grok::fetch::Endpoints::default();
744 let outcome = crate::grok::fetch_snapshot(
745 client,
746 &key,
747 &cache,
748 &endpoints,
749 DEFAULT_TTL,
750 config.grok.team_id.as_deref(),
751 )
752 .await?;
753 Ok(outcome.into())
754 }
755 VendorId::Supergrok => {
756 let cache = crate::cache::Cache::for_vendor("supergrok")?;
757 let scope_paths = crate::supergrok::scope::ScopePaths::with_overrides(
758 config.supergrok.auth_path.as_deref(),
759 config.supergrok.config_path.as_deref(),
760 )?;
761 let outcome = crate::supergrok::fetch_snapshot(
762 &config.supergrok.grok_binary,
763 &scope_paths,
764 &cache,
765 DEFAULT_TTL,
766 )
767 .await?;
768 Ok(outcome.into())
769 }
770 VendorId::Antigravity => {
771 let cache = crate::cache::Cache::for_vendor("antigravity")?;
774 let oauth = crate::antigravity::cloud::OauthClient::from_config(
775 config.antigravity.oauth_client_id.as_deref(),
776 config.antigravity.oauth_client_secret.as_deref(),
777 );
778 let outcome =
779 crate::antigravity::fetch_snapshot(client, &cache, DEFAULT_TTL, oauth.as_ref())
780 .await?;
781 Ok(outcome.into())
782 }
783 VendorId::Grokbot => {
784 let creds = crate::grokbot::resolve_credentials(&config.grokbot)?;
787 let cache = crate::cache::Cache::for_vendor("grokbot")?;
788 let endpoints = crate::grokbot::fetch::Endpoints::default();
789 let outcome = crate::grokbot::fetch::fetch_snapshot_with(
790 client,
791 &creds,
792 &cache,
793 &endpoints,
794 DEFAULT_TTL,
795 )
796 .await?;
797 Ok(outcome.into())
798 }
799 VendorId::ModelStudio => {
800 let creds = crate::modelstudio::resolve_credentials(&config.modelstudio)?;
803 let cache = crate::cache::Cache::for_vendor("modelstudio")?;
804 let endpoints =
805 crate::modelstudio::fetch::Endpoints::for_gateway(creds.region, creds.site);
806 let outcome = crate::modelstudio::fetch_snapshot_with(
807 client,
808 &creds,
809 &cache,
810 &endpoints,
811 DEFAULT_TTL,
812 )
813 .await?;
814 Ok(outcome.into())
815 }
816 VendorId::Minimax => {
817 let api_key = crate::config::resolve_api_key(
818 "MiniMax",
819 &config.minimax.api_key_env,
820 config.minimax.api_key.as_deref(),
821 )?;
822 let cache = crate::cache::Cache::for_vendor("minimax")?;
823 let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
824 let outcome =
825 crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
826 .await?;
827 Ok(outcome.into())
828 }
829 VendorId::Cursor => {
830 let cache = crate::cache::Cache::for_vendor("cursor")?;
831 let db_path = config
832 .cursor
833 .db_path
834 .clone()
835 .map(Ok)
836 .unwrap_or_else(crate::cursor::db::default_db_path)?;
837 let agent_auth_path = config
838 .cursor
839 .agent_auth_path
840 .clone()
841 .map(Ok)
842 .unwrap_or_else(crate::cursor::db::default_agent_auth_path)?;
843 let endpoints = crate::cursor::fetch::Endpoints::default();
844 let outcome = crate::cursor::fetch_snapshot(
845 client,
846 &db_path,
847 &agent_auth_path,
848 &cache,
849 &endpoints,
850 DEFAULT_TTL,
851 )
852 .await?;
853 Ok(outcome.into())
854 }
855 VendorId::Kiro => {
856 let cache = crate::cache::Cache::for_vendor("kiro")?;
857 let db_path = config
858 .kiro
859 .db_path
860 .clone()
861 .map(Ok)
862 .unwrap_or_else(crate::kiro::db::default_db_path)?;
863 let outcome =
864 crate::kiro::fetch_snapshot(client, &db_path, &cache, DEFAULT_TTL).await?;
865 Ok(outcome.into())
866 }
867 VendorId::NousResearch => {
868 let store = crate::nous::credentials::CredentialStore::default();
869 let endpoints = crate::nous::fetch::Endpoints::default();
870 let account = crate::nous::fetch::fetch_account_with_refresh(
871 client,
872 &store,
873 &endpoints,
874 Utc::now(),
875 )
876 .await?;
877 Ok(crate::outcome::Outcome::fresh(
879 crate::usage::VendorSnapshot::NousResearch(account),
880 ))
881 }
882 VendorId::OpenCodeGo => {
883 let api_key = crate::config::resolve_api_key(
884 "OpenCode Go",
885 &config.opencode_go.api_key_env,
886 config.opencode_go.api_key.as_deref(),
887 )?;
888 let cache = crate::cache::Cache::for_vendor("opencode-go")?;
889 let endpoints = crate::opencode_go::fetch::Endpoints::default();
890 let outcome = crate::opencode_go::fetch::fetch_snapshot(
891 client,
892 &api_key,
893 &cache,
894 &endpoints,
895 DEFAULT_TTL,
896 )
897 .await?;
898 Ok(outcome.into())
899 }
900 VendorId::CommandCode => {
901 let credential =
902 crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref())?;
903 let cache = crate::cache::Cache::for_vendor("commandcode")?;
904 let endpoints = crate::commandcode::fetch::Endpoints::default();
905 let outcome = crate::commandcode::fetch::fetch_snapshot(
906 client,
907 &credential.token,
908 &cache,
909 &endpoints,
910 DEFAULT_TTL,
911 )
912 .await?;
913 Ok(outcome.into())
914 }
915 VendorId::Ollama => {
916 let api_key = crate::config::resolve_api_key(
917 "Ollama",
918 &config.ollama.api_key_env,
919 config.ollama.api_key.as_deref(),
920 )?;
921 let cache = crate::cache::Cache::for_vendor("ollama")?;
922 let endpoints = crate::ollama::fetch::Endpoints::default();
923 let outcome = crate::ollama::fetch_snapshot(
924 client,
925 &api_key,
926 &config.ollama.plan,
927 &cache,
928 &endpoints,
929 DEFAULT_TTL,
930 )
931 .await?;
932 Ok(outcome.into())
933 }
934 VendorId::OrcaRouter => {
935 let api_key = crate::config::resolve_api_key(
936 "OrcaRouter",
937 &config.orcarouter.api_key_env,
938 config.orcarouter.api_key.as_deref(),
939 )?;
940 let cache = crate::cache::Cache::for_vendor("orcarouter")?;
941 let endpoints = crate::orcarouter::fetch::Endpoints::default();
942 let outcome = crate::orcarouter::fetch_snapshot(
943 client,
944 &api_key,
945 &cache,
946 &endpoints,
947 DEFAULT_TTL,
948 )
949 .await?;
950 Ok(outcome.into())
951 }
952 }
953}
954
955pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
958
959pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
966
967pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
973 let mut anthropic_seen: u32 = 0;
974 tabs.iter()
975 .map(|tab| {
976 if tab.vendor_id() == Some(VendorId::Anthropic) {
977 let delay = step * anthropic_seen;
978 anthropic_seen += 1;
979 delay
980 } else {
981 Duration::ZERO
982 }
983 })
984 .collect()
985}
986
987#[cfg(test)]
988mod tests {
989 use super::*;
990 use chrono::TimeZone;
991
992 #[test]
996 fn refresh_stagger_spaces_out_anthropic_tabs_only() {
997 let step = Duration::from_millis(800);
998 let tabs = vec![
999 TabId::vendor(VendorId::Anthropic), TabId::account("work"),
1001 TabId::account("personal"),
1002 TabId::vendor(VendorId::Openai),
1003 TabId::vendor(VendorId::Zai),
1004 ];
1005 let delays = refresh_stagger(&tabs, step);
1006 assert_eq!(
1007 delays,
1008 vec![
1009 Duration::ZERO, step, step * 2, Duration::ZERO, Duration::ZERO, ]
1015 );
1016 }
1017
1018 #[test]
1019 fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
1020 let tabs = vec![
1022 TabId::vendor(VendorId::Anthropic),
1023 TabId::vendor(VendorId::Openrouter),
1024 ];
1025 assert!(
1026 refresh_stagger(&tabs, Duration::from_millis(800))
1027 .iter()
1028 .all(|d| d.is_zero())
1029 );
1030 }
1031
1032 #[test]
1033 fn select_primary_moves_to_enabled_vendor() {
1034 let mut app = App::with_theme(
1035 vec![
1036 TabId::vendor(VendorId::Anthropic),
1037 TabId::vendor(VendorId::Openrouter),
1038 ],
1039 Theme::default(),
1040 );
1041 app.select_primary(Some(VendorId::Openrouter));
1042 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
1043 }
1044
1045 #[test]
1046 fn select_primary_ignores_disabled_vendor() {
1047 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1048 app.select_primary(Some(VendorId::Openai));
1049 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
1050 }
1051
1052 #[test]
1053 fn nav_ring_wraps_through_the_overview_at_both_ends() {
1054 let mut app = App::with_theme(
1055 vec![
1056 TabId::vendor(VendorId::Anthropic),
1057 TabId::vendor(VendorId::Openai),
1058 ],
1059 Theme::default(),
1060 );
1061 app.overview = true;
1062
1063 app.next_tab(); assert!(!app.overview);
1065 assert_eq!(app.active, 0);
1066 app.next_tab();
1067 assert_eq!(app.active, 1);
1068 app.next_tab(); assert!(app.overview);
1070
1071 app.prev_tab(); assert!(!app.overview);
1073 assert_eq!(app.active, 1);
1074 app.prev_tab();
1075 assert_eq!(app.active, 0);
1076 app.prev_tab(); assert!(app.overview);
1078 }
1079
1080 #[test]
1081 fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
1082 let mut app = App::with_theme(
1083 vec![
1084 TabId::vendor(VendorId::Anthropic),
1085 TabId::vendor(VendorId::Openai),
1086 TabId::vendor(VendorId::Zai),
1087 ],
1088 Theme::default(),
1089 );
1090 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1091
1092 app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
1094 assert_eq!(app.overview_tabs(), vec![2, 0]);
1095
1096 app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
1098 assert_eq!(app.overview_tabs(), vec![1]);
1099 }
1100
1101 fn config_with_accounts(labels: &[&str]) -> Config {
1102 let mut config = Config::default();
1103 config.openai.enabled = false;
1106 config.zai.enabled = false;
1107 config.openrouter.enabled = false;
1108 config.commandcode.enabled = false;
1109 config.anthropic.accounts = labels
1110 .iter()
1111 .map(|l| crate::config::AnthropicAccount {
1112 label: (*l).to_string(),
1113 credentials_path: format!("/creds/{l}.json").into(),
1114 })
1115 .collect();
1116 config
1117 }
1118
1119 #[test]
1120 fn show_default_account_false_hides_the_unnamed_claude_tab() {
1121 let mut config = config_with_accounts(&["work", "personal"]);
1124 config.anthropic.show_default_account = false;
1125 assert_eq!(
1126 tabs_from_config(&config),
1127 vec![TabId::account("work"), TabId::account("personal")]
1128 );
1129
1130 let mut empty = Config::default();
1133 empty.openai.enabled = false;
1134 empty.zai.enabled = false;
1135 empty.openrouter.enabled = false;
1136 empty.commandcode.enabled = false;
1137 empty.anthropic.show_default_account = false;
1138 assert_eq!(
1139 tabs_from_config(&empty),
1140 vec![TabId::vendor(VendorId::Anthropic)]
1141 );
1142 }
1143
1144 #[test]
1145 fn tabs_expand_anthropic_accounts_after_default() {
1146 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
1148 assert_eq!(
1149 tabs,
1150 vec![
1151 TabId::vendor(VendorId::Anthropic),
1152 TabId::account("work"),
1153 TabId::account("personal"),
1154 ]
1155 );
1156 }
1157
1158 #[test]
1159 fn tabs_without_accounts_are_just_enabled_vendors() {
1160 let config = Config::default();
1162 let tabs = tabs_from_config(&config);
1163 let vendors: Vec<VendorId> = tabs.iter().filter_map(TabId::vendor_id).collect();
1164 assert_eq!(vendors, config.enabled_vendors());
1165 assert!(tabs.iter().all(|t| t.account.is_none()));
1166 }
1167
1168 #[test]
1169 fn tabs_expand_openrouter_accounts_without_changing_other_vendors() {
1170 let mut config = Config::default();
1171 config.anthropic.enabled = false;
1172 config.openai.enabled = false;
1173 config.zai.enabled = false;
1174 config.commandcode.enabled = false;
1175 config.openrouter.accounts = vec![
1176 crate::config::OpenRouterAccount {
1177 label: "work".into(),
1178 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1179 api_key: None,
1180 },
1181 crate::config::OpenRouterAccount {
1182 label: "personal".into(),
1183 api_key_env: None,
1184 api_key: Some("personal-key".into()),
1185 },
1186 ];
1187 assert_eq!(
1188 tabs_from_config(&config),
1189 vec![
1190 TabId::vendor(VendorId::Openrouter),
1191 TabId::account_for(VendorId::Openrouter, "work"),
1192 TabId::account_for(VendorId::Openrouter, "personal"),
1193 ]
1194 );
1195 }
1196
1197 #[test]
1198 fn openai_named_accounts_get_their_own_tabs_after_the_default() {
1199 let mut config = Config::default();
1200 config.anthropic.enabled = false;
1201 config.zai.enabled = false;
1202 config.openrouter.enabled = false;
1203 config.commandcode.enabled = false;
1204 config.openai.accounts.push(crate::config::OpenAiAccount {
1205 label: "work".into(),
1206 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1207 });
1208 assert_eq!(
1209 tabs_from_config(&config),
1210 vec![
1211 TabId::vendor(VendorId::Openai),
1212 TabId::account_for(VendorId::Openai, "work"),
1213 ]
1214 );
1215 }
1216
1217 #[test]
1218 fn openrouter_can_hide_default_only_when_named_accounts_exist() {
1219 let mut config = Config::default();
1220 config.anthropic.enabled = false;
1221 config.openai.enabled = false;
1222 config.zai.enabled = false;
1223 config.commandcode.enabled = false;
1224 config.openrouter.show_default_account = false;
1225 assert_eq!(
1226 tabs_from_config(&config),
1227 vec![TabId::vendor(VendorId::Openrouter)]
1228 );
1229
1230 config
1231 .openrouter
1232 .accounts
1233 .push(crate::config::OpenRouterAccount {
1234 label: "work".into(),
1235 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1236 api_key: None,
1237 });
1238 assert_eq!(
1239 tabs_from_config(&config),
1240 vec![TabId::account_for(VendorId::Openrouter, "work")]
1241 );
1242 }
1243
1244 #[test]
1245 fn codex_can_hide_default_only_when_named_accounts_exist() {
1246 let mut config = Config::default();
1247 config.anthropic.enabled = false;
1248 config.zai.enabled = false;
1249 config.openrouter.enabled = false;
1250 config.commandcode.enabled = false;
1251 config.openai.show_default_account = false;
1252 assert_eq!(
1253 tabs_from_config(&config),
1254 vec![TabId::vendor(VendorId::Openai)]
1255 );
1256
1257 config.openai.accounts.push(crate::config::OpenAiAccount {
1258 label: "work".into(),
1259 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1260 });
1261 assert_eq!(
1262 tabs_from_config(&config),
1263 vec![TabId::account_for(VendorId::Openai, "work")]
1264 );
1265 }
1266
1267 #[test]
1268 fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
1269 let td = tempfile::tempdir().unwrap();
1272 for label in ["work", "personal"] {
1273 let dir = td.path().join(label);
1274 std::fs::create_dir_all(&dir).unwrap();
1275 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1276 }
1277 let mut config = Config::default();
1278 config.openai.enabled = false;
1279 config.zai.enabled = false;
1280 config.openrouter.enabled = false;
1281 config.commandcode.enabled = false;
1282 config.anthropic.accounts_dir = Some(td.path().to_path_buf());
1283
1284 let tabs = tabs_from_config(&config);
1285 assert_eq!(
1286 tabs,
1287 vec![
1288 TabId::vendor(VendorId::Anthropic),
1289 TabId::account("personal"), TabId::account("work"),
1291 ]
1292 );
1293 }
1294
1295 #[test]
1296 fn desktop_labels_become_account_tabs_after_cli_accounts() {
1297 let config = config_with_accounts(&["work"]);
1299 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1300 assert_eq!(
1301 tabs,
1302 vec![
1303 TabId::vendor(VendorId::Anthropic),
1304 TabId::account("work"),
1305 TabId::desktop_account("gmail"),
1306 TabId::desktop_account("hotmail"),
1307 ]
1308 );
1309 }
1310
1311 #[test]
1312 fn a_desktop_profile_wins_a_label_collision_with_a_cli_account() {
1313 let config = config_with_accounts(&["gmail", "work"]);
1318 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1319 assert_eq!(
1320 tabs,
1321 vec![
1322 TabId::vendor(VendorId::Anthropic),
1323 TabId::account("work"),
1324 TabId::desktop_account("gmail"),
1325 TabId::desktop_account("hotmail"),
1326 ]
1327 );
1328 }
1329
1330 #[test]
1331 fn desktop_accounts_suppress_the_default_tab_like_named_ones() {
1332 let mut config = config_with_accounts(&[]);
1335 config.cursor.enabled = false;
1336 config.anthropic.show_default_account = false;
1337
1338 assert_eq!(
1341 build_tabs(&config, &[]),
1342 vec![TabId::vendor(VendorId::Anthropic)]
1343 );
1344 assert_eq!(
1346 build_tabs(&config, &["gmail".into()]),
1347 vec![TabId::desktop_account("gmail")]
1348 );
1349 }
1350
1351 #[test]
1352 fn set_tabs_resets_states_and_clamps_selection() {
1353 let mut app = App::with_theme(
1357 tabs_from_config(&config_with_accounts(&["work", "personal"])),
1358 Theme::default(),
1359 );
1360 app.active = 2; app.tabs[0] = TabState::error("old");
1362 let old_tab = app.tabs_meta[0].clone();
1363 assert!(app.begin_refresh(&old_tab));
1364
1365 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
1366 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
1367 assert_eq!(app.active, 0, "selection clamped after shrink");
1368 assert!(matches!(app.tabs[0], TabState::Loading));
1369 assert!(!app.is_refreshing(&old_tab));
1370 }
1371
1372 #[test]
1373 fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
1374 let mut app = App::with_theme(
1375 vec![
1376 TabId::vendor(VendorId::Anthropic),
1377 TabId::vendor(VendorId::Openai),
1378 ],
1379 Theme::default(),
1380 );
1381 app.active = 1;
1382
1383 app.set_tabs(vec![
1384 TabId::vendor(VendorId::Anthropic),
1385 TabId::account("work"),
1386 TabId::vendor(VendorId::Openai),
1387 ]);
1388
1389 assert_eq!(app.active, 2);
1390 assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
1391 }
1392
1393 #[test]
1394 fn refresh_from_old_generation_is_discarded() {
1395 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1396 let old_generation = app.tab_generation;
1397 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
1398
1399 assert!(!app.apply_refresh(
1400 old_generation,
1401 &TabId::vendor(VendorId::Anthropic),
1402 TabState::error("old result"),
1403 ));
1404 assert!(matches!(app.tabs[0], TabState::Loading));
1405 }
1406
1407 #[test]
1408 fn refresh_identity_mismatch_is_discarded() {
1409 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1410 let generation = app.tab_generation;
1411
1412 assert!(!app.apply_refresh(
1413 generation,
1414 &TabId::vendor(VendorId::Openai),
1415 TabState::error("wrong tab"),
1416 ));
1417 assert!(matches!(app.tabs[0], TabState::Loading));
1418 }
1419
1420 #[test]
1421 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
1422 let anthropic = TabId::vendor(VendorId::Anthropic);
1423 let openai = TabId::vendor(VendorId::Openai);
1424 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
1425 let generation = app.tab_generation;
1426 assert!(app.begin_refresh(&anthropic));
1427
1428 app.tabs_meta.swap(0, 1);
1431 app.tabs.swap(0, 1);
1432 assert!(app.apply_refresh(generation, &anthropic, TabState::error("ready")));
1433 assert!(matches!(app.tabs[0], TabState::Loading));
1434 assert!(matches!(&app.tabs[1], TabState::Error { message, .. } if message == "ready"));
1435 assert!(!app.is_refreshing(&anthropic));
1436 }
1437
1438 fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
1439 TabState::Ready(Box::new(ReadyTab {
1440 snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
1441 label: "test".into(),
1442 total_credits: 0.0,
1443 total_usage: 0.0,
1444 usage_daily: 0.0,
1445 usage_weekly: 0.0,
1446 usage_monthly: 0.0,
1447 is_free_tier: false,
1448 limit: None,
1449 limit_remaining: None,
1450 }),
1451 stale: false,
1452 last_error: None,
1453 fetched_at: Some(fetched_at),
1454 display: Default::default(),
1455 }))
1456 }
1457
1458 #[test]
1459 fn refresh_keeps_ready_snapshot_visible_and_suppresses_duplicates() {
1460 let tab = TabId::vendor(VendorId::Openrouter);
1461 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1462 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1463 app.tabs[0] = ready_at(fetched_at);
1464
1465 assert!(app.begin_refresh(&tab));
1466 assert!(
1467 !app.begin_refresh(&tab),
1468 "duplicate request must be suppressed"
1469 );
1470 assert!(app.is_refreshing(&tab));
1471 match &app.tabs[0] {
1472 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1473 other => panic!("ready snapshot disappeared during refresh: {other:?}"),
1474 }
1475 }
1476
1477 #[test]
1478 fn first_refresh_still_uses_loading_until_data_arrives() {
1479 let tab = TabId::vendor(VendorId::Openrouter);
1480 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1481
1482 assert!(app.begin_refresh(&tab));
1483 assert!(app.is_refreshing(&tab));
1484 assert!(matches!(app.tabs[0], TabState::Loading));
1485
1486 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("not signed in"),));
1487 assert!(!app.is_refreshing(&tab));
1488 assert!(
1489 matches!(&app.tabs[0], TabState::Error { message, .. } if message == "not signed in")
1490 );
1491 }
1492
1493 #[test]
1494 fn successful_revalidation_replaces_snapshot_and_clears_indicator() {
1495 let tab = TabId::vendor(VendorId::Openrouter);
1496 let old_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1497 let new_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 1, 0).unwrap();
1498 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1499 app.tabs[0] = ready_at(old_at);
1500
1501 assert!(app.begin_refresh(&tab));
1502 assert!(app.apply_refresh(app.tab_generation, &tab, ready_at(new_at)));
1503 assert!(!app.is_refreshing(&tab));
1504 match &app.tabs[0] {
1505 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(new_at)),
1506 other => panic!("expected replacement snapshot, got {other:?}"),
1507 }
1508 }
1509
1510 #[test]
1511 fn failed_revalidation_preserves_snapshot_with_visible_warning() {
1512 let tab = TabId::vendor(VendorId::Openrouter);
1513 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1514 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1515 app.tabs[0] = ready_at(fetched_at);
1516
1517 assert!(app.begin_refresh(&tab));
1518 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("refresh failed"),));
1519 assert!(!app.is_refreshing(&tab));
1520 match &app.tabs[0] {
1521 TabState::Ready(ready) => {
1522 assert_eq!(ready.fetched_at, Some(fetched_at));
1523 assert!(ready.stale);
1524 assert_eq!(ready.last_error, Some((0, "refresh failed".into())));
1525 }
1526 other => panic!("last successful snapshot was lost: {other:?}"),
1527 }
1528 let sections = crate::tui::panels::sections_for(&app.tabs[0], Utc::now(), 5);
1529 assert!(sections.iter().any(|section| matches!(
1530 section,
1531 crate::tui::panels::Section::Text { label, value }
1532 if label == "Warning" && value == "refresh failed"
1533 )));
1534 }
1535
1536 #[test]
1537 fn old_generation_result_does_not_clear_current_refresh() {
1538 let tab = TabId::vendor(VendorId::Openrouter);
1539 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1540 let old_generation = app.tab_generation;
1541 app.set_tabs(vec![tab.clone()]);
1542 assert!(app.begin_refresh(&tab));
1543
1544 assert!(!app.apply_refresh(old_generation, &tab, TabState::error("old result"),));
1545 assert!(app.is_refreshing(&tab));
1546 assert!(matches!(app.tabs[0], TabState::Loading));
1547 }
1548
1549 #[test]
1550 fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
1551 let anthropic = TabId::vendor(VendorId::Anthropic);
1557 let openai = TabId::vendor(VendorId::Openai);
1558 let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
1559 let generation = app.tab_generation;
1560 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1561
1562 assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
1563 match &app.tabs[0] {
1564 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1565 other => panic!("expected Anthropic tab Ready, got {other:?}"),
1566 }
1567 assert!(matches!(app.tabs[1], TabState::Loading));
1568 }
1569
1570 #[test]
1571 fn select_primary_lands_on_default_account_tab() {
1572 let app = {
1575 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
1576 let mut a = App::with_theme(tabs, Theme::default());
1577 a.select_primary(Some(VendorId::Anthropic));
1578 a
1579 };
1580 assert_eq!(app.active, 0);
1581 assert_eq!(
1582 app.active_tab_id(),
1583 Some(&TabId::vendor(VendorId::Anthropic))
1584 );
1585 }
1586
1587 fn custom_spec(id: &str, enabled: bool) -> CustomProviderConfig {
1588 CustomProviderConfig {
1589 id: id.into(),
1590 name: "My Tool".into(),
1591 short_name: "myt".into(),
1592 enabled,
1593 ..Default::default()
1594 }
1595 }
1596
1597 #[test]
1598 fn custom_providers_get_tabs_after_every_builtin() {
1599 let config = Config {
1600 custom: vec![custom_spec("mytool", true), custom_spec("other", true)],
1601 ..Default::default()
1602 };
1603 let tabs = tabs_from_config(&config);
1604 let builtin_count = config.enabled_vendors().len();
1605 assert_eq!(tabs.len(), builtin_count + 2);
1606 assert!(
1607 tabs[..builtin_count]
1608 .iter()
1609 .all(|t| matches!(t.source, TabSource::Builtin(_)))
1610 );
1611 assert_eq!(tabs[builtin_count], TabId::custom(&config.custom[0]));
1612 assert_eq!(tabs[builtin_count + 1], TabId::custom(&config.custom[1]));
1613 assert_eq!(
1614 tabs[builtin_count].source,
1615 TabSource::Custom {
1616 id: "mytool".into(),
1617 name: "My Tool".into(),
1618 short_name: "myt".into(),
1619 }
1620 );
1621 assert!(tabs[builtin_count].account.is_none());
1622 assert!(!tabs[builtin_count].desktop);
1623 assert_eq!(tabs[builtin_count].vendor_id(), None);
1624 }
1625
1626 #[test]
1627 fn disabled_custom_provider_has_no_tab() {
1628 let config = Config {
1629 custom: vec![custom_spec("mytool", false)],
1630 ..Default::default()
1631 };
1632 let tabs = tabs_from_config(&config);
1633 assert!(
1634 tabs.iter()
1635 .all(|t| matches!(t.source, TabSource::Builtin(_)))
1636 );
1637 assert_eq!(tabs.len(), config.enabled_vendors().len());
1638 }
1639
1640 #[test]
1641 fn custom_tabs_are_listed_by_the_default_overview_but_not_by_a_vendor_filter() {
1642 let spec = custom_spec("mytool", true);
1643 let mut app = App::with_theme(
1644 vec![
1645 TabId::vendor(VendorId::Anthropic),
1646 TabId::custom(&spec),
1647 TabId::vendor(VendorId::Openai),
1648 ],
1649 Theme::default(),
1650 );
1651 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1652
1653 app.overview_vendors = Some(vec![VendorId::Openai, VendorId::Anthropic]);
1655 assert_eq!(app.overview_tabs(), vec![2, 0]);
1656 }
1657
1658 #[test]
1659 fn a_custom_active_tab_has_no_vendor_and_never_staggers() {
1660 let spec = custom_spec("mytool", true);
1661 let tabs = vec![
1662 TabId::vendor(VendorId::Anthropic),
1663 TabId::custom(&spec),
1664 TabId::account("work"),
1665 ];
1666 let mut app = App::with_theme(tabs.clone(), Theme::default());
1667 app.active = 1;
1668 assert_eq!(app.active_vendor(), None);
1669 assert_eq!(app.active_tab_id(), Some(&TabId::custom(&spec)));
1670
1671 app.select_primary(Some(VendorId::Anthropic));
1673 assert_eq!(app.active, 0);
1674
1675 let step = Duration::from_millis(800);
1676 assert_eq!(
1677 refresh_stagger(&tabs, step),
1678 vec![Duration::ZERO, Duration::ZERO, step]
1679 );
1680 }
1681}