1use std::collections::HashSet;
4use std::time::Duration;
5
6use chrono::Utc;
7use ratatui::layout::Rect;
8use reqwest::Client;
9
10use crate::cache::DEFAULT_TTL;
11use crate::config::{Config, CustomProviderConfig};
12use crate::error::{AppError, Result};
13use crate::theme::Theme;
14use crate::tui::settings::SettingsRow;
15use crate::vendor::{VendorId, VendorOutcome};
16
17#[derive(Debug, Clone)]
23pub enum TabState {
24 Loading,
25 Ready(Box<ReadyTab>),
26 Error {
27 message: String,
28 plan: Option<String>,
32 },
33}
34
35#[derive(Debug, Clone)]
36pub struct ReadyTab {
37 pub snapshot: crate::usage::VendorSnapshot,
38 pub stale: bool,
39 pub last_error: Option<(u16, String)>,
40 pub fetched_at: Option<chrono::DateTime<chrono::Utc>>,
45 pub display: crate::balance::DisplayPrefs,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
57pub enum TabSource {
58 Builtin(VendorId),
59 Custom {
60 id: String,
61 name: String,
62 short_name: String,
63 },
64}
65
66impl TabState {
67 pub fn error(message: impl AsRef<str>) -> Self {
68 Self::error_with_plan(message, None)
69 }
70
71 pub fn error_with_plan(message: impl AsRef<str>, plan: Option<String>) -> Self {
72 let plan = plan.and_then(|plan| {
73 let cleaned = crate::display::sanitize_untrusted_field(plan.trim());
74 if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("unknown") {
75 None
76 } else {
77 Some(cleaned)
78 }
79 });
80 Self::Error {
81 message: message.as_ref().to_string(),
82 plan,
83 }
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Hash)]
94pub struct TabId {
95 pub source: TabSource,
96 pub account: Option<String>,
97 pub desktop: bool,
98}
99
100impl TabId {
101 pub fn vendor(vendor: VendorId) -> Self {
103 Self {
104 source: TabSource::Builtin(vendor),
105 account: None,
106 desktop: false,
107 }
108 }
109
110 pub fn account(label: impl Into<String>) -> Self {
112 Self::account_for(VendorId::Anthropic, label)
113 }
114
115 pub fn account_for(vendor: VendorId, label: impl Into<String>) -> Self {
117 Self {
118 source: TabSource::Builtin(vendor),
119 account: Some(label.into()),
120 desktop: false,
121 }
122 }
123
124 pub fn desktop_account(label: impl Into<String>) -> Self {
127 Self {
128 source: TabSource::Builtin(VendorId::Anthropic),
129 account: Some(label.into()),
130 desktop: true,
131 }
132 }
133
134 pub fn custom(spec: &CustomProviderConfig) -> Self {
136 Self {
137 source: TabSource::Custom {
138 id: spec.id.clone(),
139 name: spec.name.clone(),
140 short_name: spec.short_name.clone(),
141 },
142 account: None,
143 desktop: false,
144 }
145 }
146
147 pub fn vendor_id(&self) -> Option<VendorId> {
150 match &self.source {
151 TabSource::Builtin(vendor) => Some(*vendor),
152 TabSource::Custom { .. } => None,
153 }
154 }
155}
156
157pub fn tabs_from_config(config: &Config) -> Vec<TabId> {
168 build_tabs(config, &[])
169}
170
171pub fn tabs_with_desktop(config: &Config) -> Vec<TabId> {
175 build_tabs(config, &desktop_profile_labels(config))
176}
177
178fn vendors_for_tabs(config: &Config) -> Vec<VendorId> {
181 let enabled = config.enabled_vendors();
182 let Some(wanted) = config.ui.overview_vendors.as_ref() else {
183 return enabled;
184 };
185 let mut ordered = Vec::with_capacity(enabled.len());
186 for vendor in wanted {
187 if enabled.contains(vendor) && !ordered.contains(vendor) {
188 ordered.push(*vendor);
189 }
190 }
191 for vendor in enabled {
192 if !ordered.contains(&vendor) {
193 ordered.push(vendor);
194 }
195 }
196 ordered
197}
198
199fn build_tabs(config: &Config, desktop_labels: &[String]) -> Vec<TabId> {
212 let desktop_set: HashSet<&str> = desktop_labels.iter().map(String::as_str).collect();
213 let mut tabs = Vec::new();
214 for vendor in vendors_for_tabs(config) {
215 if vendor == VendorId::Anthropic {
216 let accounts: Vec<_> = config
217 .anthropic
218 .all_accounts()
219 .into_iter()
220 .filter(|a| !desktop_set.contains(a.label.as_str()))
221 .collect();
222 if config.anthropic.show_default_account
226 || (accounts.is_empty() && desktop_labels.is_empty())
227 {
228 tabs.push(TabId::vendor(vendor));
229 }
230 for acct in accounts {
231 tabs.push(TabId::account(acct.label));
232 }
233 for label in desktop_labels {
234 tabs.push(TabId::desktop_account(label.clone()));
235 }
236 } else if let Some(accounts) = config.api_key_accounts(vendor) {
237 if config.show_default_api_key_account(vendor) || accounts.is_empty() {
238 tabs.push(TabId::vendor(vendor));
239 }
240 for account in accounts {
241 tabs.push(TabId::account_for(vendor, account.label.clone()));
242 }
243 } else if vendor == VendorId::Openai {
244 if config.openai.show_default_account || config.openai.accounts.is_empty() {
245 tabs.push(TabId::vendor(vendor));
246 }
247 for account in &config.openai.accounts {
248 tabs.push(TabId::account_for(vendor, account.label.clone()));
249 }
250 } else if vendor == VendorId::Copilot {
251 if config.copilot.show_default_account || config.copilot.accounts.is_empty() {
252 tabs.push(TabId::vendor(vendor));
253 }
254 for account in &config.copilot.accounts {
255 tabs.push(TabId::account_for(vendor, account.label.clone()));
256 }
257 } else {
258 tabs.push(TabId::vendor(vendor));
259 }
260 }
261 for spec in config.enabled_custom() {
263 tabs.push(TabId::custom(spec));
264 }
265 tabs
266}
267
268#[cfg(target_os = "macos")]
272fn desktop_profile_labels(config: &Config) -> Vec<String> {
273 let Ok(paths) = crate::claude_desktop::Paths::resolve(&config.anthropic) else {
274 return Vec::new();
275 };
276 if !paths.available() {
277 return Vec::new();
278 }
279 crate::claude_desktop::load_profiles(&paths.profiles_dir)
280 .into_iter()
281 .filter(|p| p.has_credentials)
282 .map(|p| p.label)
283 .collect()
284}
285
286#[cfg(not(target_os = "macos"))]
287fn desktop_profile_labels(_config: &Config) -> Vec<String> {
288 Vec::new()
289}
290
291#[derive(Debug, Default, Clone)]
296pub struct HitTargets {
297 pub nav_entries: Vec<(NavTarget, Rect)>,
299 pub footer_actions: Vec<(FooterAction, Rect)>,
301 pub settings_rows: Vec<(SettingsRow, Rect)>,
304}
305
306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub enum NavTarget {
309 Overview,
310 Tab(usize),
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub enum FooterAction {
316 Refresh,
317 RefreshAll,
318 Settings,
319 Quit,
320}
321
322#[derive(Debug)]
323pub struct App {
324 pub tabs_meta: Vec<TabId>,
325 pub active: usize,
326 pub tabs: Vec<TabState>,
327 refreshing_tabs: HashSet<TabId>,
330 pub tab_generation: u64,
334 pub overview: bool,
337 pub overview_vendors: Option<Vec<VendorId>>,
339 pub theme: Theme,
340 pub quit: bool,
341 pub settings: Option<crate::tui::settings::SettingsState>,
343 pub context_enabled: bool,
346 pub context_generation: u64,
349 pub context: Option<crate::tui::context::ContextState>,
351 pub vendor_box: crate::config::VendorBoxStyle,
353 pub hit: std::rc::Rc<std::cell::RefCell<HitTargets>>,
355}
356
357impl App {
358 pub fn new(tabs_meta: Vec<TabId>) -> Self {
359 Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
362 }
363
364 pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
370 let n = tabs_meta.len();
371 Self {
372 tabs_meta,
373 active: 0,
374 tabs: vec![TabState::Loading; n],
375 refreshing_tabs: HashSet::new(),
376 tab_generation: 0,
377 overview: false,
378 overview_vendors: None,
379 theme,
380 quit: false,
381 settings: None,
382 context_enabled: false,
383 context_generation: 0,
384 context: None,
385 vendor_box: crate::config::VendorBoxStyle::Sidebar,
386 hit: std::rc::Rc::new(std::cell::RefCell::new(HitTargets::default())),
387 }
388 }
389
390 pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
394 let mut app = Self::new(tabs_meta);
395 if primary.is_some() {
398 app.select_primary(primary);
399 } else {
400 app.overview = true;
401 }
402 app
403 }
404
405 pub fn active_tab_id(&self) -> Option<&TabId> {
406 self.tabs_meta.get(self.active)
407 }
408
409 pub fn active_vendor(&self) -> Option<VendorId> {
410 self.tabs_meta.get(self.active).and_then(TabId::vendor_id)
411 }
412
413 pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
420 let selected = self.active_tab_id().cloned();
421 let fallback = self.active.min(tabs_meta.len().saturating_sub(1));
422 self.tab_generation = self.tab_generation.wrapping_add(1);
423 self.active = selected
424 .as_ref()
425 .and_then(|tab| tabs_meta.iter().position(|candidate| candidate == tab))
426 .unwrap_or(fallback);
427 self.tabs = vec![TabState::Loading; tabs_meta.len()];
428 self.tabs_meta = tabs_meta;
429 self.refreshing_tabs.clear();
430 }
431
432 pub fn begin_refresh(&mut self, tab: &TabId) -> bool {
436 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
437 return false;
438 };
439 if !self.refreshing_tabs.insert(tab.clone()) {
440 return false;
441 }
442 if !matches!(self.tabs[index], TabState::Ready(_)) {
443 self.tabs[index] = TabState::Loading;
444 }
445 true
446 }
447
448 pub fn is_refreshing(&self, tab: &TabId) -> bool {
449 self.refreshing_tabs.contains(tab)
450 }
451
452 pub fn tab_is_refreshing(&self, index: usize) -> bool {
453 self.tabs_meta
454 .get(index)
455 .is_some_and(|tab| self.is_refreshing(tab))
456 }
457
458 pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
463 if generation != self.tab_generation {
464 return false;
465 }
466 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
467 return false;
468 };
469 let was_refreshing = self.refreshing_tabs.remove(tab);
470 if was_refreshing
474 && let TabState::Ready(ready) = &mut self.tabs[index]
475 && let TabState::Error { message, .. } = state
476 {
477 ready.stale = true;
478 ready.last_error = Some((0, message));
479 } else {
480 self.tabs[index] = state;
481 }
482 true
483 }
484
485 pub fn select_primary(&mut self, primary: Option<VendorId>) {
488 if let Some(p) = primary
489 && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor_id() == Some(p))
490 {
491 self.active = idx;
492 self.overview = false;
493 }
494 }
495
496 pub fn next_tab(&mut self) {
499 if self.overview {
500 if !self.tabs_meta.is_empty() {
501 self.overview = false;
502 self.active = 0;
503 }
504 } else if self.active + 1 < self.tabs_meta.len() {
505 self.active += 1;
506 } else {
507 self.overview = true;
508 }
509 }
510
511 pub fn prev_tab(&mut self) {
512 if self.overview {
513 if !self.tabs_meta.is_empty() {
514 self.overview = false;
515 self.active = self.tabs_meta.len() - 1;
516 }
517 } else if self.active > 0 {
518 self.active -= 1;
519 } else {
520 self.overview = true;
521 }
522 }
523
524 pub fn select_overview(&mut self) {
526 self.overview = true;
527 }
528
529 pub fn select_tab(&mut self, index: usize) {
531 if index < self.tabs_meta.len() {
532 self.active = index;
533 self.overview = false;
534 }
535 }
536
537 pub fn nav_from_target(&mut self, target: NavTarget) {
539 match target {
540 NavTarget::Overview => self.select_overview(),
541 NavTarget::Tab(index) => self.select_tab(index),
542 }
543 }
544
545 pub fn overview_tabs(&self) -> Vec<usize> {
551 match &self.overview_vendors {
552 None => (0..self.tabs_meta.len()).collect(),
553 Some(wanted) => {
554 let mut seen = HashSet::new();
555 wanted
556 .iter()
557 .filter(|vendor| seen.insert(**vendor))
558 .flat_map(|v| {
559 self.tabs_meta
560 .iter()
561 .enumerate()
562 .filter(move |(_, t)| t.vendor_id() == Some(*v))
563 .map(|(i, _)| i)
564 })
565 .collect()
566 }
567 }
568 }
569}
570
571pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
573 match build_outcome(client, config, tab).await {
574 Ok(outcome) => {
575 let now = Utc::now();
580 let off_the_wire = outcome.off_the_wire();
581 let fetched_at = outcome
582 .cache_age
583 .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
584 let state = TabState::Ready(Box::new(ReadyTab {
585 snapshot: outcome.snapshot,
586 stale: outcome.stale,
587 last_error: outcome.last_error.map(|(code, message)| {
588 (code, crate::display::sanitize_untrusted_field(&message))
589 }),
590 fetched_at,
591 display: match &tab.source {
592 TabSource::Builtin(vendor) => config.display_prefs(*vendor),
593 TabSource::Custom { .. } => crate::balance::DisplayPrefs::default(),
596 },
597 }));
598 if off_the_wire
604 && config.notifications.enabled
605 && let Some(input) = crate::notify::RefreshInput::from_tab(tab, &state, now)
606 {
607 let _ = crate::notify::run(input, config.notifications.threshold).await;
608 }
609 state
610 }
611 Err(e) => TabState::error_with_plan(
612 crate::display::sanitize_untrusted_field(&e.user_message()),
613 e.plan().map(str::to_string),
614 ),
615 }
616}
617
618fn api_key_and_cache(
622 config: &Config,
623 vendor: VendorId,
624 account: Option<&str>,
625) -> Result<(String, crate::cache::Cache)> {
626 let api_key = config.resolve_account_api_key_for(vendor, account)?;
627 let cache = match account {
628 Some(label) => crate::cache::Cache::for_vendor_account(vendor.slug(), label)?,
629 None => crate::cache::Cache::for_vendor(vendor.slug())?,
630 };
631 Ok((api_key, cache))
632}
633
634async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
635 let vendor = match &tab.source {
636 TabSource::Builtin(vendor) => *vendor,
637 TabSource::Custom { id, .. } => {
638 let spec = config.custom_by_id(id).ok_or_else(|| {
641 AppError::Other(format!("custom provider {id} is not configured"))
642 })?;
643 let api_key = spec.resolve_api_key()?;
644 let cache = crate::cache::Cache::for_vendor_account("custom", id)?;
645 let outcome =
646 crate::custom::fetch_snapshot(client, spec, &api_key, &cache, spec.cache_ttl())
647 .await?;
648 return Ok(outcome.into());
649 }
650 };
651 match vendor {
652 VendorId::Anthropic => {
653 let (creds_target, cache) = match tab.account.as_deref() {
659 Some(label) if tab.desktop => {
660 crate::anthropic::desktop_creds::account_target(config, label)?
661 }
662 Some(label) => config.anthropic.account_target(label)?,
663 None => (
664 config.anthropic.default_creds_target(),
665 crate::cache::Cache::for_vendor("anthropic")?,
666 ),
667 };
668 let endpoints = crate::anthropic::fetch::Endpoints::default();
669 let outcome = crate::anthropic::fetch_snapshot(
670 client,
671 &creds_target,
672 &cache,
673 &endpoints,
674 DEFAULT_TTL,
675 )
676 .await?;
677 Ok(outcome.map(crate::usage::VendorSnapshot::Anthropic))
678 }
679 VendorId::AnthropicApi => {
680 let key = crate::config::resolve_api_key(
681 "Anthropic_API",
682 &config.anthropic_api.api_key_env,
683 config.anthropic_api.api_key.as_deref(),
684 )?;
685 let cache = crate::cache::Cache::for_vendor("anthropic_api")?;
686 let endpoints = crate::anthropic_api::fetch::Endpoints::default();
687 let outcome = crate::anthropic_api::fetch_snapshot(
688 client,
689 &key,
690 &cache,
691 &endpoints,
692 DEFAULT_TTL,
693 config.anthropic_api.monthly_limit,
694 )
695 .await?;
696 Ok(outcome.into())
697 }
698 VendorId::Openrouter => {
699 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
700 let management_key = config.openrouter_management_key(tab.account.as_deref());
701 let endpoints = crate::openrouter::fetch::Endpoints::default();
702 let outcome = crate::openrouter::fetch_snapshot(
703 client,
704 &api_key,
705 management_key.as_deref(),
706 &cache,
707 &endpoints,
708 DEFAULT_TTL,
709 )
710 .await?;
711 Ok(outcome.into())
712 }
713 VendorId::Zai => {
714 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
715 let endpoints = crate::zai::fetch::Endpoints::default();
716 let outcome = crate::zai::fetch_snapshot(
717 client,
718 &api_key,
719 &cache,
720 &endpoints,
721 DEFAULT_TTL,
722 config.zai.plan_tier.as_deref(),
723 )
724 .await?;
725 Ok(outcome.into())
726 }
727 VendorId::Openai => {
728 let label = tab.account.as_deref();
729 let cache = match label {
730 Some(label) => crate::cache::Cache::for_vendor_account("openai", label)?,
731 None => crate::cache::Cache::for_vendor("openai")?,
732 };
733 let route = || config.openai.fetch_auth_path(label);
734 let endpoints = crate::openai::fetch::Endpoints::default();
735 let outcome = crate::openai::fetch_snapshot_routed(
736 client,
737 route,
738 &cache,
739 &endpoints,
740 DEFAULT_TTL,
741 )
742 .await?;
743 Ok(outcome.into())
744 }
745 VendorId::Copilot => {
746 let label = tab.account.as_deref();
747 let token = config.copilot.resolve_token(label)?;
748 let cache = match label {
749 Some(label) => crate::cache::Cache::for_vendor_account("copilot", label)?,
750 None => crate::cache::Cache::for_vendor("copilot")?,
751 };
752 let endpoints = crate::copilot::fetch::Endpoints::default();
753 let outcome =
754 crate::copilot::fetch_snapshot(client, &token, &cache, &endpoints, DEFAULT_TTL)
755 .await?;
756 Ok(outcome.into())
757 }
758 VendorId::Deepseek => {
759 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
760 let endpoints = crate::deepseek::fetch::Endpoints::default();
761 let outcome =
762 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
763 .await?;
764 Ok(outcome.into())
765 }
766 VendorId::Deepinfra => {
767 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
768 let endpoints = crate::deepinfra::fetch::Endpoints::default();
769 let outcome = crate::deepinfra::fetch::fetch_snapshot(
770 client,
771 &api_key,
772 &cache,
773 &endpoints,
774 DEFAULT_TTL,
775 )
776 .await?;
777 Ok(outcome.into())
778 }
779 VendorId::Kimi => {
780 let (auth, endpoints) = crate::kimi::resolve_auth(&config.kimi)?;
781 let cache = crate::cache::Cache::for_vendor("kimi")?;
782 let outcome = crate::kimi::fetch::fetch_snapshot_with_auth(
783 client,
784 &auth,
785 &cache,
786 &endpoints,
787 DEFAULT_TTL,
788 )
789 .await?;
790 Ok(outcome.into())
791 }
792 VendorId::Kilo => {
793 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
794 let endpoints = crate::kilo::fetch::Endpoints::default();
795 let outcome = crate::kilo::fetch_snapshot(
796 client,
797 &api_key,
798 &cache,
799 &endpoints,
800 DEFAULT_TTL,
801 config.kilo.organization_id.as_deref(),
802 )
803 .await?;
804 Ok(outcome.into())
805 }
806 VendorId::Novita => {
807 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
808 let endpoints = crate::novita::fetch::Endpoints::default();
809 let outcome =
810 crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
811 .await?;
812 Ok(outcome.into())
813 }
814 VendorId::Moonshot => {
815 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
816 let (endpoints, currency) =
817 crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
818 let outcome = crate::moonshot::fetch_snapshot(
819 client,
820 &api_key,
821 &cache,
822 &endpoints,
823 DEFAULT_TTL,
824 currency,
825 )
826 .await?;
827 Ok(outcome.into())
828 }
829 VendorId::Grok => {
830 let (key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
831 let endpoints = crate::grok::fetch::Endpoints::default();
832 let outcome = crate::grok::fetch_snapshot(
833 client,
834 &key,
835 &cache,
836 &endpoints,
837 DEFAULT_TTL,
838 config.grok.team_id.as_deref(),
839 )
840 .await?;
841 Ok(outcome.into())
842 }
843 VendorId::Supergrok => {
844 let cache = crate::cache::Cache::for_vendor("supergrok")?;
845 let scope_paths = crate::supergrok::scope::ScopePaths::with_overrides(
846 config.supergrok.auth_path.as_deref(),
847 config.supergrok.config_path.as_deref(),
848 )?;
849 let outcome = crate::supergrok::fetch_snapshot(
850 &config.supergrok.grok_binary,
851 &scope_paths,
852 &cache,
853 DEFAULT_TTL,
854 )
855 .await?;
856 Ok(outcome.into())
857 }
858 VendorId::Antigravity => {
859 let cache = crate::cache::Cache::for_vendor("antigravity")?;
862 let oauth = crate::antigravity::cloud::OauthClient::from_config(
863 config.antigravity.oauth_client_id.as_deref(),
864 config.antigravity.oauth_client_secret.as_deref(),
865 );
866 let outcome = crate::antigravity::fetch_snapshot(
867 client,
868 &cache,
869 DEFAULT_TTL,
870 oauth.as_ref(),
871 config.antigravity.ide_language_server,
872 )
873 .await?;
874 Ok(outcome.into())
875 }
876 VendorId::Grokbot => {
877 let creds = crate::grokbot::resolve_credentials(&config.grokbot)?;
880 let cache = crate::cache::Cache::for_vendor("grokbot")?;
881 let endpoints = crate::grokbot::fetch::Endpoints::default();
882 let outcome = crate::grokbot::fetch::fetch_snapshot_with(
883 client,
884 &creds,
885 &cache,
886 &endpoints,
887 DEFAULT_TTL,
888 )
889 .await?;
890 Ok(outcome.into())
891 }
892 VendorId::ModelStudio => {
893 let creds = crate::modelstudio::resolve_credentials(&config.modelstudio)?;
896 let cache = crate::cache::Cache::for_vendor("modelstudio")?;
897 let endpoints =
898 crate::modelstudio::fetch::Endpoints::for_gateway(creds.region, creds.site);
899 let outcome = crate::modelstudio::fetch_snapshot_with(
900 client,
901 &creds,
902 &cache,
903 &endpoints,
904 DEFAULT_TTL,
905 )
906 .await?;
907 Ok(outcome.into())
908 }
909 VendorId::MuseCode => {
910 let cache = crate::cache::Cache::for_vendor("muse-code")?;
911 Ok(crate::muse::fetch::fetch_snapshot(&config.muse, &cache)
912 .await?
913 .into())
914 }
915 VendorId::Devin => {
916 let cache = crate::cache::Cache::for_vendor("devin")?;
917 let outcome =
918 crate::devin::fetch::fetch_snapshot(&config.devin, &cache, DEFAULT_TTL).await?;
919 Ok(outcome.into())
920 }
921 VendorId::Minimax => {
922 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
923 let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
924 let outcome =
925 crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
926 .await?;
927 Ok(outcome.into())
928 }
929 VendorId::Cursor => {
930 let cache = crate::cache::Cache::for_vendor("cursor")?;
931 let db_path = config
932 .cursor
933 .db_path
934 .clone()
935 .map(Ok)
936 .unwrap_or_else(crate::cursor::db::default_db_path)?;
937 let agent_auth_path = config
938 .cursor
939 .agent_auth_path
940 .clone()
941 .map(Ok)
942 .unwrap_or_else(crate::cursor::db::default_agent_auth_path)?;
943 let endpoints = crate::cursor::fetch::Endpoints::default();
944 let outcome = crate::cursor::fetch_snapshot(
945 client,
946 &db_path,
947 &agent_auth_path,
948 &cache,
949 &endpoints,
950 DEFAULT_TTL,
951 )
952 .await?;
953 Ok(outcome.into())
954 }
955 VendorId::Kiro => {
956 let cache = crate::cache::Cache::for_vendor("kiro")?;
957 let db_path = config
958 .kiro
959 .db_path
960 .clone()
961 .map(Ok)
962 .unwrap_or_else(crate::kiro::db::default_db_path)?;
963 let outcome =
964 crate::kiro::fetch_snapshot(client, &db_path, &cache, DEFAULT_TTL).await?;
965 Ok(outcome.into())
966 }
967 VendorId::NousResearch => {
968 let store = crate::nous::credentials::CredentialStore::default();
969 let endpoints = crate::nous::fetch::Endpoints::default();
970 let account = crate::nous::fetch::fetch_account_with_refresh(
971 client,
972 &store,
973 &endpoints,
974 Utc::now(),
975 )
976 .await?;
977 Ok(crate::outcome::Outcome::fresh(
979 crate::usage::VendorSnapshot::NousResearch(account),
980 ))
981 }
982 VendorId::OpenCodeGo => {
983 let api_key = crate::opencode_go::creds::resolve_key(
984 &config.opencode_go.api_key_env,
985 config.opencode_go.api_key.as_deref(),
986 config.opencode_go.auth_paths.as_deref(),
987 )?;
988 let cache = crate::cache::Cache::for_vendor("opencode-go")?;
989 let endpoints = crate::opencode_go::fetch::Endpoints::default();
990 let outcome = crate::opencode_go::fetch::fetch_snapshot(
991 client,
992 &api_key,
993 &cache,
994 &endpoints,
995 DEFAULT_TTL,
996 )
997 .await?;
998 Ok(outcome.into())
999 }
1000 VendorId::Cline => {
1001 let credential = crate::cline::creds::resolve(
1002 config.cline.auth_paths.as_deref(),
1003 &config.cline.api_key_env,
1004 config.cline.api_key.as_deref(),
1005 )?;
1006 let cache = crate::cache::Cache::for_vendor("cline")?;
1007 let endpoints = crate::cline::fetch::Endpoints::default();
1008 let outcome = crate::cline::fetch::fetch_snapshot(
1009 client,
1010 &credential,
1011 &cache,
1012 &endpoints,
1013 DEFAULT_TTL,
1014 Utc::now(),
1015 )
1016 .await?;
1017 Ok(outcome.into())
1018 }
1019 VendorId::CommandCode => {
1020 let credential =
1021 crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref())?;
1022 let cache = crate::cache::Cache::for_vendor("commandcode")?;
1023 let endpoints = crate::commandcode::fetch::Endpoints::default();
1024 let outcome = crate::commandcode::fetch::fetch_snapshot(
1025 client,
1026 &credential.token,
1027 &cache,
1028 &endpoints,
1029 DEFAULT_TTL,
1030 )
1031 .await?;
1032 Ok(outcome.into())
1033 }
1034 VendorId::Ollama => {
1035 let api_key = crate::config::resolve_api_key(
1036 "Ollama",
1037 &config.ollama.api_key_env,
1038 config.ollama.api_key.as_deref(),
1039 )?;
1040 let cache = crate::cache::Cache::for_vendor("ollama")?;
1041 let endpoints = crate::ollama::fetch::Endpoints::default();
1042 let outcome = crate::ollama::fetch_snapshot(
1043 client,
1044 &api_key,
1045 &config.ollama.plan,
1046 &cache,
1047 &endpoints,
1048 DEFAULT_TTL,
1049 )
1050 .await?;
1051 Ok(outcome.into())
1052 }
1053 VendorId::OrcaRouter => {
1054 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
1055 let endpoints = crate::orcarouter::fetch::Endpoints::default();
1056 let outcome = crate::orcarouter::fetch_snapshot(
1057 client,
1058 &api_key,
1059 &cache,
1060 &endpoints,
1061 DEFAULT_TTL,
1062 )
1063 .await?;
1064 Ok(outcome.into())
1065 }
1066 VendorId::Lyceum => {
1067 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
1068 let outcome =
1069 crate::lyceum::fetch::fetch_snapshot(client, &api_key, &cache, DEFAULT_TTL).await?;
1070 Ok(outcome.into())
1071 }
1072 }
1073}
1074
1075pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
1078
1079pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
1086
1087pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
1093 let mut anthropic_seen: u32 = 0;
1094 tabs.iter()
1095 .map(|tab| {
1096 if tab.vendor_id() == Some(VendorId::Anthropic) {
1097 let delay = step * anthropic_seen;
1098 anthropic_seen += 1;
1099 delay
1100 } else {
1101 Duration::ZERO
1102 }
1103 })
1104 .collect()
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109 use super::*;
1110 use chrono::TimeZone;
1111
1112 #[test]
1116 fn refresh_stagger_spaces_out_anthropic_tabs_only() {
1117 let step = Duration::from_millis(800);
1118 let tabs = vec![
1119 TabId::vendor(VendorId::Anthropic), TabId::account("work"),
1121 TabId::account("personal"),
1122 TabId::vendor(VendorId::Openai),
1123 TabId::vendor(VendorId::Zai),
1124 ];
1125 let delays = refresh_stagger(&tabs, step);
1126 assert_eq!(
1127 delays,
1128 vec![
1129 Duration::ZERO, step, step * 2, Duration::ZERO, Duration::ZERO, ]
1135 );
1136 }
1137
1138 #[test]
1139 fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
1140 let tabs = vec![
1142 TabId::vendor(VendorId::Anthropic),
1143 TabId::vendor(VendorId::Openrouter),
1144 ];
1145 assert!(
1146 refresh_stagger(&tabs, Duration::from_millis(800))
1147 .iter()
1148 .all(|d| d.is_zero())
1149 );
1150 }
1151
1152 #[test]
1153 fn select_primary_moves_to_enabled_vendor() {
1154 let mut app = App::with_theme(
1155 vec![
1156 TabId::vendor(VendorId::Anthropic),
1157 TabId::vendor(VendorId::Openrouter),
1158 ],
1159 Theme::default(),
1160 );
1161 app.select_primary(Some(VendorId::Openrouter));
1162 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
1163 }
1164
1165 #[test]
1166 fn select_primary_ignores_disabled_vendor() {
1167 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1168 app.select_primary(Some(VendorId::Openai));
1169 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
1170 }
1171
1172 #[test]
1173 fn nav_ring_wraps_through_the_overview_at_both_ends() {
1174 let mut app = App::with_theme(
1175 vec![
1176 TabId::vendor(VendorId::Anthropic),
1177 TabId::vendor(VendorId::Openai),
1178 ],
1179 Theme::default(),
1180 );
1181 app.overview = true;
1182
1183 app.next_tab(); assert!(!app.overview);
1185 assert_eq!(app.active, 0);
1186 app.next_tab();
1187 assert_eq!(app.active, 1);
1188 app.next_tab(); assert!(app.overview);
1190
1191 app.prev_tab(); assert!(!app.overview);
1193 assert_eq!(app.active, 1);
1194 app.prev_tab();
1195 assert_eq!(app.active, 0);
1196 app.prev_tab(); assert!(app.overview);
1198 }
1199
1200 #[test]
1201 fn mouse_nav_targets_select_overview_and_tabs() {
1202 let mut app = App::with_theme(
1203 vec![
1204 TabId::vendor(VendorId::Anthropic),
1205 TabId::vendor(VendorId::Openai),
1206 ],
1207 Theme::default(),
1208 );
1209 app.overview = true;
1210 app.nav_from_target(NavTarget::Tab(1));
1211 assert!(!app.overview);
1212 assert_eq!(app.active, 1);
1213 app.nav_from_target(NavTarget::Overview);
1214 assert!(app.overview);
1215 app.nav_from_target(NavTarget::Tab(99));
1217 assert!(app.overview);
1218 }
1219
1220 #[test]
1221 fn hit_targets_default_to_empty() {
1222 let app = App::with_theme(Vec::new(), Theme::default());
1223 assert!(app.hit.borrow().nav_entries.is_empty());
1224 assert!(app.hit.borrow().footer_actions.is_empty());
1225 assert!(app.hit.borrow().settings_rows.is_empty());
1226 }
1227
1228 #[test]
1229 fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
1230 let mut app = App::with_theme(
1231 vec![
1232 TabId::vendor(VendorId::Anthropic),
1233 TabId::vendor(VendorId::Openai),
1234 TabId::vendor(VendorId::Zai),
1235 ],
1236 Theme::default(),
1237 );
1238 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1239
1240 app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
1242 assert_eq!(app.overview_tabs(), vec![2, 0]);
1243
1244 app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
1246 assert_eq!(app.overview_tabs(), vec![1]);
1247
1248 app.overview_vendors = Some(vec![VendorId::Anthropic, VendorId::Anthropic]);
1250 assert_eq!(app.overview_tabs(), vec![0]);
1251 }
1252
1253 fn config_with_accounts(labels: &[&str]) -> Config {
1254 let mut config = Config::default();
1255 config.openai.enabled = false;
1258 config.zai.enabled = false;
1259 config.openrouter.enabled = false;
1260 config.commandcode.enabled = false;
1261 config.anthropic.accounts = labels
1262 .iter()
1263 .map(|l| crate::config::AnthropicAccount {
1264 label: (*l).to_string(),
1265 credentials_path: format!("/creds/{l}.json").into(),
1266 })
1267 .collect();
1268 config
1269 }
1270
1271 #[test]
1272 fn show_default_account_false_hides_the_unnamed_claude_tab() {
1273 let mut config = config_with_accounts(&["work", "personal"]);
1276 config.anthropic.show_default_account = false;
1277 assert_eq!(
1278 tabs_from_config(&config),
1279 vec![TabId::account("work"), TabId::account("personal")]
1280 );
1281
1282 let mut empty = Config::default();
1285 empty.openai.enabled = false;
1286 empty.zai.enabled = false;
1287 empty.openrouter.enabled = false;
1288 empty.commandcode.enabled = false;
1289 empty.anthropic.show_default_account = false;
1290 assert_eq!(
1291 tabs_from_config(&empty),
1292 vec![TabId::vendor(VendorId::Anthropic)]
1293 );
1294 }
1295
1296 #[test]
1297 fn tabs_expand_anthropic_accounts_after_default() {
1298 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
1300 assert_eq!(
1301 tabs,
1302 vec![
1303 TabId::vendor(VendorId::Anthropic),
1304 TabId::account("work"),
1305 TabId::account("personal"),
1306 ]
1307 );
1308 }
1309
1310 #[test]
1311 fn tabs_follow_overview_vendors_and_keep_the_rest() {
1312 let mut config = Config::default();
1313 config.zai.enabled = false;
1314 config.openrouter.enabled = false;
1315 config.commandcode.enabled = false;
1316 config.supergrok.enabled = true;
1317 config.antigravity.enabled = true;
1318 config.opencode_go.enabled = true;
1319 config.copilot.enabled = true;
1320 config.ui.overview_vendors = Some(vec![
1321 VendorId::Anthropic,
1322 VendorId::Zai,
1323 VendorId::Openai,
1324 VendorId::Anthropic,
1325 VendorId::Supergrok,
1326 VendorId::OpenCodeGo,
1327 VendorId::Antigravity,
1328 ]);
1329 let vendors: Vec<VendorId> = tabs_from_config(&config)
1330 .iter()
1331 .filter_map(TabId::vendor_id)
1332 .collect();
1333 assert_eq!(
1334 vendors,
1335 vec![
1336 VendorId::Anthropic,
1337 VendorId::Openai,
1338 VendorId::Supergrok,
1339 VendorId::OpenCodeGo,
1340 VendorId::Antigravity,
1341 VendorId::Copilot,
1342 ]
1343 );
1344 }
1345
1346 #[test]
1347 fn tabs_without_accounts_are_just_enabled_vendors() {
1348 let config = Config::default();
1350 let tabs = tabs_from_config(&config);
1351 let vendors: Vec<VendorId> = tabs.iter().filter_map(TabId::vendor_id).collect();
1352 assert_eq!(vendors, config.enabled_vendors());
1353 assert!(tabs.iter().all(|t| t.account.is_none()));
1354 }
1355
1356 #[test]
1357 fn tabs_expand_openrouter_accounts_without_changing_other_vendors() {
1358 let mut config = Config::default();
1359 config.anthropic.enabled = false;
1360 config.openai.enabled = false;
1361 config.zai.enabled = false;
1362 config.commandcode.enabled = false;
1363 config.openrouter.accounts = vec![
1364 crate::config::ApiKeyAccount {
1365 label: "work".into(),
1366 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1367 api_key: None,
1368 management_api_key_env: None,
1369 },
1370 crate::config::ApiKeyAccount {
1371 label: "personal".into(),
1372 api_key_env: None,
1373 api_key: Some("personal-key".into()),
1374 management_api_key_env: None,
1375 },
1376 ];
1377 assert_eq!(
1378 tabs_from_config(&config),
1379 vec![
1380 TabId::vendor(VendorId::Openrouter),
1381 TabId::account_for(VendorId::Openrouter, "work"),
1382 TabId::account_for(VendorId::Openrouter, "personal"),
1383 ]
1384 );
1385 }
1386
1387 #[test]
1388 fn openai_named_accounts_get_their_own_tabs_after_the_default() {
1389 let mut config = Config::default();
1390 config.anthropic.enabled = false;
1391 config.zai.enabled = false;
1392 config.openrouter.enabled = false;
1393 config.commandcode.enabled = false;
1394 config.openai.accounts.push(crate::config::OpenAiAccount {
1395 label: "work".into(),
1396 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1397 });
1398 assert_eq!(
1399 tabs_from_config(&config),
1400 vec![
1401 TabId::vendor(VendorId::Openai),
1402 TabId::account_for(VendorId::Openai, "work"),
1403 ]
1404 );
1405 }
1406
1407 #[test]
1408 fn copilot_named_accounts_get_their_own_tabs_and_can_hide_the_default() {
1409 let base = || {
1410 let mut config = Config::default();
1411 config.anthropic.enabled = false;
1412 config.openai.enabled = false;
1413 config.zai.enabled = false;
1414 config.openrouter.enabled = false;
1415 config.commandcode.enabled = false;
1416 config.copilot.enabled = true;
1417 config
1418 };
1419
1420 assert_eq!(
1422 tabs_from_config(&base()),
1423 vec![TabId::vendor(VendorId::Copilot)]
1424 );
1425
1426 let mut config = base();
1428 config.copilot.accounts.push(crate::config::CopilotAccount {
1429 label: "work".into(),
1430 user: "octocat-work".into(),
1431 });
1432 config.copilot.accounts.push(crate::config::CopilotAccount {
1433 label: "personal".into(),
1434 user: "octocat".into(),
1435 });
1436 assert_eq!(
1437 tabs_from_config(&config),
1438 vec![
1439 TabId::vendor(VendorId::Copilot),
1440 TabId::account_for(VendorId::Copilot, "work"),
1441 TabId::account_for(VendorId::Copilot, "personal"),
1442 ]
1443 );
1444
1445 config.copilot.show_default_account = false;
1447 assert_eq!(
1448 tabs_from_config(&config),
1449 vec![
1450 TabId::account_for(VendorId::Copilot, "work"),
1451 TabId::account_for(VendorId::Copilot, "personal"),
1452 ]
1453 );
1454
1455 let mut empty = base();
1457 empty.copilot.show_default_account = false;
1458 assert_eq!(
1459 tabs_from_config(&empty),
1460 vec![TabId::vendor(VendorId::Copilot)]
1461 );
1462 }
1463
1464 #[test]
1465 fn openrouter_can_hide_default_only_when_named_accounts_exist() {
1466 let mut config = Config::default();
1467 config.anthropic.enabled = false;
1468 config.openai.enabled = false;
1469 config.zai.enabled = false;
1470 config.commandcode.enabled = false;
1471 config.openrouter.show_default_account = false;
1472 assert_eq!(
1473 tabs_from_config(&config),
1474 vec![TabId::vendor(VendorId::Openrouter)]
1475 );
1476
1477 config
1478 .openrouter
1479 .accounts
1480 .push(crate::config::ApiKeyAccount {
1481 label: "work".into(),
1482 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1483 api_key: None,
1484 management_api_key_env: None,
1485 });
1486 assert_eq!(
1487 tabs_from_config(&config),
1488 vec![TabId::account_for(VendorId::Openrouter, "work")]
1489 );
1490 }
1491
1492 #[test]
1493 fn every_api_key_account_vendor_fans_out_like_openrouter() {
1494 let config_for = |vendor: VendorId, show_default: bool| -> Config {
1495 let section = vendor.config_section();
1496 let mut text: String = ["anthropic", "openai", "commandcode", "zai", "openrouter"]
1499 .into_iter()
1500 .filter(|other| *other != section)
1501 .map(|other| format!("[{other}]\nenabled = false\n"))
1502 .collect();
1503 text.push_str(&format!(
1504 "[{section}]\nenabled = true\nshow_default_account = {show_default}\n\
1505 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k1\"\n\
1506 [[{section}.accounts]]\nlabel = \"personal\"\napi_key = \"k2\"\n"
1507 ));
1508 let config: Config = toml::from_str(&text).unwrap();
1509 config.validate().unwrap();
1510 config
1511 };
1512 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
1513 let named = vec![
1514 TabId::account_for(vendor, "work"),
1515 TabId::account_for(vendor, "personal"),
1516 ];
1517 let mut with_default = vec![TabId::vendor(vendor)];
1518 with_default.extend(named.clone());
1519 assert_eq!(
1520 tabs_from_config(&config_for(vendor, true)),
1521 with_default,
1522 "{vendor:?}"
1523 );
1524 assert_eq!(
1525 tabs_from_config(&config_for(vendor, false)),
1526 named,
1527 "{vendor:?}"
1528 );
1529 }
1530 }
1531
1532 #[test]
1533 fn codex_can_hide_default_only_when_named_accounts_exist() {
1534 let mut config = Config::default();
1535 config.anthropic.enabled = false;
1536 config.zai.enabled = false;
1537 config.openrouter.enabled = false;
1538 config.commandcode.enabled = false;
1539 config.openai.show_default_account = false;
1540 assert_eq!(
1541 tabs_from_config(&config),
1542 vec![TabId::vendor(VendorId::Openai)]
1543 );
1544
1545 config.openai.accounts.push(crate::config::OpenAiAccount {
1546 label: "work".into(),
1547 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1548 });
1549 assert_eq!(
1550 tabs_from_config(&config),
1551 vec![TabId::account_for(VendorId::Openai, "work")]
1552 );
1553 }
1554
1555 #[test]
1556 fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
1557 let td = tempfile::tempdir().unwrap();
1560 for label in ["work", "personal"] {
1561 let dir = td.path().join(label);
1562 std::fs::create_dir_all(&dir).unwrap();
1563 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1564 }
1565 let mut config = Config::default();
1566 config.openai.enabled = false;
1567 config.zai.enabled = false;
1568 config.openrouter.enabled = false;
1569 config.commandcode.enabled = false;
1570 config.anthropic.accounts_dir = Some(td.path().to_path_buf());
1571
1572 let tabs = tabs_from_config(&config);
1573 assert_eq!(
1574 tabs,
1575 vec![
1576 TabId::vendor(VendorId::Anthropic),
1577 TabId::account("personal"), TabId::account("work"),
1579 ]
1580 );
1581 }
1582
1583 #[test]
1584 fn desktop_labels_become_account_tabs_after_cli_accounts() {
1585 let config = config_with_accounts(&["work"]);
1587 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1588 assert_eq!(
1589 tabs,
1590 vec![
1591 TabId::vendor(VendorId::Anthropic),
1592 TabId::account("work"),
1593 TabId::desktop_account("gmail"),
1594 TabId::desktop_account("hotmail"),
1595 ]
1596 );
1597 }
1598
1599 #[test]
1600 fn a_desktop_profile_wins_a_label_collision_with_a_cli_account() {
1601 let config = config_with_accounts(&["gmail", "work"]);
1606 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1607 assert_eq!(
1608 tabs,
1609 vec![
1610 TabId::vendor(VendorId::Anthropic),
1611 TabId::account("work"),
1612 TabId::desktop_account("gmail"),
1613 TabId::desktop_account("hotmail"),
1614 ]
1615 );
1616 }
1617
1618 #[test]
1619 fn desktop_accounts_suppress_the_default_tab_like_named_ones() {
1620 let mut config = config_with_accounts(&[]);
1623 config.cursor.enabled = false;
1624 config.anthropic.show_default_account = false;
1625
1626 assert_eq!(
1629 build_tabs(&config, &[]),
1630 vec![TabId::vendor(VendorId::Anthropic)]
1631 );
1632 assert_eq!(
1634 build_tabs(&config, &["gmail".into()]),
1635 vec![TabId::desktop_account("gmail")]
1636 );
1637 }
1638
1639 #[test]
1640 fn set_tabs_resets_states_and_clamps_selection() {
1641 let mut app = App::with_theme(
1645 tabs_from_config(&config_with_accounts(&["work", "personal"])),
1646 Theme::default(),
1647 );
1648 app.active = 2; app.tabs[0] = TabState::error("old");
1650 let old_tab = app.tabs_meta[0].clone();
1651 assert!(app.begin_refresh(&old_tab));
1652
1653 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
1654 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
1655 assert_eq!(app.active, 0, "selection clamped after shrink");
1656 assert!(matches!(app.tabs[0], TabState::Loading));
1657 assert!(!app.is_refreshing(&old_tab));
1658 }
1659
1660 #[test]
1661 fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
1662 let mut app = App::with_theme(
1663 vec![
1664 TabId::vendor(VendorId::Anthropic),
1665 TabId::vendor(VendorId::Openai),
1666 ],
1667 Theme::default(),
1668 );
1669 app.active = 1;
1670
1671 app.set_tabs(vec![
1672 TabId::vendor(VendorId::Anthropic),
1673 TabId::account("work"),
1674 TabId::vendor(VendorId::Openai),
1675 ]);
1676
1677 assert_eq!(app.active, 2);
1678 assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
1679 }
1680
1681 #[test]
1682 fn refresh_from_old_generation_is_discarded() {
1683 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1684 let old_generation = app.tab_generation;
1685 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
1686
1687 assert!(!app.apply_refresh(
1688 old_generation,
1689 &TabId::vendor(VendorId::Anthropic),
1690 TabState::error("old result"),
1691 ));
1692 assert!(matches!(app.tabs[0], TabState::Loading));
1693 }
1694
1695 #[test]
1696 fn refresh_identity_mismatch_is_discarded() {
1697 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1698 let generation = app.tab_generation;
1699
1700 assert!(!app.apply_refresh(
1701 generation,
1702 &TabId::vendor(VendorId::Openai),
1703 TabState::error("wrong tab"),
1704 ));
1705 assert!(matches!(app.tabs[0], TabState::Loading));
1706 }
1707
1708 #[test]
1709 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
1710 let anthropic = TabId::vendor(VendorId::Anthropic);
1711 let openai = TabId::vendor(VendorId::Openai);
1712 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
1713 let generation = app.tab_generation;
1714 assert!(app.begin_refresh(&anthropic));
1715
1716 app.tabs_meta.swap(0, 1);
1719 app.tabs.swap(0, 1);
1720 assert!(app.apply_refresh(generation, &anthropic, TabState::error("ready")));
1721 assert!(matches!(app.tabs[0], TabState::Loading));
1722 assert!(matches!(&app.tabs[1], TabState::Error { message, .. } if message == "ready"));
1723 assert!(!app.is_refreshing(&anthropic));
1724 }
1725
1726 fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
1727 TabState::Ready(Box::new(ReadyTab {
1728 snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
1729 label: "test".into(),
1730 total_credits: 0.0,
1731 total_usage: 0.0,
1732 usage_daily: 0.0,
1733 usage_weekly: 0.0,
1734 usage_monthly: 0.0,
1735 is_free_tier: false,
1736 limit: None,
1737 limit_remaining: None,
1738 recent_models: Vec::new(),
1739 }),
1740 stale: false,
1741 last_error: None,
1742 fetched_at: Some(fetched_at),
1743 display: Default::default(),
1744 }))
1745 }
1746
1747 #[test]
1748 fn refresh_keeps_ready_snapshot_visible_and_suppresses_duplicates() {
1749 let tab = TabId::vendor(VendorId::Openrouter);
1750 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1751 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1752 app.tabs[0] = ready_at(fetched_at);
1753
1754 assert!(app.begin_refresh(&tab));
1755 assert!(
1756 !app.begin_refresh(&tab),
1757 "duplicate request must be suppressed"
1758 );
1759 assert!(app.is_refreshing(&tab));
1760 match &app.tabs[0] {
1761 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1762 other => panic!("ready snapshot disappeared during refresh: {other:?}"),
1763 }
1764 }
1765
1766 #[test]
1767 fn first_refresh_still_uses_loading_until_data_arrives() {
1768 let tab = TabId::vendor(VendorId::Openrouter);
1769 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1770
1771 assert!(app.begin_refresh(&tab));
1772 assert!(app.is_refreshing(&tab));
1773 assert!(matches!(app.tabs[0], TabState::Loading));
1774
1775 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("not signed in"),));
1776 assert!(!app.is_refreshing(&tab));
1777 assert!(
1778 matches!(&app.tabs[0], TabState::Error { message, .. } if message == "not signed in")
1779 );
1780 }
1781
1782 #[test]
1783 fn successful_revalidation_replaces_snapshot_and_clears_indicator() {
1784 let tab = TabId::vendor(VendorId::Openrouter);
1785 let old_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1786 let new_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 1, 0).unwrap();
1787 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1788 app.tabs[0] = ready_at(old_at);
1789
1790 assert!(app.begin_refresh(&tab));
1791 assert!(app.apply_refresh(app.tab_generation, &tab, ready_at(new_at)));
1792 assert!(!app.is_refreshing(&tab));
1793 match &app.tabs[0] {
1794 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(new_at)),
1795 other => panic!("expected replacement snapshot, got {other:?}"),
1796 }
1797 }
1798
1799 #[test]
1800 fn failed_revalidation_preserves_snapshot_with_visible_warning() {
1801 let tab = TabId::vendor(VendorId::Openrouter);
1802 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1803 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1804 app.tabs[0] = ready_at(fetched_at);
1805
1806 assert!(app.begin_refresh(&tab));
1807 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("refresh failed"),));
1808 assert!(!app.is_refreshing(&tab));
1809 match &app.tabs[0] {
1810 TabState::Ready(ready) => {
1811 assert_eq!(ready.fetched_at, Some(fetched_at));
1812 assert!(ready.stale);
1813 assert_eq!(ready.last_error, Some((0, "refresh failed".into())));
1814 }
1815 other => panic!("last successful snapshot was lost: {other:?}"),
1816 }
1817 let sections = crate::tui::panels::sections_for(&app.tabs[0], Utc::now(), 5);
1818 assert!(sections.iter().any(|section| matches!(
1819 section,
1820 crate::tui::panels::Section::Text { label, value }
1821 if label == "Warning" && value == "refresh failed"
1822 )));
1823 }
1824
1825 #[test]
1826 fn old_generation_result_does_not_clear_current_refresh() {
1827 let tab = TabId::vendor(VendorId::Openrouter);
1828 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1829 let old_generation = app.tab_generation;
1830 app.set_tabs(vec![tab.clone()]);
1831 assert!(app.begin_refresh(&tab));
1832
1833 assert!(!app.apply_refresh(old_generation, &tab, TabState::error("old result"),));
1834 assert!(app.is_refreshing(&tab));
1835 assert!(matches!(app.tabs[0], TabState::Loading));
1836 }
1837
1838 #[test]
1839 fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
1840 let anthropic = TabId::vendor(VendorId::Anthropic);
1846 let openai = TabId::vendor(VendorId::Openai);
1847 let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
1848 let generation = app.tab_generation;
1849 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1850
1851 assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
1852 match &app.tabs[0] {
1853 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1854 other => panic!("expected Anthropic tab Ready, got {other:?}"),
1855 }
1856 assert!(matches!(app.tabs[1], TabState::Loading));
1857 }
1858
1859 #[test]
1860 fn select_primary_lands_on_default_account_tab() {
1861 let app = {
1864 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
1865 let mut a = App::with_theme(tabs, Theme::default());
1866 a.select_primary(Some(VendorId::Anthropic));
1867 a
1868 };
1869 assert_eq!(app.active, 0);
1870 assert_eq!(
1871 app.active_tab_id(),
1872 Some(&TabId::vendor(VendorId::Anthropic))
1873 );
1874 }
1875
1876 fn custom_spec(id: &str, enabled: bool) -> CustomProviderConfig {
1877 CustomProviderConfig {
1878 id: id.into(),
1879 name: "My Tool".into(),
1880 short_name: "myt".into(),
1881 enabled,
1882 ..Default::default()
1883 }
1884 }
1885
1886 #[test]
1887 fn custom_providers_get_tabs_after_every_builtin() {
1888 let config = Config {
1889 custom: vec![custom_spec("mytool", true), custom_spec("other", true)],
1890 ..Default::default()
1891 };
1892 let tabs = tabs_from_config(&config);
1893 let builtin_count = config.enabled_vendors().len();
1894 assert_eq!(tabs.len(), builtin_count + 2);
1895 assert!(
1896 tabs[..builtin_count]
1897 .iter()
1898 .all(|t| matches!(t.source, TabSource::Builtin(_)))
1899 );
1900 assert_eq!(tabs[builtin_count], TabId::custom(&config.custom[0]));
1901 assert_eq!(tabs[builtin_count + 1], TabId::custom(&config.custom[1]));
1902 assert_eq!(
1903 tabs[builtin_count].source,
1904 TabSource::Custom {
1905 id: "mytool".into(),
1906 name: "My Tool".into(),
1907 short_name: "myt".into(),
1908 }
1909 );
1910 assert!(tabs[builtin_count].account.is_none());
1911 assert!(!tabs[builtin_count].desktop);
1912 assert_eq!(tabs[builtin_count].vendor_id(), None);
1913 }
1914
1915 #[test]
1916 fn disabled_custom_provider_has_no_tab() {
1917 let config = Config {
1918 custom: vec![custom_spec("mytool", false)],
1919 ..Default::default()
1920 };
1921 let tabs = tabs_from_config(&config);
1922 assert!(
1923 tabs.iter()
1924 .all(|t| matches!(t.source, TabSource::Builtin(_)))
1925 );
1926 assert_eq!(tabs.len(), config.enabled_vendors().len());
1927 }
1928
1929 #[test]
1930 fn custom_tabs_are_listed_by_the_default_overview_but_not_by_a_vendor_filter() {
1931 let spec = custom_spec("mytool", true);
1932 let mut app = App::with_theme(
1933 vec![
1934 TabId::vendor(VendorId::Anthropic),
1935 TabId::custom(&spec),
1936 TabId::vendor(VendorId::Openai),
1937 ],
1938 Theme::default(),
1939 );
1940 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1941
1942 app.overview_vendors = Some(vec![VendorId::Openai, VendorId::Anthropic]);
1944 assert_eq!(app.overview_tabs(), vec![2, 0]);
1945 }
1946
1947 #[test]
1948 fn a_custom_active_tab_has_no_vendor_and_never_staggers() {
1949 let spec = custom_spec("mytool", true);
1950 let tabs = vec![
1951 TabId::vendor(VendorId::Anthropic),
1952 TabId::custom(&spec),
1953 TabId::account("work"),
1954 ];
1955 let mut app = App::with_theme(tabs.clone(), Theme::default());
1956 app.active = 1;
1957 assert_eq!(app.active_vendor(), None);
1958 assert_eq!(app.active_tab_id(), Some(&TabId::custom(&spec)));
1959
1960 app.select_primary(Some(VendorId::Anthropic));
1962 assert_eq!(app.active, 0);
1963
1964 let step = Duration::from_millis(800);
1965 assert_eq!(
1966 refresh_stagger(&tabs, step),
1967 vec![Duration::ZERO, Duration::ZERO, step]
1968 );
1969 }
1970}