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