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