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::Moonshot => {
934 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
935 let (endpoints, currency) =
936 crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
937 let outcome = fetch_api_keyed(
938 async {
939 crate::moonshot::fetch_snapshot(
940 client, &api_key, &cache, &endpoints, ttl, currency,
941 )
942 .await
943 .map(Into::into)
944 },
945 &api_key,
946 )
947 .await?;
948 Ok(outcome)
949 }
950 VendorId::Grok => {
951 let (key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
952 let endpoints = crate::grok::fetch::Endpoints::default();
953 let outcome = fetch_api_keyed(
954 async {
955 crate::grok::fetch_snapshot(
956 client,
957 &key,
958 &cache,
959 &endpoints,
960 ttl,
961 config.grok.team_id.as_deref(),
962 )
963 .await
964 .map(Into::into)
965 },
966 &key,
967 )
968 .await?;
969 Ok(outcome)
970 }
971 VendorId::Supergrok => {
972 let cache = crate::cache::Cache::for_vendor("supergrok")?;
973 let scope_paths = crate::supergrok::scope::ScopePaths::with_overrides(
974 config.supergrok.auth_path.as_deref(),
975 config.supergrok.config_path.as_deref(),
976 )?;
977 let outcome = crate::supergrok::fetch_snapshot(
978 &config.supergrok.grok_binary,
979 &scope_paths,
980 &cache,
981 ttl,
982 )
983 .await?;
984 Ok(outcome.into())
985 }
986 VendorId::Antigravity => {
987 let cache = crate::cache::Cache::for_vendor("antigravity")?;
990 let oauth = crate::antigravity::cloud::OauthClient::from_config(
991 config.antigravity.oauth_client_id.as_deref(),
992 config.antigravity.oauth_client_secret.as_deref(),
993 );
994 let outcome = crate::antigravity::fetch_snapshot(
995 client,
996 &cache,
997 ttl,
998 oauth.as_ref(),
999 config.antigravity.agy_binary.as_deref(),
1000 config.antigravity.ide_language_server,
1001 )
1002 .await?;
1003 Ok(outcome.into())
1004 }
1005 VendorId::Grokbot => {
1006 let creds = crate::grokbot::resolve_credentials(&config.grokbot)?;
1009 let cache = crate::cache::Cache::for_vendor("grokbot")?;
1010 let endpoints = crate::grokbot::fetch::Endpoints::default();
1011 let outcome =
1012 crate::grokbot::fetch::fetch_snapshot_with(client, &creds, &cache, &endpoints, ttl)
1013 .await?;
1014 Ok(outcome.into())
1015 }
1016 VendorId::ModelStudio => {
1017 let creds = crate::modelstudio::resolve_credentials(&config.modelstudio)?;
1020 let cache = crate::cache::Cache::for_vendor("modelstudio")?;
1021 let endpoints =
1022 crate::modelstudio::fetch::Endpoints::for_gateway(creds.region, creds.site);
1023 let outcome =
1024 crate::modelstudio::fetch_snapshot_with(client, &creds, &cache, &endpoints, ttl)
1025 .await?;
1026 Ok(outcome.into())
1027 }
1028 VendorId::MuseCode => {
1029 let cache = crate::cache::Cache::for_vendor("muse-code")?;
1030 Ok(crate::muse::fetch::fetch_snapshot(&config.muse, &cache)
1031 .await?
1032 .into())
1033 }
1034 VendorId::Devin => {
1035 let (creds_cfg, cache) = match tab.account.as_deref() {
1036 Some(label) => {
1037 let creds_path = config.devin.resolve_credentials_path(Some(label))?;
1038 let mut acct_cfg = config.devin.clone();
1039 acct_cfg.credentials_path = Some(creds_path);
1040 let cache = crate::cache::Cache::for_vendor_account("devin", label)?;
1041 (acct_cfg, cache)
1042 }
1043 None => (
1044 config.devin.clone(),
1045 crate::cache::Cache::for_vendor("devin")?,
1046 ),
1047 };
1048 let outcome = crate::devin::fetch::fetch_snapshot(&creds_cfg, &cache, ttl).await?;
1049 Ok(outcome.into())
1050 }
1051 VendorId::Droid => {
1052 let cache = crate::cache::Cache::for_vendor("droid")?;
1053 let outcome = crate::droid::fetch::fetch_snapshot(&config.droid, &cache, ttl).await?;
1054 Ok(outcome.into())
1055 }
1056 VendorId::Minimax => {
1057 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
1058 let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
1059 let outcome = fetch_api_keyed(
1060 async {
1061 crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, ttl)
1062 .await
1063 .map(Into::into)
1064 },
1065 &api_key,
1066 )
1067 .await?;
1068 Ok(outcome)
1069 }
1070 VendorId::Cursor => {
1071 let cache = crate::cache::Cache::for_vendor("cursor")?;
1072 let db_path = config
1073 .cursor
1074 .db_path
1075 .clone()
1076 .map(Ok)
1077 .unwrap_or_else(crate::cursor::db::default_db_path)?;
1078 let agent_auth_path = config
1079 .cursor
1080 .agent_auth_path
1081 .clone()
1082 .map(Ok)
1083 .unwrap_or_else(crate::cursor::db::default_agent_auth_path)?;
1084 let endpoints = crate::cursor::fetch::Endpoints::default();
1085 let outcome = crate::cursor::fetch_snapshot(
1086 client,
1087 &db_path,
1088 &agent_auth_path,
1089 &cache,
1090 &endpoints,
1091 ttl,
1092 )
1093 .await?;
1094 Ok(outcome.into())
1095 }
1096 VendorId::Kiro => {
1097 let cache = crate::cache::Cache::for_vendor("kiro")?;
1098 let db_path = config
1099 .kiro
1100 .db_path
1101 .clone()
1102 .map(Ok)
1103 .unwrap_or_else(crate::kiro::db::default_db_path)?;
1104 let outcome = crate::kiro::fetch_snapshot(client, &db_path, &cache, ttl).await?;
1105 Ok(outcome.into())
1106 }
1107 VendorId::NousResearch => {
1108 let store = crate::nous::credentials::CredentialStore::default();
1109 let endpoints = crate::nous::fetch::Endpoints::default();
1110 let account = crate::nous::fetch::fetch_account_with_refresh(
1111 client,
1112 &store,
1113 &endpoints,
1114 Utc::now(),
1115 )
1116 .await?;
1117 Ok(crate::outcome::Outcome::fresh(
1119 crate::usage::VendorSnapshot::NousResearch(account),
1120 ))
1121 }
1122 VendorId::OpenCodeGo => {
1123 let api_key = crate::opencode_go::creds::resolve_key(
1124 &config.opencode_go.api_key_env,
1125 config.opencode_go.api_key.as_deref(),
1126 config.opencode_go.auth_paths.as_deref(),
1127 )?;
1128 let cache = crate::cache::Cache::for_vendor("opencode-go")?.redacting(&api_key);
1129 let endpoints = crate::opencode_go::fetch::Endpoints::default();
1130 let outcome = fetch_api_keyed(
1131 async {
1132 crate::opencode_go::fetch::fetch_snapshot(
1133 client, &api_key, &cache, &endpoints, ttl,
1134 )
1135 .await
1136 .map(Into::into)
1137 },
1138 &api_key,
1139 )
1140 .await?;
1141 Ok(outcome)
1142 }
1143 VendorId::Cline => {
1144 let credential = crate::cline::creds::resolve(
1145 config.cline.auth_paths.as_deref(),
1146 &config.cline.api_key_env,
1147 config.cline.api_key.as_deref(),
1148 )?;
1149 let cache = crate::cache::Cache::for_vendor("cline")?;
1150 let endpoints = crate::cline::fetch::Endpoints::default();
1151 let outcome = crate::cline::fetch::fetch_snapshot(
1152 client,
1153 &credential,
1154 &cache,
1155 &endpoints,
1156 ttl,
1157 Utc::now(),
1158 )
1159 .await?;
1160 Ok(outcome.into())
1161 }
1162 VendorId::CommandCode => {
1163 let credential =
1164 crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref())?;
1165 let cache = crate::cache::Cache::for_vendor("commandcode")?;
1166 let endpoints = crate::commandcode::fetch::Endpoints::default();
1167 let outcome = crate::commandcode::fetch::fetch_snapshot(
1168 client,
1169 &credential.token,
1170 &cache,
1171 &endpoints,
1172 ttl,
1173 )
1174 .await?;
1175 Ok(outcome.into())
1176 }
1177 VendorId::Ollama => {
1178 let api_key = crate::config::resolve_api_key(
1179 "Ollama",
1180 &config.ollama.api_key_env,
1181 config.ollama.api_key.as_deref(),
1182 )?;
1183 let cache = crate::cache::Cache::for_vendor("ollama")?.redacting(&api_key);
1184 let endpoints = crate::ollama::fetch::Endpoints::default();
1185 let outcome = fetch_api_keyed(
1186 async {
1187 crate::ollama::fetch_snapshot(
1188 client,
1189 &api_key,
1190 &config.ollama.plan,
1191 &cache,
1192 &endpoints,
1193 ttl,
1194 )
1195 .await
1196 .map(Into::into)
1197 },
1198 &api_key,
1199 )
1200 .await?;
1201 Ok(outcome)
1202 }
1203 VendorId::OrcaRouter => {
1204 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
1205 let endpoints = crate::orcarouter::fetch::Endpoints::default();
1206 let outcome = fetch_api_keyed(
1207 async {
1208 crate::orcarouter::fetch_snapshot(client, &api_key, &cache, &endpoints, ttl)
1209 .await
1210 .map(Into::into)
1211 },
1212 &api_key,
1213 )
1214 .await?;
1215 Ok(outcome)
1216 }
1217 VendorId::Lyceum => {
1218 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
1219 let outcome = fetch_api_keyed(
1220 async {
1221 crate::lyceum::fetch::fetch_snapshot(client, &api_key, &cache, ttl)
1222 .await
1223 .map(Into::into)
1224 },
1225 &api_key,
1226 )
1227 .await?;
1228 Ok(outcome)
1229 }
1230 }
1231}
1232
1233pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
1236
1237pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
1244
1245pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
1251 let mut anthropic_seen: u32 = 0;
1252 tabs.iter()
1253 .map(|tab| {
1254 if tab.vendor_id() == Some(VendorId::Anthropic) {
1255 let delay = step * anthropic_seen;
1256 anthropic_seen += 1;
1257 delay
1258 } else {
1259 Duration::ZERO
1260 }
1261 })
1262 .collect()
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267 use super::*;
1268 use chrono::TimeZone;
1269
1270 #[test]
1274 fn refresh_stagger_spaces_out_anthropic_tabs_only() {
1275 let step = Duration::from_millis(800);
1276 let tabs = vec![
1277 TabId::vendor(VendorId::Anthropic), TabId::account("work"),
1279 TabId::account("personal"),
1280 TabId::vendor(VendorId::Openai),
1281 TabId::vendor(VendorId::Zai),
1282 ];
1283 let delays = refresh_stagger(&tabs, step);
1284 assert_eq!(
1285 delays,
1286 vec![
1287 Duration::ZERO, step, step * 2, Duration::ZERO, Duration::ZERO, ]
1293 );
1294 }
1295
1296 #[test]
1297 fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
1298 let tabs = vec![
1300 TabId::vendor(VendorId::Anthropic),
1301 TabId::vendor(VendorId::Openrouter),
1302 ];
1303 assert!(
1304 refresh_stagger(&tabs, Duration::from_millis(800))
1305 .iter()
1306 .all(|d| d.is_zero())
1307 );
1308 }
1309
1310 #[test]
1311 fn select_primary_moves_to_enabled_vendor() {
1312 let mut app = App::with_theme(
1313 vec![
1314 TabId::vendor(VendorId::Anthropic),
1315 TabId::vendor(VendorId::Openrouter),
1316 ],
1317 Theme::default(),
1318 );
1319 app.select_primary(Some(VendorId::Openrouter));
1320 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
1321 }
1322
1323 #[test]
1324 fn select_primary_ignores_disabled_vendor() {
1325 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1326 app.select_primary(Some(VendorId::Openai));
1327 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
1328 }
1329
1330 #[test]
1331 fn nav_ring_wraps_through_the_overview_at_both_ends() {
1332 let mut app = App::with_theme(
1333 vec![
1334 TabId::vendor(VendorId::Anthropic),
1335 TabId::vendor(VendorId::Openai),
1336 ],
1337 Theme::default(),
1338 );
1339 app.overview = true;
1340
1341 app.next_tab(); assert!(!app.overview);
1343 assert_eq!(app.active, 0);
1344 app.next_tab();
1345 assert_eq!(app.active, 1);
1346 app.next_tab(); assert!(app.overview);
1348
1349 app.prev_tab(); assert!(!app.overview);
1351 assert_eq!(app.active, 1);
1352 app.prev_tab();
1353 assert_eq!(app.active, 0);
1354 app.prev_tab(); assert!(app.overview);
1356 }
1357
1358 #[test]
1359 fn mouse_nav_targets_select_overview_and_tabs() {
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 app.nav_from_target(NavTarget::Tab(1));
1369 assert!(!app.overview);
1370 assert_eq!(app.active, 1);
1371 app.nav_from_target(NavTarget::Overview);
1372 assert!(app.overview);
1373 app.nav_from_target(NavTarget::Tab(99));
1375 assert!(app.overview);
1376 }
1377
1378 #[test]
1379 fn hit_targets_default_to_empty() {
1380 let app = App::with_theme(Vec::new(), Theme::default());
1381 assert!(app.hit.borrow().nav_entries.is_empty());
1382 assert!(app.hit.borrow().footer_actions.is_empty());
1383 assert!(app.hit.borrow().settings_rows.is_empty());
1384 }
1385
1386 #[test]
1387 fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
1388 let mut app = App::with_theme(
1389 vec![
1390 TabId::vendor(VendorId::Anthropic),
1391 TabId::vendor(VendorId::Openai),
1392 TabId::vendor(VendorId::Zai),
1393 ],
1394 Theme::default(),
1395 );
1396 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1397
1398 app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
1400 assert_eq!(app.overview_tabs(), vec![2, 0]);
1401
1402 app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
1404 assert_eq!(app.overview_tabs(), vec![1]);
1405
1406 app.overview_vendors = Some(vec![VendorId::Anthropic, VendorId::Anthropic]);
1408 assert_eq!(app.overview_tabs(), vec![0]);
1409 }
1410
1411 fn config_with_accounts(labels: &[&str]) -> Config {
1412 let mut config = Config::default();
1413 config.openai.enabled = false;
1416 config.zai.enabled = false;
1417 config.openrouter.enabled = false;
1418 config.commandcode.enabled = false;
1419 config.anthropic.accounts = labels
1420 .iter()
1421 .map(|l| crate::config::AnthropicAccount {
1422 label: (*l).to_string(),
1423 credentials_path: format!("/creds/{l}.json").into(),
1424 })
1425 .collect();
1426 config
1427 }
1428
1429 #[test]
1430 fn show_default_account_false_hides_the_unnamed_claude_tab() {
1431 let mut config = config_with_accounts(&["work", "personal"]);
1434 config.anthropic.show_default_account = false;
1435 assert_eq!(
1436 tabs_from_config(&config),
1437 vec![TabId::account("work"), TabId::account("personal")]
1438 );
1439
1440 let mut empty = Config::default();
1443 empty.openai.enabled = false;
1444 empty.zai.enabled = false;
1445 empty.openrouter.enabled = false;
1446 empty.commandcode.enabled = false;
1447 empty.anthropic.show_default_account = false;
1448 assert_eq!(
1449 tabs_from_config(&empty),
1450 vec![TabId::vendor(VendorId::Anthropic)]
1451 );
1452 }
1453
1454 #[test]
1455 fn tabs_expand_anthropic_accounts_after_default() {
1456 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
1458 assert_eq!(
1459 tabs,
1460 vec![
1461 TabId::vendor(VendorId::Anthropic),
1462 TabId::account("work"),
1463 TabId::account("personal"),
1464 ]
1465 );
1466 }
1467
1468 #[test]
1469 fn tabs_follow_overview_vendors_and_keep_the_rest() {
1470 let mut config = Config::default();
1471 config.zai.enabled = false;
1472 config.openrouter.enabled = false;
1473 config.commandcode.enabled = false;
1474 config.supergrok.enabled = true;
1475 config.antigravity.enabled = true;
1476 config.opencode_go.enabled = true;
1477 config.copilot.enabled = true;
1478 config.ui.overview_vendors = Some(vec![
1479 VendorId::Anthropic,
1480 VendorId::Zai,
1481 VendorId::Openai,
1482 VendorId::Anthropic,
1483 VendorId::Supergrok,
1484 VendorId::OpenCodeGo,
1485 VendorId::Antigravity,
1486 ]);
1487 let vendors: Vec<VendorId> = tabs_from_config(&config)
1488 .iter()
1489 .filter_map(TabId::vendor_id)
1490 .collect();
1491 assert_eq!(
1492 vendors,
1493 vec![
1494 VendorId::Anthropic,
1495 VendorId::Openai,
1496 VendorId::Supergrok,
1497 VendorId::OpenCodeGo,
1498 VendorId::Antigravity,
1499 VendorId::Copilot,
1500 ]
1501 );
1502 }
1503
1504 #[test]
1505 fn tabs_without_accounts_are_just_enabled_vendors() {
1506 let config = Config::default();
1508 let tabs = tabs_from_config(&config);
1509 let vendors: Vec<VendorId> = tabs.iter().filter_map(TabId::vendor_id).collect();
1510 assert_eq!(vendors, config.enabled_vendors());
1511 assert!(tabs.iter().all(|t| t.account.is_none()));
1512 }
1513
1514 #[test]
1515 fn tabs_expand_openrouter_accounts_without_changing_other_vendors() {
1516 let mut config = Config::default();
1517 config.anthropic.enabled = false;
1518 config.openai.enabled = false;
1519 config.zai.enabled = false;
1520 config.commandcode.enabled = false;
1521 config.openrouter.accounts = vec![
1522 crate::config::ApiKeyAccount {
1523 label: "work".into(),
1524 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1525 api_key: None,
1526 management_api_key_env: None,
1527 },
1528 crate::config::ApiKeyAccount {
1529 label: "personal".into(),
1530 api_key_env: None,
1531 api_key: Some("personal-key".into()),
1532 management_api_key_env: None,
1533 },
1534 ];
1535 assert_eq!(
1536 tabs_from_config(&config),
1537 vec![
1538 TabId::vendor(VendorId::Openrouter),
1539 TabId::account_for(VendorId::Openrouter, "work"),
1540 TabId::account_for(VendorId::Openrouter, "personal"),
1541 ]
1542 );
1543 }
1544
1545 #[test]
1546 fn openai_named_accounts_get_their_own_tabs_after_the_default() {
1547 let mut config = Config::default();
1548 config.anthropic.enabled = false;
1549 config.zai.enabled = false;
1550 config.openrouter.enabled = false;
1551 config.commandcode.enabled = false;
1552 config.openai.accounts.push(crate::config::OpenAiAccount {
1553 label: "work".into(),
1554 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1555 });
1556 assert_eq!(
1557 tabs_from_config(&config),
1558 vec![
1559 TabId::vendor(VendorId::Openai),
1560 TabId::account_for(VendorId::Openai, "work"),
1561 ]
1562 );
1563 }
1564
1565 #[test]
1566 fn copilot_named_accounts_get_their_own_tabs_and_can_hide_the_default() {
1567 let base = || {
1568 let mut config = Config::default();
1569 config.anthropic.enabled = false;
1570 config.openai.enabled = false;
1571 config.zai.enabled = false;
1572 config.openrouter.enabled = false;
1573 config.commandcode.enabled = false;
1574 config.copilot.enabled = true;
1575 config
1576 };
1577
1578 assert_eq!(
1580 tabs_from_config(&base()),
1581 vec![TabId::vendor(VendorId::Copilot)]
1582 );
1583
1584 let mut config = base();
1586 config.copilot.accounts.push(crate::config::CopilotAccount {
1587 label: "work".into(),
1588 user: "octocat-work".into(),
1589 });
1590 config.copilot.accounts.push(crate::config::CopilotAccount {
1591 label: "personal".into(),
1592 user: "octocat".into(),
1593 });
1594 assert_eq!(
1595 tabs_from_config(&config),
1596 vec![
1597 TabId::vendor(VendorId::Copilot),
1598 TabId::account_for(VendorId::Copilot, "work"),
1599 TabId::account_for(VendorId::Copilot, "personal"),
1600 ]
1601 );
1602
1603 config.copilot.show_default_account = false;
1605 assert_eq!(
1606 tabs_from_config(&config),
1607 vec![
1608 TabId::account_for(VendorId::Copilot, "work"),
1609 TabId::account_for(VendorId::Copilot, "personal"),
1610 ]
1611 );
1612
1613 let mut empty = base();
1615 empty.copilot.show_default_account = false;
1616 assert_eq!(
1617 tabs_from_config(&empty),
1618 vec![TabId::vendor(VendorId::Copilot)]
1619 );
1620 }
1621
1622 #[test]
1623 fn devin_named_accounts_get_their_own_tabs_and_can_hide_the_default() {
1624 let base = || {
1625 let mut config = Config::default();
1626 config.anthropic.enabled = false;
1627 config.openai.enabled = false;
1628 config.zai.enabled = false;
1629 config.openrouter.enabled = false;
1630 config.commandcode.enabled = false;
1631 config.devin.enabled = true;
1632 config
1633 };
1634
1635 assert_eq!(
1637 tabs_from_config(&base()),
1638 vec![TabId::vendor(VendorId::Devin)]
1639 );
1640
1641 let mut config = base();
1643 config.devin.accounts.push(crate::config::DevinAccount {
1644 label: "work".into(),
1645 credentials_path: "/tmp/devin-work/credentials.toml".into(),
1646 });
1647 config.devin.accounts.push(crate::config::DevinAccount {
1648 label: "personal".into(),
1649 credentials_path: "/tmp/devin-personal/credentials.toml".into(),
1650 });
1651 assert_eq!(
1652 tabs_from_config(&config),
1653 vec![
1654 TabId::vendor(VendorId::Devin),
1655 TabId::account_for(VendorId::Devin, "work"),
1656 TabId::account_for(VendorId::Devin, "personal"),
1657 ]
1658 );
1659
1660 config.devin.show_default_account = false;
1662 assert_eq!(
1663 tabs_from_config(&config),
1664 vec![
1665 TabId::account_for(VendorId::Devin, "work"),
1666 TabId::account_for(VendorId::Devin, "personal"),
1667 ]
1668 );
1669
1670 let mut empty = base();
1672 empty.devin.show_default_account = false;
1673 assert_eq!(
1674 tabs_from_config(&empty),
1675 vec![TabId::vendor(VendorId::Devin)]
1676 );
1677 }
1678
1679 #[test]
1680 fn openrouter_can_hide_default_only_when_named_accounts_exist() {
1681 let mut config = Config::default();
1682 config.anthropic.enabled = false;
1683 config.openai.enabled = false;
1684 config.zai.enabled = false;
1685 config.commandcode.enabled = false;
1686 config.openrouter.show_default_account = false;
1687 assert_eq!(
1688 tabs_from_config(&config),
1689 vec![TabId::vendor(VendorId::Openrouter)]
1690 );
1691
1692 config
1693 .openrouter
1694 .accounts
1695 .push(crate::config::ApiKeyAccount {
1696 label: "work".into(),
1697 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1698 api_key: None,
1699 management_api_key_env: None,
1700 });
1701 assert_eq!(
1702 tabs_from_config(&config),
1703 vec![TabId::account_for(VendorId::Openrouter, "work")]
1704 );
1705 }
1706
1707 #[test]
1708 fn every_api_key_account_vendor_fans_out_like_openrouter() {
1709 let config_for = |vendor: VendorId, show_default: bool| -> Config {
1710 let section = vendor.config_section();
1711 let mut text: String = ["anthropic", "openai", "commandcode", "zai", "openrouter"]
1714 .into_iter()
1715 .filter(|other| *other != section)
1716 .map(|other| format!("[{other}]\nenabled = false\n"))
1717 .collect();
1718 text.push_str(&format!(
1719 "[{section}]\nenabled = true\nshow_default_account = {show_default}\n\
1720 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k1\"\n\
1721 [[{section}.accounts]]\nlabel = \"personal\"\napi_key = \"k2\"\n"
1722 ));
1723 let config: Config = toml::from_str(&text).unwrap();
1724 config.validate().unwrap();
1725 config
1726 };
1727 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
1728 let named = vec![
1729 TabId::account_for(vendor, "work"),
1730 TabId::account_for(vendor, "personal"),
1731 ];
1732 let mut with_default = vec![TabId::vendor(vendor)];
1733 with_default.extend(named.clone());
1734 assert_eq!(
1735 tabs_from_config(&config_for(vendor, true)),
1736 with_default,
1737 "{vendor:?}"
1738 );
1739 assert_eq!(
1740 tabs_from_config(&config_for(vendor, false)),
1741 named,
1742 "{vendor:?}"
1743 );
1744 }
1745 }
1746
1747 #[test]
1748 fn codex_can_hide_default_only_when_named_accounts_exist() {
1749 let mut config = Config::default();
1750 config.anthropic.enabled = false;
1751 config.zai.enabled = false;
1752 config.openrouter.enabled = false;
1753 config.commandcode.enabled = false;
1754 config.openai.show_default_account = false;
1755 assert_eq!(
1756 tabs_from_config(&config),
1757 vec![TabId::vendor(VendorId::Openai)]
1758 );
1759
1760 config.openai.accounts.push(crate::config::OpenAiAccount {
1761 label: "work".into(),
1762 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1763 });
1764 assert_eq!(
1765 tabs_from_config(&config),
1766 vec![TabId::account_for(VendorId::Openai, "work")]
1767 );
1768 }
1769
1770 #[test]
1771 fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
1772 let td = tempfile::tempdir().unwrap();
1775 for label in ["work", "personal"] {
1776 let dir = td.path().join(label);
1777 std::fs::create_dir_all(&dir).unwrap();
1778 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1779 }
1780 let mut config = Config::default();
1781 config.openai.enabled = false;
1782 config.zai.enabled = false;
1783 config.openrouter.enabled = false;
1784 config.commandcode.enabled = false;
1785 config.anthropic.accounts_dir = Some(td.path().to_path_buf());
1786
1787 let tabs = tabs_from_config(&config);
1788 assert_eq!(
1789 tabs,
1790 vec![
1791 TabId::vendor(VendorId::Anthropic),
1792 TabId::account("personal"), TabId::account("work"),
1794 ]
1795 );
1796 }
1797
1798 #[test]
1799 fn desktop_labels_become_account_tabs_after_cli_accounts() {
1800 let config = config_with_accounts(&["work"]);
1802 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1803 assert_eq!(
1804 tabs,
1805 vec![
1806 TabId::vendor(VendorId::Anthropic),
1807 TabId::account("work"),
1808 TabId::desktop_account("gmail"),
1809 TabId::desktop_account("hotmail"),
1810 ]
1811 );
1812 }
1813
1814 #[test]
1815 fn a_desktop_profile_wins_a_label_collision_with_a_cli_account() {
1816 let config = config_with_accounts(&["gmail", "work"]);
1821 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1822 assert_eq!(
1823 tabs,
1824 vec![
1825 TabId::vendor(VendorId::Anthropic),
1826 TabId::account("work"),
1827 TabId::desktop_account("gmail"),
1828 TabId::desktop_account("hotmail"),
1829 ]
1830 );
1831 }
1832
1833 #[test]
1834 fn desktop_accounts_suppress_the_default_tab_like_named_ones() {
1835 let mut config = config_with_accounts(&[]);
1838 config.cursor.enabled = false;
1839 config.anthropic.show_default_account = false;
1840
1841 assert_eq!(
1844 build_tabs(&config, &[]),
1845 vec![TabId::vendor(VendorId::Anthropic)]
1846 );
1847 assert_eq!(
1849 build_tabs(&config, &["gmail".into()]),
1850 vec![TabId::desktop_account("gmail")]
1851 );
1852 }
1853
1854 #[test]
1855 fn set_tabs_resets_states_and_clamps_selection() {
1856 let mut app = App::with_theme(
1860 tabs_from_config(&config_with_accounts(&["work", "personal"])),
1861 Theme::default(),
1862 );
1863 app.active = 2; app.tabs[0] = TabState::error("old");
1865 let old_tab = app.tabs_meta[0].clone();
1866 assert!(app.begin_refresh(&old_tab));
1867
1868 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
1869 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
1870 assert_eq!(app.active, 0, "selection clamped after shrink");
1871 assert!(matches!(app.tabs[0], TabState::Loading));
1872 assert!(!app.is_refreshing(&old_tab));
1873 }
1874
1875 #[test]
1876 fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
1877 let mut app = App::with_theme(
1878 vec![
1879 TabId::vendor(VendorId::Anthropic),
1880 TabId::vendor(VendorId::Openai),
1881 ],
1882 Theme::default(),
1883 );
1884 app.active = 1;
1885
1886 app.set_tabs(vec![
1887 TabId::vendor(VendorId::Anthropic),
1888 TabId::account("work"),
1889 TabId::vendor(VendorId::Openai),
1890 ]);
1891
1892 assert_eq!(app.active, 2);
1893 assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
1894 }
1895
1896 #[test]
1897 fn refresh_from_old_generation_is_discarded() {
1898 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1899 let old_generation = app.tab_generation;
1900 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
1901
1902 assert!(!app.apply_refresh(
1903 old_generation,
1904 &TabId::vendor(VendorId::Anthropic),
1905 TabState::error("old result"),
1906 ));
1907 assert!(matches!(app.tabs[0], TabState::Loading));
1908 }
1909
1910 #[test]
1911 fn refresh_identity_mismatch_is_discarded() {
1912 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1913 let generation = app.tab_generation;
1914
1915 assert!(!app.apply_refresh(
1916 generation,
1917 &TabId::vendor(VendorId::Openai),
1918 TabState::error("wrong tab"),
1919 ));
1920 assert!(matches!(app.tabs[0], TabState::Loading));
1921 }
1922
1923 #[test]
1924 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
1925 let anthropic = TabId::vendor(VendorId::Anthropic);
1926 let openai = TabId::vendor(VendorId::Openai);
1927 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
1928 let generation = app.tab_generation;
1929 assert!(app.begin_refresh(&anthropic));
1930
1931 app.tabs_meta.swap(0, 1);
1934 app.tabs.swap(0, 1);
1935 assert!(app.apply_refresh(generation, &anthropic, TabState::error("ready")));
1936 assert!(matches!(app.tabs[0], TabState::Loading));
1937 assert!(matches!(&app.tabs[1], TabState::Error { message, .. } if message == "ready"));
1938 assert!(!app.is_refreshing(&anthropic));
1939 }
1940
1941 fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
1942 TabState::Ready(Box::new(ReadyTab {
1943 snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
1944 label: "test".into(),
1945 total_credits: 0.0,
1946 total_usage: 0.0,
1947 usage_daily: 0.0,
1948 usage_weekly: 0.0,
1949 usage_monthly: 0.0,
1950 is_free_tier: false,
1951 limit: None,
1952 limit_remaining: None,
1953 recent_models: Vec::new(),
1954 }),
1955 stale: false,
1956 last_error: None,
1957 fetched_at: Some(fetched_at),
1958 display: Default::default(),
1959 }))
1960 }
1961
1962 #[test]
1963 fn refresh_keeps_ready_snapshot_visible_and_suppresses_duplicates() {
1964 let tab = TabId::vendor(VendorId::Openrouter);
1965 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1966 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1967 app.tabs[0] = ready_at(fetched_at);
1968
1969 assert!(app.begin_refresh(&tab));
1970 assert!(
1971 !app.begin_refresh(&tab),
1972 "duplicate request must be suppressed"
1973 );
1974 assert!(app.is_refreshing(&tab));
1975 match &app.tabs[0] {
1976 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1977 other => panic!("ready snapshot disappeared during refresh: {other:?}"),
1978 }
1979 }
1980
1981 #[test]
1982 fn first_refresh_still_uses_loading_until_data_arrives() {
1983 let tab = TabId::vendor(VendorId::Openrouter);
1984 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1985
1986 assert!(app.begin_refresh(&tab));
1987 assert!(app.is_refreshing(&tab));
1988 assert!(matches!(app.tabs[0], TabState::Loading));
1989
1990 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("not signed in"),));
1991 assert!(!app.is_refreshing(&tab));
1992 assert!(
1993 matches!(&app.tabs[0], TabState::Error { message, .. } if message == "not signed in")
1994 );
1995 }
1996
1997 #[test]
1998 fn successful_revalidation_replaces_snapshot_and_clears_indicator() {
1999 let tab = TabId::vendor(VendorId::Openrouter);
2000 let old_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
2001 let new_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 1, 0).unwrap();
2002 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
2003 app.tabs[0] = ready_at(old_at);
2004
2005 assert!(app.begin_refresh(&tab));
2006 assert!(app.apply_refresh(app.tab_generation, &tab, ready_at(new_at)));
2007 assert!(!app.is_refreshing(&tab));
2008 match &app.tabs[0] {
2009 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(new_at)),
2010 other => panic!("expected replacement snapshot, got {other:?}"),
2011 }
2012 }
2013
2014 #[test]
2015 fn failed_revalidation_preserves_snapshot_with_visible_warning() {
2016 let tab = TabId::vendor(VendorId::Openrouter);
2017 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
2018 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
2019 app.tabs[0] = ready_at(fetched_at);
2020
2021 assert!(app.begin_refresh(&tab));
2022 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("refresh failed"),));
2023 assert!(!app.is_refreshing(&tab));
2024 match &app.tabs[0] {
2025 TabState::Ready(ready) => {
2026 assert_eq!(ready.fetched_at, Some(fetched_at));
2027 assert!(ready.stale);
2028 assert_eq!(ready.last_error, Some((0, "refresh failed".into())));
2029 }
2030 other => panic!("last successful snapshot was lost: {other:?}"),
2031 }
2032 let sections = crate::tui::panels::sections_for(&app.tabs[0], Utc::now(), 5);
2033 assert!(sections.iter().any(|section| matches!(
2034 section,
2035 crate::tui::panels::Section::Text { label, value }
2036 if label == "Warning" && value == "refresh failed"
2037 )));
2038 }
2039
2040 #[test]
2041 fn old_generation_result_does_not_clear_current_refresh() {
2042 let tab = TabId::vendor(VendorId::Openrouter);
2043 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
2044 let old_generation = app.tab_generation;
2045 app.set_tabs(vec![tab.clone()]);
2046 assert!(app.begin_refresh(&tab));
2047
2048 assert!(!app.apply_refresh(old_generation, &tab, TabState::error("old result"),));
2049 assert!(app.is_refreshing(&tab));
2050 assert!(matches!(app.tabs[0], TabState::Loading));
2051 }
2052
2053 #[test]
2054 fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
2055 let anthropic = TabId::vendor(VendorId::Anthropic);
2061 let openai = TabId::vendor(VendorId::Openai);
2062 let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
2063 let generation = app.tab_generation;
2064 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
2065
2066 assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
2067 match &app.tabs[0] {
2068 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
2069 other => panic!("expected Anthropic tab Ready, got {other:?}"),
2070 }
2071 assert!(matches!(app.tabs[1], TabState::Loading));
2072 }
2073
2074 #[test]
2075 fn select_primary_lands_on_default_account_tab() {
2076 let app = {
2079 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
2080 let mut a = App::with_theme(tabs, Theme::default());
2081 a.select_primary(Some(VendorId::Anthropic));
2082 a
2083 };
2084 assert_eq!(app.active, 0);
2085 assert_eq!(
2086 app.active_tab_id(),
2087 Some(&TabId::vendor(VendorId::Anthropic))
2088 );
2089 }
2090
2091 fn custom_spec(id: &str, enabled: bool) -> CustomProviderConfig {
2092 CustomProviderConfig {
2093 id: id.into(),
2094 name: "My Tool".into(),
2095 short_name: "myt".into(),
2096 enabled,
2097 ..Default::default()
2098 }
2099 }
2100
2101 #[test]
2102 fn custom_providers_get_tabs_after_every_builtin() {
2103 let config = Config {
2104 custom: vec![custom_spec("mytool", true), custom_spec("other", true)],
2105 ..Default::default()
2106 };
2107 let tabs = tabs_from_config(&config);
2108 let builtin_count = config.enabled_vendors().len();
2109 assert_eq!(tabs.len(), builtin_count + 2);
2110 assert!(
2111 tabs[..builtin_count]
2112 .iter()
2113 .all(|t| matches!(t.source, TabSource::Builtin(_)))
2114 );
2115 assert_eq!(tabs[builtin_count], TabId::custom(&config.custom[0]));
2116 assert_eq!(tabs[builtin_count + 1], TabId::custom(&config.custom[1]));
2117 assert_eq!(
2118 tabs[builtin_count].source,
2119 TabSource::Custom {
2120 id: "mytool".into(),
2121 name: "My Tool".into(),
2122 short_name: "myt".into(),
2123 }
2124 );
2125 assert!(tabs[builtin_count].account.is_none());
2126 assert!(!tabs[builtin_count].desktop);
2127 assert_eq!(tabs[builtin_count].vendor_id(), None);
2128 }
2129
2130 #[test]
2131 fn disabled_custom_provider_has_no_tab() {
2132 let config = Config {
2133 custom: vec![custom_spec("mytool", false)],
2134 ..Default::default()
2135 };
2136 let tabs = tabs_from_config(&config);
2137 assert!(
2138 tabs.iter()
2139 .all(|t| matches!(t.source, TabSource::Builtin(_)))
2140 );
2141 assert_eq!(tabs.len(), config.enabled_vendors().len());
2142 }
2143
2144 #[test]
2145 fn custom_tabs_are_listed_by_the_default_overview_but_not_by_a_vendor_filter() {
2146 let spec = custom_spec("mytool", true);
2147 let mut app = App::with_theme(
2148 vec![
2149 TabId::vendor(VendorId::Anthropic),
2150 TabId::custom(&spec),
2151 TabId::vendor(VendorId::Openai),
2152 ],
2153 Theme::default(),
2154 );
2155 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
2156
2157 app.overview_vendors = Some(vec![VendorId::Openai, VendorId::Anthropic]);
2159 assert_eq!(app.overview_tabs(), vec![2, 0]);
2160 }
2161
2162 #[test]
2163 fn a_custom_active_tab_has_no_vendor_and_never_staggers() {
2164 let spec = custom_spec("mytool", true);
2165 let tabs = vec![
2166 TabId::vendor(VendorId::Anthropic),
2167 TabId::custom(&spec),
2168 TabId::account("work"),
2169 ];
2170 let mut app = App::with_theme(tabs.clone(), Theme::default());
2171 app.active = 1;
2172 assert_eq!(app.active_vendor(), None);
2173 assert_eq!(app.active_tab_id(), Some(&TabId::custom(&spec)));
2174
2175 app.select_primary(Some(VendorId::Anthropic));
2177 assert_eq!(app.active, 0);
2178
2179 let step = Duration::from_millis(800);
2180 assert_eq!(
2181 refresh_stagger(&tabs, step),
2182 vec![Duration::ZERO, Duration::ZERO, step]
2183 );
2184 }
2185}