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