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::Devin => {
910 let cache = crate::cache::Cache::for_vendor("devin")?;
911 let outcome =
912 crate::devin::fetch::fetch_snapshot(&config.devin, &cache, DEFAULT_TTL).await?;
913 Ok(outcome.into())
914 }
915 VendorId::Minimax => {
916 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
917 let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
918 let outcome =
919 crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
920 .await?;
921 Ok(outcome.into())
922 }
923 VendorId::Cursor => {
924 let cache = crate::cache::Cache::for_vendor("cursor")?;
925 let db_path = config
926 .cursor
927 .db_path
928 .clone()
929 .map(Ok)
930 .unwrap_or_else(crate::cursor::db::default_db_path)?;
931 let agent_auth_path = config
932 .cursor
933 .agent_auth_path
934 .clone()
935 .map(Ok)
936 .unwrap_or_else(crate::cursor::db::default_agent_auth_path)?;
937 let endpoints = crate::cursor::fetch::Endpoints::default();
938 let outcome = crate::cursor::fetch_snapshot(
939 client,
940 &db_path,
941 &agent_auth_path,
942 &cache,
943 &endpoints,
944 DEFAULT_TTL,
945 )
946 .await?;
947 Ok(outcome.into())
948 }
949 VendorId::Kiro => {
950 let cache = crate::cache::Cache::for_vendor("kiro")?;
951 let db_path = config
952 .kiro
953 .db_path
954 .clone()
955 .map(Ok)
956 .unwrap_or_else(crate::kiro::db::default_db_path)?;
957 let outcome =
958 crate::kiro::fetch_snapshot(client, &db_path, &cache, DEFAULT_TTL).await?;
959 Ok(outcome.into())
960 }
961 VendorId::NousResearch => {
962 let store = crate::nous::credentials::CredentialStore::default();
963 let endpoints = crate::nous::fetch::Endpoints::default();
964 let account = crate::nous::fetch::fetch_account_with_refresh(
965 client,
966 &store,
967 &endpoints,
968 Utc::now(),
969 )
970 .await?;
971 Ok(crate::outcome::Outcome::fresh(
973 crate::usage::VendorSnapshot::NousResearch(account),
974 ))
975 }
976 VendorId::OpenCodeGo => {
977 let api_key = crate::opencode_go::creds::resolve_key(
978 &config.opencode_go.api_key_env,
979 config.opencode_go.api_key.as_deref(),
980 config.opencode_go.auth_paths.as_deref(),
981 )?;
982 let cache = crate::cache::Cache::for_vendor("opencode-go")?;
983 let endpoints = crate::opencode_go::fetch::Endpoints::default();
984 let outcome = crate::opencode_go::fetch::fetch_snapshot(
985 client,
986 &api_key,
987 &cache,
988 &endpoints,
989 DEFAULT_TTL,
990 )
991 .await?;
992 Ok(outcome.into())
993 }
994 VendorId::Cline => {
995 let credential = crate::cline::creds::resolve(
996 config.cline.auth_paths.as_deref(),
997 &config.cline.api_key_env,
998 config.cline.api_key.as_deref(),
999 )?;
1000 let cache = crate::cache::Cache::for_vendor("cline")?;
1001 let endpoints = crate::cline::fetch::Endpoints::default();
1002 let outcome = crate::cline::fetch::fetch_snapshot(
1003 client,
1004 &credential,
1005 &cache,
1006 &endpoints,
1007 DEFAULT_TTL,
1008 Utc::now(),
1009 )
1010 .await?;
1011 Ok(outcome.into())
1012 }
1013 VendorId::CommandCode => {
1014 let credential =
1015 crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref())?;
1016 let cache = crate::cache::Cache::for_vendor("commandcode")?;
1017 let endpoints = crate::commandcode::fetch::Endpoints::default();
1018 let outcome = crate::commandcode::fetch::fetch_snapshot(
1019 client,
1020 &credential.token,
1021 &cache,
1022 &endpoints,
1023 DEFAULT_TTL,
1024 )
1025 .await?;
1026 Ok(outcome.into())
1027 }
1028 VendorId::Ollama => {
1029 let api_key = crate::config::resolve_api_key(
1030 "Ollama",
1031 &config.ollama.api_key_env,
1032 config.ollama.api_key.as_deref(),
1033 )?;
1034 let cache = crate::cache::Cache::for_vendor("ollama")?;
1035 let endpoints = crate::ollama::fetch::Endpoints::default();
1036 let outcome = crate::ollama::fetch_snapshot(
1037 client,
1038 &api_key,
1039 &config.ollama.plan,
1040 &cache,
1041 &endpoints,
1042 DEFAULT_TTL,
1043 )
1044 .await?;
1045 Ok(outcome.into())
1046 }
1047 VendorId::OrcaRouter => {
1048 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
1049 let endpoints = crate::orcarouter::fetch::Endpoints::default();
1050 let outcome = crate::orcarouter::fetch_snapshot(
1051 client,
1052 &api_key,
1053 &cache,
1054 &endpoints,
1055 DEFAULT_TTL,
1056 )
1057 .await?;
1058 Ok(outcome.into())
1059 }
1060 VendorId::Lyceum => {
1061 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
1062 let outcome =
1063 crate::lyceum::fetch::fetch_snapshot(client, &api_key, &cache, DEFAULT_TTL).await?;
1064 Ok(outcome.into())
1065 }
1066 }
1067}
1068
1069pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
1072
1073pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
1080
1081pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
1087 let mut anthropic_seen: u32 = 0;
1088 tabs.iter()
1089 .map(|tab| {
1090 if tab.vendor_id() == Some(VendorId::Anthropic) {
1091 let delay = step * anthropic_seen;
1092 anthropic_seen += 1;
1093 delay
1094 } else {
1095 Duration::ZERO
1096 }
1097 })
1098 .collect()
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103 use super::*;
1104 use chrono::TimeZone;
1105
1106 #[test]
1110 fn refresh_stagger_spaces_out_anthropic_tabs_only() {
1111 let step = Duration::from_millis(800);
1112 let tabs = vec![
1113 TabId::vendor(VendorId::Anthropic), TabId::account("work"),
1115 TabId::account("personal"),
1116 TabId::vendor(VendorId::Openai),
1117 TabId::vendor(VendorId::Zai),
1118 ];
1119 let delays = refresh_stagger(&tabs, step);
1120 assert_eq!(
1121 delays,
1122 vec![
1123 Duration::ZERO, step, step * 2, Duration::ZERO, Duration::ZERO, ]
1129 );
1130 }
1131
1132 #[test]
1133 fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
1134 let tabs = vec![
1136 TabId::vendor(VendorId::Anthropic),
1137 TabId::vendor(VendorId::Openrouter),
1138 ];
1139 assert!(
1140 refresh_stagger(&tabs, Duration::from_millis(800))
1141 .iter()
1142 .all(|d| d.is_zero())
1143 );
1144 }
1145
1146 #[test]
1147 fn select_primary_moves_to_enabled_vendor() {
1148 let mut app = App::with_theme(
1149 vec![
1150 TabId::vendor(VendorId::Anthropic),
1151 TabId::vendor(VendorId::Openrouter),
1152 ],
1153 Theme::default(),
1154 );
1155 app.select_primary(Some(VendorId::Openrouter));
1156 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
1157 }
1158
1159 #[test]
1160 fn select_primary_ignores_disabled_vendor() {
1161 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1162 app.select_primary(Some(VendorId::Openai));
1163 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
1164 }
1165
1166 #[test]
1167 fn nav_ring_wraps_through_the_overview_at_both_ends() {
1168 let mut app = App::with_theme(
1169 vec![
1170 TabId::vendor(VendorId::Anthropic),
1171 TabId::vendor(VendorId::Openai),
1172 ],
1173 Theme::default(),
1174 );
1175 app.overview = true;
1176
1177 app.next_tab(); assert!(!app.overview);
1179 assert_eq!(app.active, 0);
1180 app.next_tab();
1181 assert_eq!(app.active, 1);
1182 app.next_tab(); assert!(app.overview);
1184
1185 app.prev_tab(); assert!(!app.overview);
1187 assert_eq!(app.active, 1);
1188 app.prev_tab();
1189 assert_eq!(app.active, 0);
1190 app.prev_tab(); assert!(app.overview);
1192 }
1193
1194 #[test]
1195 fn mouse_nav_targets_select_overview_and_tabs() {
1196 let mut app = App::with_theme(
1197 vec![
1198 TabId::vendor(VendorId::Anthropic),
1199 TabId::vendor(VendorId::Openai),
1200 ],
1201 Theme::default(),
1202 );
1203 app.overview = true;
1204 app.nav_from_target(NavTarget::Tab(1));
1205 assert!(!app.overview);
1206 assert_eq!(app.active, 1);
1207 app.nav_from_target(NavTarget::Overview);
1208 assert!(app.overview);
1209 app.nav_from_target(NavTarget::Tab(99));
1211 assert!(app.overview);
1212 }
1213
1214 #[test]
1215 fn hit_targets_default_to_empty() {
1216 let app = App::with_theme(Vec::new(), Theme::default());
1217 assert!(app.hit.borrow().nav_entries.is_empty());
1218 assert!(app.hit.borrow().footer_actions.is_empty());
1219 assert!(app.hit.borrow().settings_rows.is_empty());
1220 }
1221
1222 #[test]
1223 fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
1224 let mut app = App::with_theme(
1225 vec![
1226 TabId::vendor(VendorId::Anthropic),
1227 TabId::vendor(VendorId::Openai),
1228 TabId::vendor(VendorId::Zai),
1229 ],
1230 Theme::default(),
1231 );
1232 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1233
1234 app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
1236 assert_eq!(app.overview_tabs(), vec![2, 0]);
1237
1238 app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
1240 assert_eq!(app.overview_tabs(), vec![1]);
1241
1242 app.overview_vendors = Some(vec![VendorId::Anthropic, VendorId::Anthropic]);
1244 assert_eq!(app.overview_tabs(), vec![0]);
1245 }
1246
1247 fn config_with_accounts(labels: &[&str]) -> Config {
1248 let mut config = Config::default();
1249 config.openai.enabled = false;
1252 config.zai.enabled = false;
1253 config.openrouter.enabled = false;
1254 config.commandcode.enabled = false;
1255 config.anthropic.accounts = labels
1256 .iter()
1257 .map(|l| crate::config::AnthropicAccount {
1258 label: (*l).to_string(),
1259 credentials_path: format!("/creds/{l}.json").into(),
1260 })
1261 .collect();
1262 config
1263 }
1264
1265 #[test]
1266 fn show_default_account_false_hides_the_unnamed_claude_tab() {
1267 let mut config = config_with_accounts(&["work", "personal"]);
1270 config.anthropic.show_default_account = false;
1271 assert_eq!(
1272 tabs_from_config(&config),
1273 vec![TabId::account("work"), TabId::account("personal")]
1274 );
1275
1276 let mut empty = Config::default();
1279 empty.openai.enabled = false;
1280 empty.zai.enabled = false;
1281 empty.openrouter.enabled = false;
1282 empty.commandcode.enabled = false;
1283 empty.anthropic.show_default_account = false;
1284 assert_eq!(
1285 tabs_from_config(&empty),
1286 vec![TabId::vendor(VendorId::Anthropic)]
1287 );
1288 }
1289
1290 #[test]
1291 fn tabs_expand_anthropic_accounts_after_default() {
1292 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
1294 assert_eq!(
1295 tabs,
1296 vec![
1297 TabId::vendor(VendorId::Anthropic),
1298 TabId::account("work"),
1299 TabId::account("personal"),
1300 ]
1301 );
1302 }
1303
1304 #[test]
1305 fn tabs_follow_overview_vendors_and_keep_the_rest() {
1306 let mut config = Config::default();
1307 config.zai.enabled = false;
1308 config.openrouter.enabled = false;
1309 config.commandcode.enabled = false;
1310 config.supergrok.enabled = true;
1311 config.antigravity.enabled = true;
1312 config.opencode_go.enabled = true;
1313 config.copilot.enabled = true;
1314 config.ui.overview_vendors = Some(vec![
1315 VendorId::Anthropic,
1316 VendorId::Zai,
1317 VendorId::Openai,
1318 VendorId::Anthropic,
1319 VendorId::Supergrok,
1320 VendorId::OpenCodeGo,
1321 VendorId::Antigravity,
1322 ]);
1323 let vendors: Vec<VendorId> = tabs_from_config(&config)
1324 .iter()
1325 .filter_map(TabId::vendor_id)
1326 .collect();
1327 assert_eq!(
1328 vendors,
1329 vec![
1330 VendorId::Anthropic,
1331 VendorId::Openai,
1332 VendorId::Supergrok,
1333 VendorId::OpenCodeGo,
1334 VendorId::Antigravity,
1335 VendorId::Copilot,
1336 ]
1337 );
1338 }
1339
1340 #[test]
1341 fn tabs_without_accounts_are_just_enabled_vendors() {
1342 let config = Config::default();
1344 let tabs = tabs_from_config(&config);
1345 let vendors: Vec<VendorId> = tabs.iter().filter_map(TabId::vendor_id).collect();
1346 assert_eq!(vendors, config.enabled_vendors());
1347 assert!(tabs.iter().all(|t| t.account.is_none()));
1348 }
1349
1350 #[test]
1351 fn tabs_expand_openrouter_accounts_without_changing_other_vendors() {
1352 let mut config = Config::default();
1353 config.anthropic.enabled = false;
1354 config.openai.enabled = false;
1355 config.zai.enabled = false;
1356 config.commandcode.enabled = false;
1357 config.openrouter.accounts = vec![
1358 crate::config::ApiKeyAccount {
1359 label: "work".into(),
1360 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1361 api_key: None,
1362 management_api_key_env: None,
1363 },
1364 crate::config::ApiKeyAccount {
1365 label: "personal".into(),
1366 api_key_env: None,
1367 api_key: Some("personal-key".into()),
1368 management_api_key_env: None,
1369 },
1370 ];
1371 assert_eq!(
1372 tabs_from_config(&config),
1373 vec![
1374 TabId::vendor(VendorId::Openrouter),
1375 TabId::account_for(VendorId::Openrouter, "work"),
1376 TabId::account_for(VendorId::Openrouter, "personal"),
1377 ]
1378 );
1379 }
1380
1381 #[test]
1382 fn openai_named_accounts_get_their_own_tabs_after_the_default() {
1383 let mut config = Config::default();
1384 config.anthropic.enabled = false;
1385 config.zai.enabled = false;
1386 config.openrouter.enabled = false;
1387 config.commandcode.enabled = false;
1388 config.openai.accounts.push(crate::config::OpenAiAccount {
1389 label: "work".into(),
1390 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1391 });
1392 assert_eq!(
1393 tabs_from_config(&config),
1394 vec![
1395 TabId::vendor(VendorId::Openai),
1396 TabId::account_for(VendorId::Openai, "work"),
1397 ]
1398 );
1399 }
1400
1401 #[test]
1402 fn copilot_named_accounts_get_their_own_tabs_and_can_hide_the_default() {
1403 let base = || {
1404 let mut config = Config::default();
1405 config.anthropic.enabled = false;
1406 config.openai.enabled = false;
1407 config.zai.enabled = false;
1408 config.openrouter.enabled = false;
1409 config.commandcode.enabled = false;
1410 config.copilot.enabled = true;
1411 config
1412 };
1413
1414 assert_eq!(
1416 tabs_from_config(&base()),
1417 vec![TabId::vendor(VendorId::Copilot)]
1418 );
1419
1420 let mut config = base();
1422 config.copilot.accounts.push(crate::config::CopilotAccount {
1423 label: "work".into(),
1424 user: "octocat-work".into(),
1425 });
1426 config.copilot.accounts.push(crate::config::CopilotAccount {
1427 label: "personal".into(),
1428 user: "octocat".into(),
1429 });
1430 assert_eq!(
1431 tabs_from_config(&config),
1432 vec![
1433 TabId::vendor(VendorId::Copilot),
1434 TabId::account_for(VendorId::Copilot, "work"),
1435 TabId::account_for(VendorId::Copilot, "personal"),
1436 ]
1437 );
1438
1439 config.copilot.show_default_account = false;
1441 assert_eq!(
1442 tabs_from_config(&config),
1443 vec![
1444 TabId::account_for(VendorId::Copilot, "work"),
1445 TabId::account_for(VendorId::Copilot, "personal"),
1446 ]
1447 );
1448
1449 let mut empty = base();
1451 empty.copilot.show_default_account = false;
1452 assert_eq!(
1453 tabs_from_config(&empty),
1454 vec![TabId::vendor(VendorId::Copilot)]
1455 );
1456 }
1457
1458 #[test]
1459 fn openrouter_can_hide_default_only_when_named_accounts_exist() {
1460 let mut config = Config::default();
1461 config.anthropic.enabled = false;
1462 config.openai.enabled = false;
1463 config.zai.enabled = false;
1464 config.commandcode.enabled = false;
1465 config.openrouter.show_default_account = false;
1466 assert_eq!(
1467 tabs_from_config(&config),
1468 vec![TabId::vendor(VendorId::Openrouter)]
1469 );
1470
1471 config
1472 .openrouter
1473 .accounts
1474 .push(crate::config::ApiKeyAccount {
1475 label: "work".into(),
1476 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1477 api_key: None,
1478 management_api_key_env: None,
1479 });
1480 assert_eq!(
1481 tabs_from_config(&config),
1482 vec![TabId::account_for(VendorId::Openrouter, "work")]
1483 );
1484 }
1485
1486 #[test]
1487 fn every_api_key_account_vendor_fans_out_like_openrouter() {
1488 let config_for = |vendor: VendorId, show_default: bool| -> Config {
1489 let section = vendor.config_section();
1490 let mut text: String = ["anthropic", "openai", "commandcode", "zai", "openrouter"]
1493 .into_iter()
1494 .filter(|other| *other != section)
1495 .map(|other| format!("[{other}]\nenabled = false\n"))
1496 .collect();
1497 text.push_str(&format!(
1498 "[{section}]\nenabled = true\nshow_default_account = {show_default}\n\
1499 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k1\"\n\
1500 [[{section}.accounts]]\nlabel = \"personal\"\napi_key = \"k2\"\n"
1501 ));
1502 let config: Config = toml::from_str(&text).unwrap();
1503 config.validate().unwrap();
1504 config
1505 };
1506 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
1507 let named = vec![
1508 TabId::account_for(vendor, "work"),
1509 TabId::account_for(vendor, "personal"),
1510 ];
1511 let mut with_default = vec![TabId::vendor(vendor)];
1512 with_default.extend(named.clone());
1513 assert_eq!(
1514 tabs_from_config(&config_for(vendor, true)),
1515 with_default,
1516 "{vendor:?}"
1517 );
1518 assert_eq!(
1519 tabs_from_config(&config_for(vendor, false)),
1520 named,
1521 "{vendor:?}"
1522 );
1523 }
1524 }
1525
1526 #[test]
1527 fn codex_can_hide_default_only_when_named_accounts_exist() {
1528 let mut config = Config::default();
1529 config.anthropic.enabled = false;
1530 config.zai.enabled = false;
1531 config.openrouter.enabled = false;
1532 config.commandcode.enabled = false;
1533 config.openai.show_default_account = false;
1534 assert_eq!(
1535 tabs_from_config(&config),
1536 vec![TabId::vendor(VendorId::Openai)]
1537 );
1538
1539 config.openai.accounts.push(crate::config::OpenAiAccount {
1540 label: "work".into(),
1541 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1542 });
1543 assert_eq!(
1544 tabs_from_config(&config),
1545 vec![TabId::account_for(VendorId::Openai, "work")]
1546 );
1547 }
1548
1549 #[test]
1550 fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
1551 let td = tempfile::tempdir().unwrap();
1554 for label in ["work", "personal"] {
1555 let dir = td.path().join(label);
1556 std::fs::create_dir_all(&dir).unwrap();
1557 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1558 }
1559 let mut config = Config::default();
1560 config.openai.enabled = false;
1561 config.zai.enabled = false;
1562 config.openrouter.enabled = false;
1563 config.commandcode.enabled = false;
1564 config.anthropic.accounts_dir = Some(td.path().to_path_buf());
1565
1566 let tabs = tabs_from_config(&config);
1567 assert_eq!(
1568 tabs,
1569 vec![
1570 TabId::vendor(VendorId::Anthropic),
1571 TabId::account("personal"), TabId::account("work"),
1573 ]
1574 );
1575 }
1576
1577 #[test]
1578 fn desktop_labels_become_account_tabs_after_cli_accounts() {
1579 let config = config_with_accounts(&["work"]);
1581 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1582 assert_eq!(
1583 tabs,
1584 vec![
1585 TabId::vendor(VendorId::Anthropic),
1586 TabId::account("work"),
1587 TabId::desktop_account("gmail"),
1588 TabId::desktop_account("hotmail"),
1589 ]
1590 );
1591 }
1592
1593 #[test]
1594 fn a_desktop_profile_wins_a_label_collision_with_a_cli_account() {
1595 let config = config_with_accounts(&["gmail", "work"]);
1600 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1601 assert_eq!(
1602 tabs,
1603 vec![
1604 TabId::vendor(VendorId::Anthropic),
1605 TabId::account("work"),
1606 TabId::desktop_account("gmail"),
1607 TabId::desktop_account("hotmail"),
1608 ]
1609 );
1610 }
1611
1612 #[test]
1613 fn desktop_accounts_suppress_the_default_tab_like_named_ones() {
1614 let mut config = config_with_accounts(&[]);
1617 config.cursor.enabled = false;
1618 config.anthropic.show_default_account = false;
1619
1620 assert_eq!(
1623 build_tabs(&config, &[]),
1624 vec![TabId::vendor(VendorId::Anthropic)]
1625 );
1626 assert_eq!(
1628 build_tabs(&config, &["gmail".into()]),
1629 vec![TabId::desktop_account("gmail")]
1630 );
1631 }
1632
1633 #[test]
1634 fn set_tabs_resets_states_and_clamps_selection() {
1635 let mut app = App::with_theme(
1639 tabs_from_config(&config_with_accounts(&["work", "personal"])),
1640 Theme::default(),
1641 );
1642 app.active = 2; app.tabs[0] = TabState::error("old");
1644 let old_tab = app.tabs_meta[0].clone();
1645 assert!(app.begin_refresh(&old_tab));
1646
1647 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
1648 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
1649 assert_eq!(app.active, 0, "selection clamped after shrink");
1650 assert!(matches!(app.tabs[0], TabState::Loading));
1651 assert!(!app.is_refreshing(&old_tab));
1652 }
1653
1654 #[test]
1655 fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
1656 let mut app = App::with_theme(
1657 vec![
1658 TabId::vendor(VendorId::Anthropic),
1659 TabId::vendor(VendorId::Openai),
1660 ],
1661 Theme::default(),
1662 );
1663 app.active = 1;
1664
1665 app.set_tabs(vec![
1666 TabId::vendor(VendorId::Anthropic),
1667 TabId::account("work"),
1668 TabId::vendor(VendorId::Openai),
1669 ]);
1670
1671 assert_eq!(app.active, 2);
1672 assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
1673 }
1674
1675 #[test]
1676 fn refresh_from_old_generation_is_discarded() {
1677 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1678 let old_generation = app.tab_generation;
1679 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
1680
1681 assert!(!app.apply_refresh(
1682 old_generation,
1683 &TabId::vendor(VendorId::Anthropic),
1684 TabState::error("old result"),
1685 ));
1686 assert!(matches!(app.tabs[0], TabState::Loading));
1687 }
1688
1689 #[test]
1690 fn refresh_identity_mismatch_is_discarded() {
1691 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1692 let generation = app.tab_generation;
1693
1694 assert!(!app.apply_refresh(
1695 generation,
1696 &TabId::vendor(VendorId::Openai),
1697 TabState::error("wrong tab"),
1698 ));
1699 assert!(matches!(app.tabs[0], TabState::Loading));
1700 }
1701
1702 #[test]
1703 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
1704 let anthropic = TabId::vendor(VendorId::Anthropic);
1705 let openai = TabId::vendor(VendorId::Openai);
1706 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
1707 let generation = app.tab_generation;
1708 assert!(app.begin_refresh(&anthropic));
1709
1710 app.tabs_meta.swap(0, 1);
1713 app.tabs.swap(0, 1);
1714 assert!(app.apply_refresh(generation, &anthropic, TabState::error("ready")));
1715 assert!(matches!(app.tabs[0], TabState::Loading));
1716 assert!(matches!(&app.tabs[1], TabState::Error { message, .. } if message == "ready"));
1717 assert!(!app.is_refreshing(&anthropic));
1718 }
1719
1720 fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
1721 TabState::Ready(Box::new(ReadyTab {
1722 snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
1723 label: "test".into(),
1724 total_credits: 0.0,
1725 total_usage: 0.0,
1726 usage_daily: 0.0,
1727 usage_weekly: 0.0,
1728 usage_monthly: 0.0,
1729 is_free_tier: false,
1730 limit: None,
1731 limit_remaining: None,
1732 recent_models: Vec::new(),
1733 }),
1734 stale: false,
1735 last_error: None,
1736 fetched_at: Some(fetched_at),
1737 display: Default::default(),
1738 }))
1739 }
1740
1741 #[test]
1742 fn refresh_keeps_ready_snapshot_visible_and_suppresses_duplicates() {
1743 let tab = TabId::vendor(VendorId::Openrouter);
1744 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1745 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1746 app.tabs[0] = ready_at(fetched_at);
1747
1748 assert!(app.begin_refresh(&tab));
1749 assert!(
1750 !app.begin_refresh(&tab),
1751 "duplicate request must be suppressed"
1752 );
1753 assert!(app.is_refreshing(&tab));
1754 match &app.tabs[0] {
1755 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1756 other => panic!("ready snapshot disappeared during refresh: {other:?}"),
1757 }
1758 }
1759
1760 #[test]
1761 fn first_refresh_still_uses_loading_until_data_arrives() {
1762 let tab = TabId::vendor(VendorId::Openrouter);
1763 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1764
1765 assert!(app.begin_refresh(&tab));
1766 assert!(app.is_refreshing(&tab));
1767 assert!(matches!(app.tabs[0], TabState::Loading));
1768
1769 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("not signed in"),));
1770 assert!(!app.is_refreshing(&tab));
1771 assert!(
1772 matches!(&app.tabs[0], TabState::Error { message, .. } if message == "not signed in")
1773 );
1774 }
1775
1776 #[test]
1777 fn successful_revalidation_replaces_snapshot_and_clears_indicator() {
1778 let tab = TabId::vendor(VendorId::Openrouter);
1779 let old_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1780 let new_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 1, 0).unwrap();
1781 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1782 app.tabs[0] = ready_at(old_at);
1783
1784 assert!(app.begin_refresh(&tab));
1785 assert!(app.apply_refresh(app.tab_generation, &tab, ready_at(new_at)));
1786 assert!(!app.is_refreshing(&tab));
1787 match &app.tabs[0] {
1788 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(new_at)),
1789 other => panic!("expected replacement snapshot, got {other:?}"),
1790 }
1791 }
1792
1793 #[test]
1794 fn failed_revalidation_preserves_snapshot_with_visible_warning() {
1795 let tab = TabId::vendor(VendorId::Openrouter);
1796 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1797 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1798 app.tabs[0] = ready_at(fetched_at);
1799
1800 assert!(app.begin_refresh(&tab));
1801 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("refresh failed"),));
1802 assert!(!app.is_refreshing(&tab));
1803 match &app.tabs[0] {
1804 TabState::Ready(ready) => {
1805 assert_eq!(ready.fetched_at, Some(fetched_at));
1806 assert!(ready.stale);
1807 assert_eq!(ready.last_error, Some((0, "refresh failed".into())));
1808 }
1809 other => panic!("last successful snapshot was lost: {other:?}"),
1810 }
1811 let sections = crate::tui::panels::sections_for(&app.tabs[0], Utc::now(), 5);
1812 assert!(sections.iter().any(|section| matches!(
1813 section,
1814 crate::tui::panels::Section::Text { label, value }
1815 if label == "Warning" && value == "refresh failed"
1816 )));
1817 }
1818
1819 #[test]
1820 fn old_generation_result_does_not_clear_current_refresh() {
1821 let tab = TabId::vendor(VendorId::Openrouter);
1822 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1823 let old_generation = app.tab_generation;
1824 app.set_tabs(vec![tab.clone()]);
1825 assert!(app.begin_refresh(&tab));
1826
1827 assert!(!app.apply_refresh(old_generation, &tab, TabState::error("old result"),));
1828 assert!(app.is_refreshing(&tab));
1829 assert!(matches!(app.tabs[0], TabState::Loading));
1830 }
1831
1832 #[test]
1833 fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
1834 let anthropic = TabId::vendor(VendorId::Anthropic);
1840 let openai = TabId::vendor(VendorId::Openai);
1841 let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
1842 let generation = app.tab_generation;
1843 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1844
1845 assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
1846 match &app.tabs[0] {
1847 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1848 other => panic!("expected Anthropic tab Ready, got {other:?}"),
1849 }
1850 assert!(matches!(app.tabs[1], TabState::Loading));
1851 }
1852
1853 #[test]
1854 fn select_primary_lands_on_default_account_tab() {
1855 let app = {
1858 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
1859 let mut a = App::with_theme(tabs, Theme::default());
1860 a.select_primary(Some(VendorId::Anthropic));
1861 a
1862 };
1863 assert_eq!(app.active, 0);
1864 assert_eq!(
1865 app.active_tab_id(),
1866 Some(&TabId::vendor(VendorId::Anthropic))
1867 );
1868 }
1869
1870 fn custom_spec(id: &str, enabled: bool) -> CustomProviderConfig {
1871 CustomProviderConfig {
1872 id: id.into(),
1873 name: "My Tool".into(),
1874 short_name: "myt".into(),
1875 enabled,
1876 ..Default::default()
1877 }
1878 }
1879
1880 #[test]
1881 fn custom_providers_get_tabs_after_every_builtin() {
1882 let config = Config {
1883 custom: vec![custom_spec("mytool", true), custom_spec("other", true)],
1884 ..Default::default()
1885 };
1886 let tabs = tabs_from_config(&config);
1887 let builtin_count = config.enabled_vendors().len();
1888 assert_eq!(tabs.len(), builtin_count + 2);
1889 assert!(
1890 tabs[..builtin_count]
1891 .iter()
1892 .all(|t| matches!(t.source, TabSource::Builtin(_)))
1893 );
1894 assert_eq!(tabs[builtin_count], TabId::custom(&config.custom[0]));
1895 assert_eq!(tabs[builtin_count + 1], TabId::custom(&config.custom[1]));
1896 assert_eq!(
1897 tabs[builtin_count].source,
1898 TabSource::Custom {
1899 id: "mytool".into(),
1900 name: "My Tool".into(),
1901 short_name: "myt".into(),
1902 }
1903 );
1904 assert!(tabs[builtin_count].account.is_none());
1905 assert!(!tabs[builtin_count].desktop);
1906 assert_eq!(tabs[builtin_count].vendor_id(), None);
1907 }
1908
1909 #[test]
1910 fn disabled_custom_provider_has_no_tab() {
1911 let config = Config {
1912 custom: vec![custom_spec("mytool", false)],
1913 ..Default::default()
1914 };
1915 let tabs = tabs_from_config(&config);
1916 assert!(
1917 tabs.iter()
1918 .all(|t| matches!(t.source, TabSource::Builtin(_)))
1919 );
1920 assert_eq!(tabs.len(), config.enabled_vendors().len());
1921 }
1922
1923 #[test]
1924 fn custom_tabs_are_listed_by_the_default_overview_but_not_by_a_vendor_filter() {
1925 let spec = custom_spec("mytool", true);
1926 let mut app = App::with_theme(
1927 vec![
1928 TabId::vendor(VendorId::Anthropic),
1929 TabId::custom(&spec),
1930 TabId::vendor(VendorId::Openai),
1931 ],
1932 Theme::default(),
1933 );
1934 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1935
1936 app.overview_vendors = Some(vec![VendorId::Openai, VendorId::Anthropic]);
1938 assert_eq!(app.overview_tabs(), vec![2, 0]);
1939 }
1940
1941 #[test]
1942 fn a_custom_active_tab_has_no_vendor_and_never_staggers() {
1943 let spec = custom_spec("mytool", true);
1944 let tabs = vec![
1945 TabId::vendor(VendorId::Anthropic),
1946 TabId::custom(&spec),
1947 TabId::account("work"),
1948 ];
1949 let mut app = App::with_theme(tabs.clone(), Theme::default());
1950 app.active = 1;
1951 assert_eq!(app.active_vendor(), None);
1952 assert_eq!(app.active_tab_id(), Some(&TabId::custom(&spec)));
1953
1954 app.select_primary(Some(VendorId::Anthropic));
1956 assert_eq!(app.active, 0);
1957
1958 let step = Duration::from_millis(800);
1959 assert_eq!(
1960 refresh_stagger(&tabs, step),
1961 vec![Duration::ZERO, Duration::ZERO, step]
1962 );
1963 }
1964}