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