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::outcome::fetch_api_keyed;
14use crate::theme::Theme;
15use crate::tui::settings::SettingsRow;
16use crate::vendor::{VendorId, VendorOutcome};
17
18#[derive(Debug, Clone)]
24pub enum TabState {
25 Loading,
26 Ready(Box<ReadyTab>),
27 Error {
28 message: String,
29 plan: Option<String>,
33 },
34}
35
36#[derive(Debug, Clone)]
37pub struct ReadyTab {
38 pub snapshot: crate::usage::VendorSnapshot,
39 pub stale: bool,
40 pub last_error: Option<(u16, String)>,
41 pub fetched_at: Option<chrono::DateTime<chrono::Utc>>,
46 pub display: crate::balance::DisplayPrefs,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Hash)]
58pub enum TabSource {
59 Builtin(VendorId),
60 Custom {
61 id: String,
62 name: String,
63 short_name: String,
64 },
65}
66
67impl TabState {
68 pub fn error(message: impl AsRef<str>) -> Self {
69 Self::error_with_plan(message, None)
70 }
71
72 pub fn error_with_plan(message: impl AsRef<str>, plan: Option<String>) -> Self {
73 let plan = plan.and_then(|plan| {
74 let cleaned = crate::display::sanitize_untrusted_field(plan.trim());
75 if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("unknown") {
76 None
77 } else {
78 Some(cleaned)
79 }
80 });
81 Self::Error {
82 message: message.as_ref().to_string(),
83 plan,
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Hash)]
95pub struct TabId {
96 pub source: TabSource,
97 pub account: Option<String>,
98 pub desktop: bool,
99}
100
101impl TabId {
102 pub fn vendor(vendor: VendorId) -> Self {
104 Self {
105 source: TabSource::Builtin(vendor),
106 account: None,
107 desktop: false,
108 }
109 }
110
111 pub fn account(label: impl Into<String>) -> Self {
113 Self::account_for(VendorId::Anthropic, label)
114 }
115
116 pub fn account_for(vendor: VendorId, label: impl Into<String>) -> Self {
118 Self {
119 source: TabSource::Builtin(vendor),
120 account: Some(label.into()),
121 desktop: false,
122 }
123 }
124
125 pub fn desktop_account(label: impl Into<String>) -> Self {
128 Self {
129 source: TabSource::Builtin(VendorId::Anthropic),
130 account: Some(label.into()),
131 desktop: true,
132 }
133 }
134
135 pub fn custom(spec: &CustomProviderConfig) -> Self {
137 Self {
138 source: TabSource::Custom {
139 id: spec.id.clone(),
140 name: spec.name.clone(),
141 short_name: spec.short_name.clone(),
142 },
143 account: None,
144 desktop: false,
145 }
146 }
147
148 pub fn vendor_id(&self) -> Option<VendorId> {
151 match &self.source {
152 TabSource::Builtin(vendor) => Some(*vendor),
153 TabSource::Custom { .. } => None,
154 }
155 }
156}
157
158pub fn tabs_from_config(config: &Config) -> Vec<TabId> {
169 build_tabs(config, &[])
170}
171
172pub fn tabs_with_desktop(config: &Config) -> Vec<TabId> {
176 build_tabs(config, &desktop_profile_labels(config))
177}
178
179fn vendors_for_tabs(config: &Config) -> Vec<VendorId> {
182 let enabled = config.enabled_vendors();
183 let Some(wanted) = config.ui.overview_vendors.as_ref() else {
184 return enabled;
185 };
186 let mut ordered = Vec::with_capacity(enabled.len());
187 for vendor in wanted {
188 if enabled.contains(vendor) && !ordered.contains(vendor) {
189 ordered.push(*vendor);
190 }
191 }
192 for vendor in enabled {
193 if !ordered.contains(&vendor) {
194 ordered.push(vendor);
195 }
196 }
197 ordered
198}
199
200fn build_tabs(config: &Config, desktop_labels: &[String]) -> Vec<TabId> {
213 let desktop_set: HashSet<&str> = desktop_labels.iter().map(String::as_str).collect();
214 let mut tabs = Vec::new();
215 for vendor in vendors_for_tabs(config) {
216 if vendor == VendorId::Anthropic {
217 let accounts: Vec<_> = config
218 .anthropic
219 .all_accounts()
220 .into_iter()
221 .filter(|a| !desktop_set.contains(a.label.as_str()))
222 .collect();
223 if config.anthropic.show_default_account
227 || (accounts.is_empty() && desktop_labels.is_empty())
228 {
229 tabs.push(TabId::vendor(vendor));
230 }
231 for acct in accounts {
232 tabs.push(TabId::account(acct.label));
233 }
234 for label in desktop_labels {
235 tabs.push(TabId::desktop_account(label.clone()));
236 }
237 } else if let Some(accounts) = config.api_key_accounts(vendor) {
238 if config.show_default_api_key_account(vendor) || accounts.is_empty() {
239 tabs.push(TabId::vendor(vendor));
240 }
241 for account in accounts {
242 tabs.push(TabId::account_for(vendor, account.label.clone()));
243 }
244 } else if vendor == VendorId::Openai {
245 if config.openai.show_default_account || config.openai.accounts.is_empty() {
246 tabs.push(TabId::vendor(vendor));
247 }
248 for account in &config.openai.accounts {
249 tabs.push(TabId::account_for(vendor, account.label.clone()));
250 }
251 } else if vendor == VendorId::Copilot {
252 if config.copilot.show_default_account || config.copilot.accounts.is_empty() {
253 tabs.push(TabId::vendor(vendor));
254 }
255 for account in &config.copilot.accounts {
256 tabs.push(TabId::account_for(vendor, account.label.clone()));
257 }
258 } else if vendor == VendorId::Devin {
259 if config.devin.show_default_account || config.devin.accounts.is_empty() {
260 tabs.push(TabId::vendor(vendor));
261 }
262 for account in &config.devin.accounts {
263 tabs.push(TabId::account_for(vendor, account.label.clone()));
264 }
265 } else {
266 tabs.push(TabId::vendor(vendor));
267 }
268 }
269 for spec in config.enabled_custom() {
271 tabs.push(TabId::custom(spec));
272 }
273 tabs
274}
275
276#[cfg(target_os = "macos")]
280fn desktop_profile_labels(config: &Config) -> Vec<String> {
281 let Ok(paths) = crate::claude_desktop::Paths::resolve(&config.anthropic) else {
282 return Vec::new();
283 };
284 if !paths.available() {
285 return Vec::new();
286 }
287 crate::claude_desktop::load_profiles(&paths.profiles_dir)
288 .into_iter()
289 .filter(|p| p.has_credentials)
290 .map(|p| p.label)
291 .collect()
292}
293
294#[cfg(not(target_os = "macos"))]
295fn desktop_profile_labels(_config: &Config) -> Vec<String> {
296 Vec::new()
297}
298
299#[derive(Debug, Default, Clone)]
304pub struct HitTargets {
305 pub nav_entries: Vec<(NavTarget, Rect)>,
307 pub footer_actions: Vec<(FooterAction, Rect)>,
309 pub settings_rows: Vec<(SettingsRow, Rect)>,
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum NavTarget {
317 Overview,
318 Tab(usize),
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub enum FooterAction {
324 Refresh,
325 RefreshAll,
326 Settings,
327 Quit,
328}
329
330#[derive(Debug)]
331pub struct App {
332 pub tabs_meta: Vec<TabId>,
333 pub active: usize,
334 pub tabs: Vec<TabState>,
335 refreshing_tabs: HashSet<TabId>,
338 pub tab_generation: u64,
342 pub overview: bool,
345 pub overview_vendors: Option<Vec<VendorId>>,
347 pub theme: Theme,
348 pub quit: bool,
349 pub settings: Option<crate::tui::settings::SettingsState>,
351 pub context_enabled: bool,
354 pub context_generation: u64,
357 pub context: Option<crate::tui::context::ContextState>,
359 pub vendor_box: crate::config::VendorBoxStyle,
361 pub hit: std::rc::Rc<std::cell::RefCell<HitTargets>>,
363}
364
365impl App {
366 pub fn new(tabs_meta: Vec<TabId>) -> Self {
367 Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
370 }
371
372 pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
378 let n = tabs_meta.len();
379 Self {
380 tabs_meta,
381 active: 0,
382 tabs: vec![TabState::Loading; n],
383 refreshing_tabs: HashSet::new(),
384 tab_generation: 0,
385 overview: false,
386 overview_vendors: None,
387 theme,
388 quit: false,
389 settings: None,
390 context_enabled: false,
391 context_generation: 0,
392 context: None,
393 vendor_box: crate::config::VendorBoxStyle::Sidebar,
394 hit: std::rc::Rc::new(std::cell::RefCell::new(HitTargets::default())),
395 }
396 }
397
398 pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
402 let mut app = Self::new(tabs_meta);
403 if primary.is_some() {
406 app.select_primary(primary);
407 } else {
408 app.overview = true;
409 }
410 app
411 }
412
413 pub fn active_tab_id(&self) -> Option<&TabId> {
414 self.tabs_meta.get(self.active)
415 }
416
417 pub fn active_vendor(&self) -> Option<VendorId> {
418 self.tabs_meta.get(self.active).and_then(TabId::vendor_id)
419 }
420
421 pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
428 let selected = self.active_tab_id().cloned();
429 let fallback = self.active.min(tabs_meta.len().saturating_sub(1));
430 self.tab_generation = self.tab_generation.wrapping_add(1);
431 self.active = selected
432 .as_ref()
433 .and_then(|tab| tabs_meta.iter().position(|candidate| candidate == tab))
434 .unwrap_or(fallback);
435 self.tabs = vec![TabState::Loading; tabs_meta.len()];
436 self.tabs_meta = tabs_meta;
437 self.refreshing_tabs.clear();
438 }
439
440 pub fn begin_refresh(&mut self, tab: &TabId) -> bool {
444 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
445 return false;
446 };
447 if !self.refreshing_tabs.insert(tab.clone()) {
448 return false;
449 }
450 if !matches!(self.tabs[index], TabState::Ready(_)) {
451 self.tabs[index] = TabState::Loading;
452 }
453 true
454 }
455
456 pub fn is_refreshing(&self, tab: &TabId) -> bool {
457 self.refreshing_tabs.contains(tab)
458 }
459
460 pub fn tab_is_refreshing(&self, index: usize) -> bool {
461 self.tabs_meta
462 .get(index)
463 .is_some_and(|tab| self.is_refreshing(tab))
464 }
465
466 pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
471 if generation != self.tab_generation {
472 return false;
473 }
474 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
475 return false;
476 };
477 let was_refreshing = self.refreshing_tabs.remove(tab);
478 if was_refreshing
482 && let TabState::Ready(ready) = &mut self.tabs[index]
483 && let TabState::Error { message, .. } = state
484 {
485 ready.stale = true;
486 ready.last_error = Some((0, message));
487 } else {
488 self.tabs[index] = state;
489 }
490 true
491 }
492
493 pub fn select_primary(&mut self, primary: Option<VendorId>) {
496 if let Some(p) = primary
497 && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor_id() == Some(p))
498 {
499 self.active = idx;
500 self.overview = false;
501 }
502 }
503
504 pub fn next_tab(&mut self) {
507 if self.overview {
508 if !self.tabs_meta.is_empty() {
509 self.overview = false;
510 self.active = 0;
511 }
512 } else if self.active + 1 < self.tabs_meta.len() {
513 self.active += 1;
514 } else {
515 self.overview = true;
516 }
517 }
518
519 pub fn prev_tab(&mut self) {
520 if self.overview {
521 if !self.tabs_meta.is_empty() {
522 self.overview = false;
523 self.active = self.tabs_meta.len() - 1;
524 }
525 } else if self.active > 0 {
526 self.active -= 1;
527 } else {
528 self.overview = true;
529 }
530 }
531
532 pub fn select_overview(&mut self) {
534 self.overview = true;
535 }
536
537 pub fn select_tab(&mut self, index: usize) {
539 if index < self.tabs_meta.len() {
540 self.active = index;
541 self.overview = false;
542 }
543 }
544
545 pub fn nav_from_target(&mut self, target: NavTarget) {
547 match target {
548 NavTarget::Overview => self.select_overview(),
549 NavTarget::Tab(index) => self.select_tab(index),
550 }
551 }
552
553 pub fn overview_tabs(&self) -> Vec<usize> {
559 match &self.overview_vendors {
560 None => (0..self.tabs_meta.len()).collect(),
561 Some(wanted) => {
562 let mut seen = HashSet::new();
563 wanted
564 .iter()
565 .filter(|vendor| seen.insert(**vendor))
566 .flat_map(|v| {
567 self.tabs_meta
568 .iter()
569 .enumerate()
570 .filter(move |(_, t)| t.vendor_id() == Some(*v))
571 .map(|(i, _)| i)
572 })
573 .collect()
574 }
575 }
576 }
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq)]
588pub enum Freshness {
589 Cached,
590 Wire,
591}
592
593impl Freshness {
594 pub fn ttl(self) -> Duration {
597 match self {
598 Freshness::Cached => DEFAULT_TTL,
599 Freshness::Wire => Duration::ZERO,
600 }
601 }
602}
603
604pub async fn refresh_one(
606 client: &Client,
607 config: &Config,
608 tab: &TabId,
609 freshness: Freshness,
610) -> TabState {
611 match build_outcome(client, config, tab, freshness).await {
612 Ok(outcome) => {
613 let now = Utc::now();
618 let off_the_wire = outcome.off_the_wire();
619 let fetched_at = outcome
620 .cache_age
621 .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
622 let state = TabState::Ready(Box::new(ReadyTab {
623 snapshot: outcome.snapshot,
624 stale: outcome.stale,
625 last_error: outcome.last_error.map(|(code, message)| {
626 (code, crate::display::sanitize_untrusted_field(&message))
627 }),
628 fetched_at,
629 display: match &tab.source {
630 TabSource::Builtin(vendor) => config.display_prefs(*vendor),
631 TabSource::Custom { .. } => crate::balance::DisplayPrefs::default(),
634 },
635 }));
636 if off_the_wire
642 && config.notifications.enabled
643 && let Some(input) = crate::notify::RefreshInput::from_tab(tab, &state, now)
644 {
645 let _ = crate::notify::run(input, config.notifications.threshold).await;
646 }
647 state
648 }
649 Err(e) => TabState::error_with_plan(
650 crate::display::sanitize_untrusted_field(&e.user_message()),
651 e.plan().map(str::to_string),
652 ),
653 }
654}
655
656fn api_key_and_cache(
660 config: &Config,
661 vendor: VendorId,
662 account: Option<&str>,
663) -> Result<(String, crate::cache::Cache)> {
664 let api_key = config.resolve_account_api_key_for(vendor, account)?;
665 let cache = match account {
666 Some(label) => crate::cache::Cache::for_vendor_account(vendor.slug(), label)?,
667 None => crate::cache::Cache::for_vendor(vendor.slug())?,
668 }
669 .redacting(&api_key);
673 Ok((api_key, cache))
674}
675
676async fn build_outcome(
680 client: &Client,
681 config: &Config,
682 tab: &TabId,
683 freshness: Freshness,
684) -> Result<VendorOutcome> {
685 let vendor = match &tab.source {
686 TabSource::Builtin(vendor) => *vendor,
687 TabSource::Custom { id, .. } => {
688 let spec = config.custom_by_id(id).ok_or_else(|| {
691 AppError::Other(format!("custom provider {id} is not configured"))
692 })?;
693 let api_key = spec.resolve_api_key()?;
694 let cache = crate::cache::Cache::for_vendor_account("custom", id)?;
695 let outcome =
696 crate::custom::fetch_snapshot(client, spec, &api_key, &cache, spec.cache_ttl())
697 .await?;
698 return Ok(outcome.into());
699 }
700 };
701 let ttl = freshness.ttl();
705 match vendor {
706 VendorId::Anthropic => {
707 let (creds_target, cache) = match tab.account.as_deref() {
713 Some(label) if tab.desktop => {
714 crate::anthropic::desktop_creds::account_target(config, label)?
715 }
716 Some(label) => config.anthropic.account_target(label)?,
717 None => (
718 config.anthropic.default_creds_target(),
719 crate::cache::Cache::for_vendor("anthropic")?,
720 ),
721 };
722 let endpoints = crate::anthropic::fetch::Endpoints::default();
723 let outcome =
724 crate::anthropic::fetch_snapshot(client, &creds_target, &cache, &endpoints, ttl)
725 .await?;
726 Ok(outcome.map(crate::usage::VendorSnapshot::Anthropic))
727 }
728 VendorId::AnthropicApi => {
729 let key = crate::config::resolve_api_key(
730 "Anthropic_API",
731 &config.anthropic_api.api_key_env,
732 config.anthropic_api.api_key.as_deref(),
733 )?;
734 let cache = crate::cache::Cache::for_vendor("anthropic_api")?.redacting(&key);
735 let endpoints = crate::anthropic_api::fetch::Endpoints::default();
736 let outcome = fetch_api_keyed(
737 async {
738 crate::anthropic_api::fetch_snapshot(
739 client,
740 &key,
741 &cache,
742 &endpoints,
743 ttl,
744 config.anthropic_api.monthly_limit,
745 )
746 .await
747 .map(Into::into)
748 },
749 &key,
750 )
751 .await?;
752 Ok(outcome)
753 }
754 VendorId::Openrouter => {
755 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
756 let management_key = config.openrouter_management_key(tab.account.as_deref());
757 let endpoints = crate::openrouter::fetch::Endpoints::default();
758 let outcome = fetch_api_keyed(
759 async {
760 crate::openrouter::fetch_snapshot(
761 client,
762 &api_key,
763 management_key.as_deref(),
764 &cache,
765 &endpoints,
766 ttl,
767 )
768 .await
769 .map(Into::into)
770 },
771 &api_key,
772 )
773 .await?;
774 Ok(outcome)
775 }
776 VendorId::Zai => {
777 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
778 let endpoints = crate::zai::fetch::Endpoints::default();
779 let outcome = fetch_api_keyed(
780 async {
781 crate::zai::fetch_snapshot(
782 client,
783 &api_key,
784 &cache,
785 &endpoints,
786 ttl,
787 config.zai.plan_tier.as_deref(),
788 )
789 .await
790 .map(Into::into)
791 },
792 &api_key,
793 )
794 .await?;
795 Ok(outcome)
796 }
797 VendorId::Openai => {
798 let label = tab.account.as_deref();
799 let cache = match label {
800 Some(label) => crate::cache::Cache::for_vendor_account("openai", label)?,
801 None => crate::cache::Cache::for_vendor("openai")?,
802 };
803 let route = || config.openai.fetch_auth_path(label);
804 let endpoints = crate::openai::fetch::Endpoints::default();
805 let outcome =
806 crate::openai::fetch_snapshot_routed(client, route, &cache, &endpoints, ttl)
807 .await?;
808 Ok(outcome.into())
809 }
810 VendorId::Copilot => {
811 let label = tab.account.as_deref();
812 let token = config.copilot.resolve_token_async(label).await?;
813 let cache = match label {
814 Some(label) => crate::cache::Cache::for_vendor_account("copilot", label)?,
815 None => crate::cache::Cache::for_vendor("copilot")?,
816 };
817 let endpoints = crate::copilot::fetch::Endpoints::default();
818 let outcome =
819 crate::copilot::fetch_snapshot(client, &token, &cache, &endpoints, ttl).await?;
820 Ok(outcome.into())
821 }
822 VendorId::Deepseek => {
823 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
824 let endpoints = crate::deepseek::fetch::Endpoints::default();
825 let outcome = fetch_api_keyed(
826 async {
827 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, ttl)
828 .await
829 .map(Into::into)
830 },
831 &api_key,
832 )
833 .await?;
834 Ok(outcome)
835 }
836 VendorId::Deepinfra => {
837 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
838 let endpoints = crate::deepinfra::fetch::Endpoints::default();
839 let outcome = fetch_api_keyed(
840 async {
841 crate::deepinfra::fetch::fetch_snapshot(
842 client, &api_key, &cache, &endpoints, ttl,
843 )
844 .await
845 .map(Into::into)
846 },
847 &api_key,
848 )
849 .await?;
850 Ok(outcome)
851 }
852 VendorId::Kimi => {
853 let (auth, endpoints) = crate::kimi::resolve_auth(&config.kimi)?;
854 let secret = match &auth {
860 crate::kimi::fetch::Auth::ApiKey(key) => key.clone(),
861 crate::kimi::fetch::Auth::KimiCode(_) => String::new(),
862 };
863 let cache = crate::cache::Cache::for_vendor("kimi")?.redacting(&secret);
864 let outcome = fetch_api_keyed(
865 async {
866 crate::kimi::fetch::fetch_snapshot_with_auth(
867 client, &auth, &cache, &endpoints, ttl,
868 )
869 .await
870 .map(Into::into)
871 },
872 &secret,
873 )
874 .await?;
875 Ok(outcome)
876 }
877 VendorId::Kilo => {
878 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
879 let endpoints = crate::kilo::fetch::Endpoints::default();
880 let outcome = fetch_api_keyed(
881 async {
882 crate::kilo::fetch_snapshot(
883 client,
884 &api_key,
885 &cache,
886 &endpoints,
887 ttl,
888 config.kilo.organization_id.as_deref(),
889 )
890 .await
891 .map(Into::into)
892 },
893 &api_key,
894 )
895 .await?;
896 Ok(outcome)
897 }
898 VendorId::Novita => {
899 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
900 let endpoints = crate::novita::fetch::Endpoints::default();
901 let outcome = fetch_api_keyed(
902 async {
903 crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, ttl)
904 .await
905 .map(Into::into)
906 },
907 &api_key,
908 )
909 .await?;
910 Ok(outcome)
911 }
912 VendorId::Synthetic => {
913 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
914 let endpoints = crate::synthetic::fetch::Endpoints::default();
915 let outcome = fetch_api_keyed(
916 async {
917 crate::synthetic::fetch_snapshot(
918 client,
919 &api_key,
920 &cache,
921 &endpoints,
922 ttl,
923 config.synthetic.plan_tier.as_deref(),
924 )
925 .await
926 .map(Into::into)
927 },
928 &api_key,
929 )
930 .await?;
931 Ok(outcome)
932 }
933 VendorId::DeepRelay => {
934 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
935 let endpoints = crate::deeprelay::fetch::Endpoints::default();
936 let outcome = fetch_api_keyed(
937 async {
938 crate::deeprelay::fetch_snapshot(client, &api_key, &cache, &endpoints, ttl)
939 .await
940 .map(Into::into)
941 },
942 &api_key,
943 )
944 .await?;
945 Ok(outcome)
946 }
947 VendorId::Mistral => {
948 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
949 let endpoints = crate::mistral::fetch::Endpoints::default();
950 let outcome = fetch_api_keyed(
951 async {
952 crate::mistral::fetch_snapshot(client, &api_key, &cache, &endpoints, ttl)
953 .await
954 .map(Into::into)
955 },
956 &api_key,
957 )
958 .await?;
959 Ok(outcome)
960 }
961 VendorId::Moonshot => {
962 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
963 let (endpoints, currency) =
964 crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
965 let outcome = fetch_api_keyed(
966 async {
967 crate::moonshot::fetch_snapshot(
968 client, &api_key, &cache, &endpoints, ttl, currency,
969 )
970 .await
971 .map(Into::into)
972 },
973 &api_key,
974 )
975 .await?;
976 Ok(outcome)
977 }
978 VendorId::Grok => {
979 let (key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
980 let endpoints = crate::grok::fetch::Endpoints::default();
981 let outcome = fetch_api_keyed(
982 async {
983 crate::grok::fetch_snapshot(
984 client,
985 &key,
986 &cache,
987 &endpoints,
988 ttl,
989 config.grok.team_id.as_deref(),
990 )
991 .await
992 .map(Into::into)
993 },
994 &key,
995 )
996 .await?;
997 Ok(outcome)
998 }
999 VendorId::Supergrok => {
1000 let cache = crate::cache::Cache::for_vendor("supergrok")?;
1001 let scope_paths = crate::supergrok::scope::ScopePaths::with_overrides(
1002 config.supergrok.auth_path.as_deref(),
1003 config.supergrok.config_path.as_deref(),
1004 )?;
1005 let outcome = crate::supergrok::fetch_snapshot(
1006 &config.supergrok.grok_binary,
1007 &scope_paths,
1008 &cache,
1009 ttl,
1010 )
1011 .await?;
1012 Ok(outcome.into())
1013 }
1014 VendorId::Antigravity => {
1015 let cache = crate::cache::Cache::for_vendor("antigravity")?;
1018 let oauth = crate::antigravity::cloud::OauthClient::from_config(
1019 config.antigravity.oauth_client_id.as_deref(),
1020 config.antigravity.oauth_client_secret.as_deref(),
1021 );
1022 let outcome = crate::antigravity::fetch_snapshot(
1023 client,
1024 &cache,
1025 ttl,
1026 oauth.as_ref(),
1027 config.antigravity.agy_binary.as_deref(),
1028 config.antigravity.ide_language_server,
1029 )
1030 .await?;
1031 Ok(outcome.into())
1032 }
1033 VendorId::Grokbot => {
1034 let creds = crate::grokbot::resolve_credentials(&config.grokbot)?;
1037 let cache = crate::cache::Cache::for_vendor("grokbot")?;
1038 let endpoints = crate::grokbot::fetch::Endpoints::default();
1039 let outcome =
1040 crate::grokbot::fetch::fetch_snapshot_with(client, &creds, &cache, &endpoints, ttl)
1041 .await?;
1042 Ok(outcome.into())
1043 }
1044 VendorId::ModelStudio => {
1045 let creds = crate::modelstudio::resolve_credentials(&config.modelstudio)?;
1048 let cache = crate::cache::Cache::for_vendor("modelstudio")?;
1049 let endpoints =
1050 crate::modelstudio::fetch::Endpoints::for_gateway(creds.region, creds.site);
1051 let outcome =
1052 crate::modelstudio::fetch_snapshot_with(client, &creds, &cache, &endpoints, ttl)
1053 .await?;
1054 Ok(outcome.into())
1055 }
1056 VendorId::MuseCode => {
1057 let cache = crate::cache::Cache::for_vendor("muse-code")?;
1058 Ok(crate::muse::fetch::fetch_snapshot(&config.muse, &cache)
1059 .await?
1060 .into())
1061 }
1062 VendorId::Devin => {
1063 let (creds_cfg, cache) = match tab.account.as_deref() {
1064 Some(label) => {
1065 let creds_path = config.devin.resolve_credentials_path(Some(label))?;
1066 let mut acct_cfg = config.devin.clone();
1067 acct_cfg.credentials_path = Some(creds_path);
1068 let cache = crate::cache::Cache::for_vendor_account("devin", label)?;
1069 (acct_cfg, cache)
1070 }
1071 None => (
1072 config.devin.clone(),
1073 crate::cache::Cache::for_vendor("devin")?,
1074 ),
1075 };
1076 let outcome = crate::devin::fetch::fetch_snapshot(&creds_cfg, &cache, ttl).await?;
1077 Ok(outcome.into())
1078 }
1079 VendorId::Droid => {
1080 let cache = crate::cache::Cache::for_vendor("droid")?;
1081 let outcome = crate::droid::fetch::fetch_snapshot(&config.droid, &cache, ttl).await?;
1082 Ok(outcome.into())
1083 }
1084 VendorId::Minimax => {
1085 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
1086 let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
1087 let outcome = fetch_api_keyed(
1088 async {
1089 crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, ttl)
1090 .await
1091 .map(Into::into)
1092 },
1093 &api_key,
1094 )
1095 .await?;
1096 Ok(outcome)
1097 }
1098 VendorId::Cursor => {
1099 let cache = crate::cache::Cache::for_vendor("cursor")?;
1100 let db_path = config
1101 .cursor
1102 .db_path
1103 .clone()
1104 .map(Ok)
1105 .unwrap_or_else(crate::cursor::db::default_db_path)?;
1106 let agent_auth_path = config
1107 .cursor
1108 .agent_auth_path
1109 .clone()
1110 .map(Ok)
1111 .unwrap_or_else(crate::cursor::db::default_agent_auth_path)?;
1112 let endpoints = crate::cursor::fetch::Endpoints::default();
1113 let outcome = crate::cursor::fetch_snapshot(
1114 client,
1115 &db_path,
1116 &agent_auth_path,
1117 &cache,
1118 &endpoints,
1119 ttl,
1120 )
1121 .await?;
1122 Ok(outcome.into())
1123 }
1124 VendorId::Kiro => {
1125 let cache = crate::cache::Cache::for_vendor("kiro")?;
1126 let db_path = config
1127 .kiro
1128 .db_path
1129 .clone()
1130 .map(Ok)
1131 .unwrap_or_else(crate::kiro::db::default_db_path)?;
1132 let outcome = crate::kiro::fetch_snapshot(client, &db_path, &cache, ttl).await?;
1133 Ok(outcome.into())
1134 }
1135 VendorId::NousResearch => {
1136 let store = crate::nous::credentials::CredentialStore::default();
1137 let endpoints = crate::nous::fetch::Endpoints::default();
1138 let account = crate::nous::fetch::fetch_account_with_refresh(
1139 client,
1140 &store,
1141 &endpoints,
1142 Utc::now(),
1143 )
1144 .await?;
1145 Ok(crate::outcome::Outcome::fresh(
1147 crate::usage::VendorSnapshot::NousResearch(account),
1148 ))
1149 }
1150 VendorId::OpenCodeGo => {
1151 let api_key = crate::opencode_go::creds::resolve_key(
1152 &config.opencode_go.api_key_env,
1153 config.opencode_go.api_key.as_deref(),
1154 config.opencode_go.auth_paths.as_deref(),
1155 )?;
1156 let cache = crate::cache::Cache::for_vendor("opencode-go")?.redacting(&api_key);
1157 let endpoints = crate::opencode_go::fetch::Endpoints::default();
1158 let outcome = fetch_api_keyed(
1159 async {
1160 crate::opencode_go::fetch::fetch_snapshot(
1161 client, &api_key, &cache, &endpoints, ttl,
1162 )
1163 .await
1164 .map(Into::into)
1165 },
1166 &api_key,
1167 )
1168 .await?;
1169 Ok(outcome)
1170 }
1171 VendorId::Cline => {
1172 let credential = crate::cline::creds::resolve(
1173 config.cline.auth_paths.as_deref(),
1174 &config.cline.api_key_env,
1175 config.cline.api_key.as_deref(),
1176 )?;
1177 let cache = crate::cache::Cache::for_vendor("cline")?;
1178 let endpoints = crate::cline::fetch::Endpoints::default();
1179 let outcome = crate::cline::fetch::fetch_snapshot(
1180 client,
1181 &credential,
1182 &cache,
1183 &endpoints,
1184 ttl,
1185 Utc::now(),
1186 )
1187 .await?;
1188 Ok(outcome.into())
1189 }
1190 VendorId::CommandCode => {
1191 let credential =
1192 crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref())?;
1193 let cache = crate::cache::Cache::for_vendor("commandcode")?;
1194 let endpoints = crate::commandcode::fetch::Endpoints::default();
1195 let outcome = crate::commandcode::fetch::fetch_snapshot(
1196 client,
1197 &credential.token,
1198 &cache,
1199 &endpoints,
1200 ttl,
1201 )
1202 .await?;
1203 Ok(outcome.into())
1204 }
1205 VendorId::Ollama => {
1206 let api_key = crate::config::resolve_api_key(
1207 "Ollama",
1208 &config.ollama.api_key_env,
1209 config.ollama.api_key.as_deref(),
1210 )?;
1211 let cache = crate::cache::Cache::for_vendor("ollama")?.redacting(&api_key);
1212 let endpoints = crate::ollama::fetch::Endpoints::default();
1213 let outcome = fetch_api_keyed(
1214 async {
1215 crate::ollama::fetch_snapshot(
1216 client,
1217 &api_key,
1218 &config.ollama.plan,
1219 &cache,
1220 &endpoints,
1221 ttl,
1222 )
1223 .await
1224 .map(Into::into)
1225 },
1226 &api_key,
1227 )
1228 .await?;
1229 Ok(outcome)
1230 }
1231 VendorId::OrcaRouter => {
1232 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
1233 let endpoints = crate::orcarouter::fetch::Endpoints::default();
1234 let outcome = fetch_api_keyed(
1235 async {
1236 crate::orcarouter::fetch_snapshot(client, &api_key, &cache, &endpoints, ttl)
1237 .await
1238 .map(Into::into)
1239 },
1240 &api_key,
1241 )
1242 .await?;
1243 Ok(outcome)
1244 }
1245 VendorId::Lyceum => {
1246 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
1247 let outcome = fetch_api_keyed(
1248 async {
1249 crate::lyceum::fetch::fetch_snapshot(client, &api_key, &cache, ttl)
1250 .await
1251 .map(Into::into)
1252 },
1253 &api_key,
1254 )
1255 .await?;
1256 Ok(outcome)
1257 }
1258 }
1259}
1260
1261pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
1264
1265pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
1272
1273pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
1279 let mut anthropic_seen: u32 = 0;
1280 tabs.iter()
1281 .map(|tab| {
1282 if tab.vendor_id() == Some(VendorId::Anthropic) {
1283 let delay = step * anthropic_seen;
1284 anthropic_seen += 1;
1285 delay
1286 } else {
1287 Duration::ZERO
1288 }
1289 })
1290 .collect()
1291}
1292
1293#[cfg(test)]
1294mod tests {
1295 use super::*;
1296 use chrono::TimeZone;
1297
1298 #[test]
1302 fn refresh_stagger_spaces_out_anthropic_tabs_only() {
1303 let step = Duration::from_millis(800);
1304 let tabs = vec![
1305 TabId::vendor(VendorId::Anthropic), TabId::account("work"),
1307 TabId::account("personal"),
1308 TabId::vendor(VendorId::Openai),
1309 TabId::vendor(VendorId::Zai),
1310 ];
1311 let delays = refresh_stagger(&tabs, step);
1312 assert_eq!(
1313 delays,
1314 vec![
1315 Duration::ZERO, step, step * 2, Duration::ZERO, Duration::ZERO, ]
1321 );
1322 }
1323
1324 #[test]
1325 fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
1326 let tabs = vec![
1328 TabId::vendor(VendorId::Anthropic),
1329 TabId::vendor(VendorId::Openrouter),
1330 ];
1331 assert!(
1332 refresh_stagger(&tabs, Duration::from_millis(800))
1333 .iter()
1334 .all(|d| d.is_zero())
1335 );
1336 }
1337
1338 #[test]
1339 fn select_primary_moves_to_enabled_vendor() {
1340 let mut app = App::with_theme(
1341 vec![
1342 TabId::vendor(VendorId::Anthropic),
1343 TabId::vendor(VendorId::Openrouter),
1344 ],
1345 Theme::default(),
1346 );
1347 app.select_primary(Some(VendorId::Openrouter));
1348 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
1349 }
1350
1351 #[test]
1352 fn select_primary_ignores_disabled_vendor() {
1353 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1354 app.select_primary(Some(VendorId::Openai));
1355 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
1356 }
1357
1358 #[test]
1359 fn nav_ring_wraps_through_the_overview_at_both_ends() {
1360 let mut app = App::with_theme(
1361 vec![
1362 TabId::vendor(VendorId::Anthropic),
1363 TabId::vendor(VendorId::Openai),
1364 ],
1365 Theme::default(),
1366 );
1367 app.overview = true;
1368
1369 app.next_tab(); assert!(!app.overview);
1371 assert_eq!(app.active, 0);
1372 app.next_tab();
1373 assert_eq!(app.active, 1);
1374 app.next_tab(); assert!(app.overview);
1376
1377 app.prev_tab(); assert!(!app.overview);
1379 assert_eq!(app.active, 1);
1380 app.prev_tab();
1381 assert_eq!(app.active, 0);
1382 app.prev_tab(); assert!(app.overview);
1384 }
1385
1386 #[test]
1387 fn mouse_nav_targets_select_overview_and_tabs() {
1388 let mut app = App::with_theme(
1389 vec![
1390 TabId::vendor(VendorId::Anthropic),
1391 TabId::vendor(VendorId::Openai),
1392 ],
1393 Theme::default(),
1394 );
1395 app.overview = true;
1396 app.nav_from_target(NavTarget::Tab(1));
1397 assert!(!app.overview);
1398 assert_eq!(app.active, 1);
1399 app.nav_from_target(NavTarget::Overview);
1400 assert!(app.overview);
1401 app.nav_from_target(NavTarget::Tab(99));
1403 assert!(app.overview);
1404 }
1405
1406 #[test]
1407 fn hit_targets_default_to_empty() {
1408 let app = App::with_theme(Vec::new(), Theme::default());
1409 assert!(app.hit.borrow().nav_entries.is_empty());
1410 assert!(app.hit.borrow().footer_actions.is_empty());
1411 assert!(app.hit.borrow().settings_rows.is_empty());
1412 }
1413
1414 #[test]
1415 fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
1416 let mut app = App::with_theme(
1417 vec![
1418 TabId::vendor(VendorId::Anthropic),
1419 TabId::vendor(VendorId::Openai),
1420 TabId::vendor(VendorId::Zai),
1421 ],
1422 Theme::default(),
1423 );
1424 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1425
1426 app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
1428 assert_eq!(app.overview_tabs(), vec![2, 0]);
1429
1430 app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
1432 assert_eq!(app.overview_tabs(), vec![1]);
1433
1434 app.overview_vendors = Some(vec![VendorId::Anthropic, VendorId::Anthropic]);
1436 assert_eq!(app.overview_tabs(), vec![0]);
1437 }
1438
1439 fn config_with_accounts(labels: &[&str]) -> Config {
1440 let mut config = Config::default();
1441 config.openai.enabled = false;
1444 config.zai.enabled = false;
1445 config.openrouter.enabled = false;
1446 config.commandcode.enabled = false;
1447 config.anthropic.accounts = labels
1448 .iter()
1449 .map(|l| crate::config::AnthropicAccount {
1450 label: (*l).to_string(),
1451 credentials_path: format!("/creds/{l}.json").into(),
1452 })
1453 .collect();
1454 config
1455 }
1456
1457 #[test]
1458 fn show_default_account_false_hides_the_unnamed_claude_tab() {
1459 let mut config = config_with_accounts(&["work", "personal"]);
1462 config.anthropic.show_default_account = false;
1463 assert_eq!(
1464 tabs_from_config(&config),
1465 vec![TabId::account("work"), TabId::account("personal")]
1466 );
1467
1468 let mut empty = Config::default();
1471 empty.openai.enabled = false;
1472 empty.zai.enabled = false;
1473 empty.openrouter.enabled = false;
1474 empty.commandcode.enabled = false;
1475 empty.anthropic.show_default_account = false;
1476 assert_eq!(
1477 tabs_from_config(&empty),
1478 vec![TabId::vendor(VendorId::Anthropic)]
1479 );
1480 }
1481
1482 #[test]
1483 fn tabs_expand_anthropic_accounts_after_default() {
1484 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
1486 assert_eq!(
1487 tabs,
1488 vec![
1489 TabId::vendor(VendorId::Anthropic),
1490 TabId::account("work"),
1491 TabId::account("personal"),
1492 ]
1493 );
1494 }
1495
1496 #[test]
1497 fn tabs_follow_overview_vendors_and_keep_the_rest() {
1498 let mut config = Config::default();
1499 config.zai.enabled = false;
1500 config.openrouter.enabled = false;
1501 config.commandcode.enabled = false;
1502 config.supergrok.enabled = true;
1503 config.antigravity.enabled = true;
1504 config.opencode_go.enabled = true;
1505 config.copilot.enabled = true;
1506 config.ui.overview_vendors = Some(vec![
1507 VendorId::Anthropic,
1508 VendorId::Zai,
1509 VendorId::Openai,
1510 VendorId::Anthropic,
1511 VendorId::Supergrok,
1512 VendorId::OpenCodeGo,
1513 VendorId::Antigravity,
1514 ]);
1515 let vendors: Vec<VendorId> = tabs_from_config(&config)
1516 .iter()
1517 .filter_map(TabId::vendor_id)
1518 .collect();
1519 assert_eq!(
1520 vendors,
1521 vec![
1522 VendorId::Anthropic,
1523 VendorId::Openai,
1524 VendorId::Supergrok,
1525 VendorId::OpenCodeGo,
1526 VendorId::Antigravity,
1527 VendorId::Copilot,
1528 ]
1529 );
1530 }
1531
1532 #[test]
1533 fn tabs_without_accounts_are_just_enabled_vendors() {
1534 let config = Config::default();
1536 let tabs = tabs_from_config(&config);
1537 let vendors: Vec<VendorId> = tabs.iter().filter_map(TabId::vendor_id).collect();
1538 assert_eq!(vendors, config.enabled_vendors());
1539 assert!(tabs.iter().all(|t| t.account.is_none()));
1540 }
1541
1542 #[test]
1543 fn tabs_expand_openrouter_accounts_without_changing_other_vendors() {
1544 let mut config = Config::default();
1545 config.anthropic.enabled = false;
1546 config.openai.enabled = false;
1547 config.zai.enabled = false;
1548 config.commandcode.enabled = false;
1549 config.openrouter.accounts = vec![
1550 crate::config::ApiKeyAccount {
1551 label: "work".into(),
1552 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1553 api_key: None,
1554 management_api_key_env: None,
1555 },
1556 crate::config::ApiKeyAccount {
1557 label: "personal".into(),
1558 api_key_env: None,
1559 api_key: Some("personal-key".into()),
1560 management_api_key_env: None,
1561 },
1562 ];
1563 assert_eq!(
1564 tabs_from_config(&config),
1565 vec![
1566 TabId::vendor(VendorId::Openrouter),
1567 TabId::account_for(VendorId::Openrouter, "work"),
1568 TabId::account_for(VendorId::Openrouter, "personal"),
1569 ]
1570 );
1571 }
1572
1573 #[test]
1574 fn openai_named_accounts_get_their_own_tabs_after_the_default() {
1575 let mut config = Config::default();
1576 config.anthropic.enabled = false;
1577 config.zai.enabled = false;
1578 config.openrouter.enabled = false;
1579 config.commandcode.enabled = false;
1580 config.openai.accounts.push(crate::config::OpenAiAccount {
1581 label: "work".into(),
1582 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1583 });
1584 assert_eq!(
1585 tabs_from_config(&config),
1586 vec![
1587 TabId::vendor(VendorId::Openai),
1588 TabId::account_for(VendorId::Openai, "work"),
1589 ]
1590 );
1591 }
1592
1593 #[test]
1594 fn copilot_named_accounts_get_their_own_tabs_and_can_hide_the_default() {
1595 let base = || {
1596 let mut config = Config::default();
1597 config.anthropic.enabled = false;
1598 config.openai.enabled = false;
1599 config.zai.enabled = false;
1600 config.openrouter.enabled = false;
1601 config.commandcode.enabled = false;
1602 config.copilot.enabled = true;
1603 config
1604 };
1605
1606 assert_eq!(
1608 tabs_from_config(&base()),
1609 vec![TabId::vendor(VendorId::Copilot)]
1610 );
1611
1612 let mut config = base();
1614 config.copilot.accounts.push(crate::config::CopilotAccount {
1615 label: "work".into(),
1616 user: "octocat-work".into(),
1617 });
1618 config.copilot.accounts.push(crate::config::CopilotAccount {
1619 label: "personal".into(),
1620 user: "octocat".into(),
1621 });
1622 assert_eq!(
1623 tabs_from_config(&config),
1624 vec![
1625 TabId::vendor(VendorId::Copilot),
1626 TabId::account_for(VendorId::Copilot, "work"),
1627 TabId::account_for(VendorId::Copilot, "personal"),
1628 ]
1629 );
1630
1631 config.copilot.show_default_account = false;
1633 assert_eq!(
1634 tabs_from_config(&config),
1635 vec![
1636 TabId::account_for(VendorId::Copilot, "work"),
1637 TabId::account_for(VendorId::Copilot, "personal"),
1638 ]
1639 );
1640
1641 let mut empty = base();
1643 empty.copilot.show_default_account = false;
1644 assert_eq!(
1645 tabs_from_config(&empty),
1646 vec![TabId::vendor(VendorId::Copilot)]
1647 );
1648 }
1649
1650 #[test]
1651 fn devin_named_accounts_get_their_own_tabs_and_can_hide_the_default() {
1652 let base = || {
1653 let mut config = Config::default();
1654 config.anthropic.enabled = false;
1655 config.openai.enabled = false;
1656 config.zai.enabled = false;
1657 config.openrouter.enabled = false;
1658 config.commandcode.enabled = false;
1659 config.devin.enabled = true;
1660 config
1661 };
1662
1663 assert_eq!(
1665 tabs_from_config(&base()),
1666 vec![TabId::vendor(VendorId::Devin)]
1667 );
1668
1669 let mut config = base();
1671 config.devin.accounts.push(crate::config::DevinAccount {
1672 label: "work".into(),
1673 credentials_path: "/tmp/devin-work/credentials.toml".into(),
1674 });
1675 config.devin.accounts.push(crate::config::DevinAccount {
1676 label: "personal".into(),
1677 credentials_path: "/tmp/devin-personal/credentials.toml".into(),
1678 });
1679 assert_eq!(
1680 tabs_from_config(&config),
1681 vec![
1682 TabId::vendor(VendorId::Devin),
1683 TabId::account_for(VendorId::Devin, "work"),
1684 TabId::account_for(VendorId::Devin, "personal"),
1685 ]
1686 );
1687
1688 config.devin.show_default_account = false;
1690 assert_eq!(
1691 tabs_from_config(&config),
1692 vec![
1693 TabId::account_for(VendorId::Devin, "work"),
1694 TabId::account_for(VendorId::Devin, "personal"),
1695 ]
1696 );
1697
1698 let mut empty = base();
1700 empty.devin.show_default_account = false;
1701 assert_eq!(
1702 tabs_from_config(&empty),
1703 vec![TabId::vendor(VendorId::Devin)]
1704 );
1705 }
1706
1707 #[test]
1708 fn openrouter_can_hide_default_only_when_named_accounts_exist() {
1709 let mut config = Config::default();
1710 config.anthropic.enabled = false;
1711 config.openai.enabled = false;
1712 config.zai.enabled = false;
1713 config.commandcode.enabled = false;
1714 config.openrouter.show_default_account = false;
1715 assert_eq!(
1716 tabs_from_config(&config),
1717 vec![TabId::vendor(VendorId::Openrouter)]
1718 );
1719
1720 config
1721 .openrouter
1722 .accounts
1723 .push(crate::config::ApiKeyAccount {
1724 label: "work".into(),
1725 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1726 api_key: None,
1727 management_api_key_env: None,
1728 });
1729 assert_eq!(
1730 tabs_from_config(&config),
1731 vec![TabId::account_for(VendorId::Openrouter, "work")]
1732 );
1733 }
1734
1735 #[test]
1736 fn every_api_key_account_vendor_fans_out_like_openrouter() {
1737 let config_for = |vendor: VendorId, show_default: bool| -> Config {
1738 let section = vendor.config_section();
1739 let mut text: String = ["anthropic", "openai", "commandcode", "zai", "openrouter"]
1742 .into_iter()
1743 .filter(|other| *other != section)
1744 .map(|other| format!("[{other}]\nenabled = false\n"))
1745 .collect();
1746 text.push_str(&format!(
1747 "[{section}]\nenabled = true\nshow_default_account = {show_default}\n\
1748 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k1\"\n\
1749 [[{section}.accounts]]\nlabel = \"personal\"\napi_key = \"k2\"\n"
1750 ));
1751 let config: Config = toml::from_str(&text).unwrap();
1752 config.validate().unwrap();
1753 config
1754 };
1755 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
1756 let named = vec![
1757 TabId::account_for(vendor, "work"),
1758 TabId::account_for(vendor, "personal"),
1759 ];
1760 let mut with_default = vec![TabId::vendor(vendor)];
1761 with_default.extend(named.clone());
1762 assert_eq!(
1763 tabs_from_config(&config_for(vendor, true)),
1764 with_default,
1765 "{vendor:?}"
1766 );
1767 assert_eq!(
1768 tabs_from_config(&config_for(vendor, false)),
1769 named,
1770 "{vendor:?}"
1771 );
1772 }
1773 }
1774
1775 #[test]
1776 fn codex_can_hide_default_only_when_named_accounts_exist() {
1777 let mut config = Config::default();
1778 config.anthropic.enabled = false;
1779 config.zai.enabled = false;
1780 config.openrouter.enabled = false;
1781 config.commandcode.enabled = false;
1782 config.openai.show_default_account = false;
1783 assert_eq!(
1784 tabs_from_config(&config),
1785 vec![TabId::vendor(VendorId::Openai)]
1786 );
1787
1788 config.openai.accounts.push(crate::config::OpenAiAccount {
1789 label: "work".into(),
1790 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1791 });
1792 assert_eq!(
1793 tabs_from_config(&config),
1794 vec![TabId::account_for(VendorId::Openai, "work")]
1795 );
1796 }
1797
1798 #[test]
1799 fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
1800 let td = tempfile::tempdir().unwrap();
1803 for label in ["work", "personal"] {
1804 let dir = td.path().join(label);
1805 std::fs::create_dir_all(&dir).unwrap();
1806 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1807 }
1808 let mut config = Config::default();
1809 config.openai.enabled = false;
1810 config.zai.enabled = false;
1811 config.openrouter.enabled = false;
1812 config.commandcode.enabled = false;
1813 config.anthropic.accounts_dir = Some(td.path().to_path_buf());
1814
1815 let tabs = tabs_from_config(&config);
1816 assert_eq!(
1817 tabs,
1818 vec![
1819 TabId::vendor(VendorId::Anthropic),
1820 TabId::account("personal"), TabId::account("work"),
1822 ]
1823 );
1824 }
1825
1826 #[test]
1827 fn desktop_labels_become_account_tabs_after_cli_accounts() {
1828 let config = config_with_accounts(&["work"]);
1830 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1831 assert_eq!(
1832 tabs,
1833 vec![
1834 TabId::vendor(VendorId::Anthropic),
1835 TabId::account("work"),
1836 TabId::desktop_account("gmail"),
1837 TabId::desktop_account("hotmail"),
1838 ]
1839 );
1840 }
1841
1842 #[test]
1843 fn a_desktop_profile_wins_a_label_collision_with_a_cli_account() {
1844 let config = config_with_accounts(&["gmail", "work"]);
1849 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1850 assert_eq!(
1851 tabs,
1852 vec![
1853 TabId::vendor(VendorId::Anthropic),
1854 TabId::account("work"),
1855 TabId::desktop_account("gmail"),
1856 TabId::desktop_account("hotmail"),
1857 ]
1858 );
1859 }
1860
1861 #[test]
1862 fn desktop_accounts_suppress_the_default_tab_like_named_ones() {
1863 let mut config = config_with_accounts(&[]);
1866 config.cursor.enabled = false;
1867 config.anthropic.show_default_account = false;
1868
1869 assert_eq!(
1872 build_tabs(&config, &[]),
1873 vec![TabId::vendor(VendorId::Anthropic)]
1874 );
1875 assert_eq!(
1877 build_tabs(&config, &["gmail".into()]),
1878 vec![TabId::desktop_account("gmail")]
1879 );
1880 }
1881
1882 #[test]
1883 fn set_tabs_resets_states_and_clamps_selection() {
1884 let mut app = App::with_theme(
1888 tabs_from_config(&config_with_accounts(&["work", "personal"])),
1889 Theme::default(),
1890 );
1891 app.active = 2; app.tabs[0] = TabState::error("old");
1893 let old_tab = app.tabs_meta[0].clone();
1894 assert!(app.begin_refresh(&old_tab));
1895
1896 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
1897 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
1898 assert_eq!(app.active, 0, "selection clamped after shrink");
1899 assert!(matches!(app.tabs[0], TabState::Loading));
1900 assert!(!app.is_refreshing(&old_tab));
1901 }
1902
1903 #[test]
1904 fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
1905 let mut app = App::with_theme(
1906 vec![
1907 TabId::vendor(VendorId::Anthropic),
1908 TabId::vendor(VendorId::Openai),
1909 ],
1910 Theme::default(),
1911 );
1912 app.active = 1;
1913
1914 app.set_tabs(vec![
1915 TabId::vendor(VendorId::Anthropic),
1916 TabId::account("work"),
1917 TabId::vendor(VendorId::Openai),
1918 ]);
1919
1920 assert_eq!(app.active, 2);
1921 assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
1922 }
1923
1924 #[test]
1925 fn refresh_from_old_generation_is_discarded() {
1926 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1927 let old_generation = app.tab_generation;
1928 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
1929
1930 assert!(!app.apply_refresh(
1931 old_generation,
1932 &TabId::vendor(VendorId::Anthropic),
1933 TabState::error("old result"),
1934 ));
1935 assert!(matches!(app.tabs[0], TabState::Loading));
1936 }
1937
1938 #[test]
1939 fn refresh_identity_mismatch_is_discarded() {
1940 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1941 let generation = app.tab_generation;
1942
1943 assert!(!app.apply_refresh(
1944 generation,
1945 &TabId::vendor(VendorId::Openai),
1946 TabState::error("wrong tab"),
1947 ));
1948 assert!(matches!(app.tabs[0], TabState::Loading));
1949 }
1950
1951 #[test]
1952 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
1953 let anthropic = TabId::vendor(VendorId::Anthropic);
1954 let openai = TabId::vendor(VendorId::Openai);
1955 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
1956 let generation = app.tab_generation;
1957 assert!(app.begin_refresh(&anthropic));
1958
1959 app.tabs_meta.swap(0, 1);
1962 app.tabs.swap(0, 1);
1963 assert!(app.apply_refresh(generation, &anthropic, TabState::error("ready")));
1964 assert!(matches!(app.tabs[0], TabState::Loading));
1965 assert!(matches!(&app.tabs[1], TabState::Error { message, .. } if message == "ready"));
1966 assert!(!app.is_refreshing(&anthropic));
1967 }
1968
1969 fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
1970 TabState::Ready(Box::new(ReadyTab {
1971 snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
1972 label: "test".into(),
1973 total_credits: 0.0,
1974 total_usage: 0.0,
1975 usage_daily: 0.0,
1976 usage_weekly: 0.0,
1977 usage_monthly: 0.0,
1978 is_free_tier: false,
1979 limit: None,
1980 limit_remaining: None,
1981 recent_models: Vec::new(),
1982 }),
1983 stale: false,
1984 last_error: None,
1985 fetched_at: Some(fetched_at),
1986 display: Default::default(),
1987 }))
1988 }
1989
1990 #[test]
1991 fn refresh_keeps_ready_snapshot_visible_and_suppresses_duplicates() {
1992 let tab = TabId::vendor(VendorId::Openrouter);
1993 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1994 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1995 app.tabs[0] = ready_at(fetched_at);
1996
1997 assert!(app.begin_refresh(&tab));
1998 assert!(
1999 !app.begin_refresh(&tab),
2000 "duplicate request must be suppressed"
2001 );
2002 assert!(app.is_refreshing(&tab));
2003 match &app.tabs[0] {
2004 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
2005 other => panic!("ready snapshot disappeared during refresh: {other:?}"),
2006 }
2007 }
2008
2009 #[test]
2010 fn first_refresh_still_uses_loading_until_data_arrives() {
2011 let tab = TabId::vendor(VendorId::Openrouter);
2012 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
2013
2014 assert!(app.begin_refresh(&tab));
2015 assert!(app.is_refreshing(&tab));
2016 assert!(matches!(app.tabs[0], TabState::Loading));
2017
2018 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("not signed in"),));
2019 assert!(!app.is_refreshing(&tab));
2020 assert!(
2021 matches!(&app.tabs[0], TabState::Error { message, .. } if message == "not signed in")
2022 );
2023 }
2024
2025 #[test]
2026 fn successful_revalidation_replaces_snapshot_and_clears_indicator() {
2027 let tab = TabId::vendor(VendorId::Openrouter);
2028 let old_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
2029 let new_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 1, 0).unwrap();
2030 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
2031 app.tabs[0] = ready_at(old_at);
2032
2033 assert!(app.begin_refresh(&tab));
2034 assert!(app.apply_refresh(app.tab_generation, &tab, ready_at(new_at)));
2035 assert!(!app.is_refreshing(&tab));
2036 match &app.tabs[0] {
2037 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(new_at)),
2038 other => panic!("expected replacement snapshot, got {other:?}"),
2039 }
2040 }
2041
2042 #[test]
2043 fn failed_revalidation_preserves_snapshot_with_visible_warning() {
2044 let tab = TabId::vendor(VendorId::Openrouter);
2045 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
2046 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
2047 app.tabs[0] = ready_at(fetched_at);
2048
2049 assert!(app.begin_refresh(&tab));
2050 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("refresh failed"),));
2051 assert!(!app.is_refreshing(&tab));
2052 match &app.tabs[0] {
2053 TabState::Ready(ready) => {
2054 assert_eq!(ready.fetched_at, Some(fetched_at));
2055 assert!(ready.stale);
2056 assert_eq!(ready.last_error, Some((0, "refresh failed".into())));
2057 }
2058 other => panic!("last successful snapshot was lost: {other:?}"),
2059 }
2060 let sections = crate::tui::panels::sections_for(&app.tabs[0], Utc::now(), 5);
2061 assert!(sections.iter().any(|section| matches!(
2062 section,
2063 crate::tui::panels::Section::Text { label, value }
2064 if label == "Warning" && value == "refresh failed"
2065 )));
2066 }
2067
2068 #[test]
2069 fn old_generation_result_does_not_clear_current_refresh() {
2070 let tab = TabId::vendor(VendorId::Openrouter);
2071 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
2072 let old_generation = app.tab_generation;
2073 app.set_tabs(vec![tab.clone()]);
2074 assert!(app.begin_refresh(&tab));
2075
2076 assert!(!app.apply_refresh(old_generation, &tab, TabState::error("old result"),));
2077 assert!(app.is_refreshing(&tab));
2078 assert!(matches!(app.tabs[0], TabState::Loading));
2079 }
2080
2081 #[test]
2082 fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
2083 let anthropic = TabId::vendor(VendorId::Anthropic);
2089 let openai = TabId::vendor(VendorId::Openai);
2090 let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
2091 let generation = app.tab_generation;
2092 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
2093
2094 assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
2095 match &app.tabs[0] {
2096 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
2097 other => panic!("expected Anthropic tab Ready, got {other:?}"),
2098 }
2099 assert!(matches!(app.tabs[1], TabState::Loading));
2100 }
2101
2102 #[test]
2103 fn select_primary_lands_on_default_account_tab() {
2104 let app = {
2107 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
2108 let mut a = App::with_theme(tabs, Theme::default());
2109 a.select_primary(Some(VendorId::Anthropic));
2110 a
2111 };
2112 assert_eq!(app.active, 0);
2113 assert_eq!(
2114 app.active_tab_id(),
2115 Some(&TabId::vendor(VendorId::Anthropic))
2116 );
2117 }
2118
2119 fn custom_spec(id: &str, enabled: bool) -> CustomProviderConfig {
2120 CustomProviderConfig {
2121 id: id.into(),
2122 name: "My Tool".into(),
2123 short_name: "myt".into(),
2124 enabled,
2125 ..Default::default()
2126 }
2127 }
2128
2129 #[test]
2130 fn custom_providers_get_tabs_after_every_builtin() {
2131 let config = Config {
2132 custom: vec![custom_spec("mytool", true), custom_spec("other", true)],
2133 ..Default::default()
2134 };
2135 let tabs = tabs_from_config(&config);
2136 let builtin_count = config.enabled_vendors().len();
2137 assert_eq!(tabs.len(), builtin_count + 2);
2138 assert!(
2139 tabs[..builtin_count]
2140 .iter()
2141 .all(|t| matches!(t.source, TabSource::Builtin(_)))
2142 );
2143 assert_eq!(tabs[builtin_count], TabId::custom(&config.custom[0]));
2144 assert_eq!(tabs[builtin_count + 1], TabId::custom(&config.custom[1]));
2145 assert_eq!(
2146 tabs[builtin_count].source,
2147 TabSource::Custom {
2148 id: "mytool".into(),
2149 name: "My Tool".into(),
2150 short_name: "myt".into(),
2151 }
2152 );
2153 assert!(tabs[builtin_count].account.is_none());
2154 assert!(!tabs[builtin_count].desktop);
2155 assert_eq!(tabs[builtin_count].vendor_id(), None);
2156 }
2157
2158 #[test]
2159 fn disabled_custom_provider_has_no_tab() {
2160 let config = Config {
2161 custom: vec![custom_spec("mytool", false)],
2162 ..Default::default()
2163 };
2164 let tabs = tabs_from_config(&config);
2165 assert!(
2166 tabs.iter()
2167 .all(|t| matches!(t.source, TabSource::Builtin(_)))
2168 );
2169 assert_eq!(tabs.len(), config.enabled_vendors().len());
2170 }
2171
2172 #[test]
2173 fn custom_tabs_are_listed_by_the_default_overview_but_not_by_a_vendor_filter() {
2174 let spec = custom_spec("mytool", true);
2175 let mut app = App::with_theme(
2176 vec![
2177 TabId::vendor(VendorId::Anthropic),
2178 TabId::custom(&spec),
2179 TabId::vendor(VendorId::Openai),
2180 ],
2181 Theme::default(),
2182 );
2183 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
2184
2185 app.overview_vendors = Some(vec![VendorId::Openai, VendorId::Anthropic]);
2187 assert_eq!(app.overview_tabs(), vec![2, 0]);
2188 }
2189
2190 #[test]
2191 fn a_custom_active_tab_has_no_vendor_and_never_staggers() {
2192 let spec = custom_spec("mytool", true);
2193 let tabs = vec![
2194 TabId::vendor(VendorId::Anthropic),
2195 TabId::custom(&spec),
2196 TabId::account("work"),
2197 ];
2198 let mut app = App::with_theme(tabs.clone(), Theme::default());
2199 app.active = 1;
2200 assert_eq!(app.active_vendor(), None);
2201 assert_eq!(app.active_tab_id(), Some(&TabId::custom(&spec)));
2202
2203 app.select_primary(Some(VendorId::Anthropic));
2205 assert_eq!(app.active, 0);
2206
2207 let step = Duration::from_millis(800);
2208 assert_eq!(
2209 refresh_stagger(&tabs, step),
2210 vec![Duration::ZERO, Duration::ZERO, step]
2211 );
2212 }
2213}