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> {
166 build_tabs(config, &[])
167}
168
169pub fn tabs_with_desktop(config: &Config) -> Vec<TabId> {
173 build_tabs(config, &desktop_profile_labels(config))
174}
175
176fn build_tabs(config: &Config, desktop_labels: &[String]) -> Vec<TabId> {
189 let desktop_set: HashSet<&str> = desktop_labels.iter().map(String::as_str).collect();
190 let mut tabs = Vec::new();
191 for vendor in config.enabled_vendors() {
192 if vendor == VendorId::Anthropic {
193 let accounts: Vec<_> = config
194 .anthropic
195 .all_accounts()
196 .into_iter()
197 .filter(|a| !desktop_set.contains(a.label.as_str()))
198 .collect();
199 if config.anthropic.show_default_account
203 || (accounts.is_empty() && desktop_labels.is_empty())
204 {
205 tabs.push(TabId::vendor(vendor));
206 }
207 for acct in accounts {
208 tabs.push(TabId::account(acct.label));
209 }
210 for label in desktop_labels {
211 tabs.push(TabId::desktop_account(label.clone()));
212 }
213 } else if let Some(accounts) = config.api_key_accounts(vendor) {
214 if config.show_default_api_key_account(vendor) || accounts.is_empty() {
215 tabs.push(TabId::vendor(vendor));
216 }
217 for account in accounts {
218 tabs.push(TabId::account_for(vendor, account.label.clone()));
219 }
220 } else if vendor == VendorId::Openai {
221 if config.openai.show_default_account || config.openai.accounts.is_empty() {
222 tabs.push(TabId::vendor(vendor));
223 }
224 for account in &config.openai.accounts {
225 tabs.push(TabId::account_for(vendor, account.label.clone()));
226 }
227 } else if vendor == VendorId::Copilot {
228 if config.copilot.show_default_account || config.copilot.accounts.is_empty() {
229 tabs.push(TabId::vendor(vendor));
230 }
231 for account in &config.copilot.accounts {
232 tabs.push(TabId::account_for(vendor, account.label.clone()));
233 }
234 } else {
235 tabs.push(TabId::vendor(vendor));
236 }
237 }
238 for spec in config.enabled_custom() {
240 tabs.push(TabId::custom(spec));
241 }
242 tabs
243}
244
245#[cfg(target_os = "macos")]
249fn desktop_profile_labels(config: &Config) -> Vec<String> {
250 let Ok(paths) = crate::claude_desktop::Paths::resolve(&config.anthropic) else {
251 return Vec::new();
252 };
253 if !paths.available() {
254 return Vec::new();
255 }
256 crate::claude_desktop::load_profiles(&paths.profiles_dir)
257 .into_iter()
258 .filter(|p| p.has_credentials)
259 .map(|p| p.label)
260 .collect()
261}
262
263#[cfg(not(target_os = "macos"))]
264fn desktop_profile_labels(_config: &Config) -> Vec<String> {
265 Vec::new()
266}
267
268#[derive(Debug, Default, Clone)]
273pub struct HitTargets {
274 pub nav_entries: Vec<(NavTarget, Rect)>,
276 pub footer_actions: Vec<(FooterAction, Rect)>,
278 pub settings_rows: Vec<(SettingsRow, Rect)>,
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub enum NavTarget {
286 Overview,
287 Tab(usize),
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum FooterAction {
293 Refresh,
294 RefreshAll,
295 Settings,
296 Quit,
297}
298
299#[derive(Debug)]
300pub struct App {
301 pub tabs_meta: Vec<TabId>,
302 pub active: usize,
303 pub tabs: Vec<TabState>,
304 refreshing_tabs: HashSet<TabId>,
307 pub tab_generation: u64,
311 pub overview: bool,
314 pub overview_vendors: Option<Vec<VendorId>>,
316 pub theme: Theme,
317 pub quit: bool,
318 pub settings: Option<crate::tui::settings::SettingsState>,
320 pub context_enabled: bool,
323 pub context_generation: u64,
326 pub context: Option<crate::tui::context::ContextState>,
328 pub vendor_box: crate::config::VendorBoxStyle,
330 pub hit: std::rc::Rc<std::cell::RefCell<HitTargets>>,
332}
333
334impl App {
335 pub fn new(tabs_meta: Vec<TabId>) -> Self {
336 Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
339 }
340
341 pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
347 let n = tabs_meta.len();
348 Self {
349 tabs_meta,
350 active: 0,
351 tabs: vec![TabState::Loading; n],
352 refreshing_tabs: HashSet::new(),
353 tab_generation: 0,
354 overview: false,
355 overview_vendors: None,
356 theme,
357 quit: false,
358 settings: None,
359 context_enabled: false,
360 context_generation: 0,
361 context: None,
362 vendor_box: crate::config::VendorBoxStyle::Sidebar,
363 hit: std::rc::Rc::new(std::cell::RefCell::new(HitTargets::default())),
364 }
365 }
366
367 pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
371 let mut app = Self::new(tabs_meta);
372 if primary.is_some() {
375 app.select_primary(primary);
376 } else {
377 app.overview = true;
378 }
379 app
380 }
381
382 pub fn active_tab_id(&self) -> Option<&TabId> {
383 self.tabs_meta.get(self.active)
384 }
385
386 pub fn active_vendor(&self) -> Option<VendorId> {
387 self.tabs_meta.get(self.active).and_then(TabId::vendor_id)
388 }
389
390 pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
397 let selected = self.active_tab_id().cloned();
398 let fallback = self.active.min(tabs_meta.len().saturating_sub(1));
399 self.tab_generation = self.tab_generation.wrapping_add(1);
400 self.active = selected
401 .as_ref()
402 .and_then(|tab| tabs_meta.iter().position(|candidate| candidate == tab))
403 .unwrap_or(fallback);
404 self.tabs = vec![TabState::Loading; tabs_meta.len()];
405 self.tabs_meta = tabs_meta;
406 self.refreshing_tabs.clear();
407 }
408
409 pub fn begin_refresh(&mut self, tab: &TabId) -> bool {
413 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
414 return false;
415 };
416 if !self.refreshing_tabs.insert(tab.clone()) {
417 return false;
418 }
419 if !matches!(self.tabs[index], TabState::Ready(_)) {
420 self.tabs[index] = TabState::Loading;
421 }
422 true
423 }
424
425 pub fn is_refreshing(&self, tab: &TabId) -> bool {
426 self.refreshing_tabs.contains(tab)
427 }
428
429 pub fn tab_is_refreshing(&self, index: usize) -> bool {
430 self.tabs_meta
431 .get(index)
432 .is_some_and(|tab| self.is_refreshing(tab))
433 }
434
435 pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
440 if generation != self.tab_generation {
441 return false;
442 }
443 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
444 return false;
445 };
446 let was_refreshing = self.refreshing_tabs.remove(tab);
447 if was_refreshing
451 && let TabState::Ready(ready) = &mut self.tabs[index]
452 && let TabState::Error { message, .. } = state
453 {
454 ready.stale = true;
455 ready.last_error = Some((0, message));
456 } else {
457 self.tabs[index] = state;
458 }
459 true
460 }
461
462 pub fn select_primary(&mut self, primary: Option<VendorId>) {
465 if let Some(p) = primary
466 && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor_id() == Some(p))
467 {
468 self.active = idx;
469 self.overview = false;
470 }
471 }
472
473 pub fn next_tab(&mut self) {
476 if self.overview {
477 if !self.tabs_meta.is_empty() {
478 self.overview = false;
479 self.active = 0;
480 }
481 } else if self.active + 1 < self.tabs_meta.len() {
482 self.active += 1;
483 } else {
484 self.overview = true;
485 }
486 }
487
488 pub fn prev_tab(&mut self) {
489 if self.overview {
490 if !self.tabs_meta.is_empty() {
491 self.overview = false;
492 self.active = self.tabs_meta.len() - 1;
493 }
494 } else if self.active > 0 {
495 self.active -= 1;
496 } else {
497 self.overview = true;
498 }
499 }
500
501 pub fn select_overview(&mut self) {
503 self.overview = true;
504 }
505
506 pub fn select_tab(&mut self, index: usize) {
508 if index < self.tabs_meta.len() {
509 self.active = index;
510 self.overview = false;
511 }
512 }
513
514 pub fn nav_from_target(&mut self, target: NavTarget) {
516 match target {
517 NavTarget::Overview => self.select_overview(),
518 NavTarget::Tab(index) => self.select_tab(index),
519 }
520 }
521
522 pub fn overview_tabs(&self) -> Vec<usize> {
527 match &self.overview_vendors {
528 None => (0..self.tabs_meta.len()).collect(),
529 Some(wanted) => wanted
530 .iter()
531 .flat_map(|v| {
532 self.tabs_meta
533 .iter()
534 .enumerate()
535 .filter(move |(_, t)| t.vendor_id() == Some(*v))
536 .map(|(i, _)| i)
537 })
538 .collect(),
539 }
540 }
541}
542
543pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
545 match build_outcome(client, config, tab).await {
546 Ok(outcome) => {
547 let now = Utc::now();
552 let off_the_wire = outcome.off_the_wire();
553 let fetched_at = outcome
554 .cache_age
555 .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
556 let state = TabState::Ready(Box::new(ReadyTab {
557 snapshot: outcome.snapshot,
558 stale: outcome.stale,
559 last_error: outcome.last_error.map(|(code, message)| {
560 (code, crate::display::sanitize_untrusted_field(&message))
561 }),
562 fetched_at,
563 display: match &tab.source {
564 TabSource::Builtin(vendor) => config.display_prefs(*vendor),
565 TabSource::Custom { .. } => crate::balance::DisplayPrefs::default(),
568 },
569 }));
570 if off_the_wire
576 && config.notifications.enabled
577 && let Some(input) = crate::notify::RefreshInput::from_tab(tab, &state, now)
578 {
579 let _ = crate::notify::run(input, config.notifications.threshold).await;
580 }
581 state
582 }
583 Err(e) => TabState::error_with_plan(
584 crate::display::sanitize_untrusted_field(&e.user_message()),
585 e.plan().map(str::to_string),
586 ),
587 }
588}
589
590fn api_key_and_cache(
594 config: &Config,
595 vendor: VendorId,
596 account: Option<&str>,
597) -> Result<(String, crate::cache::Cache)> {
598 let api_key = config.resolve_account_api_key_for(vendor, account)?;
599 let cache = match account {
600 Some(label) => crate::cache::Cache::for_vendor_account(vendor.slug(), label)?,
601 None => crate::cache::Cache::for_vendor(vendor.slug())?,
602 };
603 Ok((api_key, cache))
604}
605
606async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
607 let vendor = match &tab.source {
608 TabSource::Builtin(vendor) => *vendor,
609 TabSource::Custom { id, .. } => {
610 let spec = config.custom_by_id(id).ok_or_else(|| {
613 AppError::Other(format!("custom provider {id} is not configured"))
614 })?;
615 let api_key = spec.resolve_api_key()?;
616 let cache = crate::cache::Cache::for_vendor_account("custom", id)?;
617 let outcome =
618 crate::custom::fetch_snapshot(client, spec, &api_key, &cache, spec.cache_ttl())
619 .await?;
620 return Ok(outcome.into());
621 }
622 };
623 match vendor {
624 VendorId::Anthropic => {
625 let (creds_target, cache) = match tab.account.as_deref() {
631 Some(label) if tab.desktop => {
632 crate::anthropic::desktop_creds::account_target(config, label)?
633 }
634 Some(label) => config.anthropic.account_target(label)?,
635 None => (
636 config.anthropic.default_creds_target(),
637 crate::cache::Cache::for_vendor("anthropic")?,
638 ),
639 };
640 let endpoints = crate::anthropic::fetch::Endpoints::default();
641 let outcome = crate::anthropic::fetch_snapshot(
642 client,
643 &creds_target,
644 &cache,
645 &endpoints,
646 DEFAULT_TTL,
647 )
648 .await?;
649 Ok(outcome.map(crate::usage::VendorSnapshot::Anthropic))
650 }
651 VendorId::AnthropicApi => {
652 let key = crate::config::resolve_api_key(
653 "Anthropic_API",
654 &config.anthropic_api.api_key_env,
655 config.anthropic_api.api_key.as_deref(),
656 )?;
657 let cache = crate::cache::Cache::for_vendor("anthropic_api")?;
658 let endpoints = crate::anthropic_api::fetch::Endpoints::default();
659 let outcome = crate::anthropic_api::fetch_snapshot(
660 client,
661 &key,
662 &cache,
663 &endpoints,
664 DEFAULT_TTL,
665 config.anthropic_api.monthly_limit,
666 )
667 .await?;
668 Ok(outcome.into())
669 }
670 VendorId::Openrouter => {
671 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
672 let management_key = config.openrouter_management_key(tab.account.as_deref());
673 let endpoints = crate::openrouter::fetch::Endpoints::default();
674 let outcome = crate::openrouter::fetch_snapshot(
675 client,
676 &api_key,
677 management_key.as_deref(),
678 &cache,
679 &endpoints,
680 DEFAULT_TTL,
681 )
682 .await?;
683 Ok(outcome.into())
684 }
685 VendorId::Zai => {
686 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
687 let endpoints = crate::zai::fetch::Endpoints::default();
688 let outcome = crate::zai::fetch_snapshot(
689 client,
690 &api_key,
691 &cache,
692 &endpoints,
693 DEFAULT_TTL,
694 config.zai.plan_tier.as_deref(),
695 )
696 .await?;
697 Ok(outcome.into())
698 }
699 VendorId::Openai => {
700 let label = tab.account.as_deref();
701 let cache = match label {
702 Some(label) => crate::cache::Cache::for_vendor_account("openai", label)?,
703 None => crate::cache::Cache::for_vendor("openai")?,
704 };
705 let route = || config.openai.fetch_auth_path(label);
706 let endpoints = crate::openai::fetch::Endpoints::default();
707 let outcome = crate::openai::fetch_snapshot_routed(
708 client,
709 route,
710 &cache,
711 &endpoints,
712 DEFAULT_TTL,
713 )
714 .await?;
715 Ok(outcome.into())
716 }
717 VendorId::Copilot => {
718 let label = tab.account.as_deref();
719 let token = config.copilot.resolve_token(label)?;
720 let cache = match label {
721 Some(label) => crate::cache::Cache::for_vendor_account("copilot", label)?,
722 None => crate::cache::Cache::for_vendor("copilot")?,
723 };
724 let endpoints = crate::copilot::fetch::Endpoints::default();
725 let outcome =
726 crate::copilot::fetch_snapshot(client, &token, &cache, &endpoints, DEFAULT_TTL)
727 .await?;
728 Ok(outcome.into())
729 }
730 VendorId::Deepseek => {
731 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
732 let endpoints = crate::deepseek::fetch::Endpoints::default();
733 let outcome =
734 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
735 .await?;
736 Ok(outcome.into())
737 }
738 VendorId::Deepinfra => {
739 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
740 let endpoints = crate::deepinfra::fetch::Endpoints::default();
741 let outcome = crate::deepinfra::fetch::fetch_snapshot(
742 client,
743 &api_key,
744 &cache,
745 &endpoints,
746 DEFAULT_TTL,
747 )
748 .await?;
749 Ok(outcome.into())
750 }
751 VendorId::Kimi => {
752 let (auth, endpoints) = crate::kimi::resolve_auth(&config.kimi)?;
753 let cache = crate::cache::Cache::for_vendor("kimi")?;
754 let outcome = crate::kimi::fetch::fetch_snapshot_with_auth(
755 client,
756 &auth,
757 &cache,
758 &endpoints,
759 DEFAULT_TTL,
760 )
761 .await?;
762 Ok(outcome.into())
763 }
764 VendorId::Kilo => {
765 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
766 let endpoints = crate::kilo::fetch::Endpoints::default();
767 let outcome = crate::kilo::fetch_snapshot(
768 client,
769 &api_key,
770 &cache,
771 &endpoints,
772 DEFAULT_TTL,
773 config.kilo.organization_id.as_deref(),
774 )
775 .await?;
776 Ok(outcome.into())
777 }
778 VendorId::Novita => {
779 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
780 let endpoints = crate::novita::fetch::Endpoints::default();
781 let outcome =
782 crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
783 .await?;
784 Ok(outcome.into())
785 }
786 VendorId::Moonshot => {
787 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
788 let (endpoints, currency) =
789 crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
790 let outcome = crate::moonshot::fetch_snapshot(
791 client,
792 &api_key,
793 &cache,
794 &endpoints,
795 DEFAULT_TTL,
796 currency,
797 )
798 .await?;
799 Ok(outcome.into())
800 }
801 VendorId::Grok => {
802 let (key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
803 let endpoints = crate::grok::fetch::Endpoints::default();
804 let outcome = crate::grok::fetch_snapshot(
805 client,
806 &key,
807 &cache,
808 &endpoints,
809 DEFAULT_TTL,
810 config.grok.team_id.as_deref(),
811 )
812 .await?;
813 Ok(outcome.into())
814 }
815 VendorId::Supergrok => {
816 let cache = crate::cache::Cache::for_vendor("supergrok")?;
817 let scope_paths = crate::supergrok::scope::ScopePaths::with_overrides(
818 config.supergrok.auth_path.as_deref(),
819 config.supergrok.config_path.as_deref(),
820 )?;
821 let outcome = crate::supergrok::fetch_snapshot(
822 &config.supergrok.grok_binary,
823 &scope_paths,
824 &cache,
825 DEFAULT_TTL,
826 )
827 .await?;
828 Ok(outcome.into())
829 }
830 VendorId::Antigravity => {
831 let cache = crate::cache::Cache::for_vendor("antigravity")?;
834 let oauth = crate::antigravity::cloud::OauthClient::from_config(
835 config.antigravity.oauth_client_id.as_deref(),
836 config.antigravity.oauth_client_secret.as_deref(),
837 );
838 let outcome =
839 crate::antigravity::fetch_snapshot(client, &cache, DEFAULT_TTL, oauth.as_ref())
840 .await?;
841 Ok(outcome.into())
842 }
843 VendorId::Grokbot => {
844 let creds = crate::grokbot::resolve_credentials(&config.grokbot)?;
847 let cache = crate::cache::Cache::for_vendor("grokbot")?;
848 let endpoints = crate::grokbot::fetch::Endpoints::default();
849 let outcome = crate::grokbot::fetch::fetch_snapshot_with(
850 client,
851 &creds,
852 &cache,
853 &endpoints,
854 DEFAULT_TTL,
855 )
856 .await?;
857 Ok(outcome.into())
858 }
859 VendorId::ModelStudio => {
860 let creds = crate::modelstudio::resolve_credentials(&config.modelstudio)?;
863 let cache = crate::cache::Cache::for_vendor("modelstudio")?;
864 let endpoints =
865 crate::modelstudio::fetch::Endpoints::for_gateway(creds.region, creds.site);
866 let outcome = crate::modelstudio::fetch_snapshot_with(
867 client,
868 &creds,
869 &cache,
870 &endpoints,
871 DEFAULT_TTL,
872 )
873 .await?;
874 Ok(outcome.into())
875 }
876 VendorId::Devin => {
877 let cache = crate::cache::Cache::for_vendor("devin")?;
878 let outcome =
879 crate::devin::fetch::fetch_snapshot(&config.devin, &cache, DEFAULT_TTL).await?;
880 Ok(outcome.into())
881 }
882 VendorId::Minimax => {
883 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
884 let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
885 let outcome =
886 crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
887 .await?;
888 Ok(outcome.into())
889 }
890 VendorId::Cursor => {
891 let cache = crate::cache::Cache::for_vendor("cursor")?;
892 let db_path = config
893 .cursor
894 .db_path
895 .clone()
896 .map(Ok)
897 .unwrap_or_else(crate::cursor::db::default_db_path)?;
898 let agent_auth_path = config
899 .cursor
900 .agent_auth_path
901 .clone()
902 .map(Ok)
903 .unwrap_or_else(crate::cursor::db::default_agent_auth_path)?;
904 let endpoints = crate::cursor::fetch::Endpoints::default();
905 let outcome = crate::cursor::fetch_snapshot(
906 client,
907 &db_path,
908 &agent_auth_path,
909 &cache,
910 &endpoints,
911 DEFAULT_TTL,
912 )
913 .await?;
914 Ok(outcome.into())
915 }
916 VendorId::Kiro => {
917 let cache = crate::cache::Cache::for_vendor("kiro")?;
918 let db_path = config
919 .kiro
920 .db_path
921 .clone()
922 .map(Ok)
923 .unwrap_or_else(crate::kiro::db::default_db_path)?;
924 let outcome =
925 crate::kiro::fetch_snapshot(client, &db_path, &cache, DEFAULT_TTL).await?;
926 Ok(outcome.into())
927 }
928 VendorId::NousResearch => {
929 let store = crate::nous::credentials::CredentialStore::default();
930 let endpoints = crate::nous::fetch::Endpoints::default();
931 let account = crate::nous::fetch::fetch_account_with_refresh(
932 client,
933 &store,
934 &endpoints,
935 Utc::now(),
936 )
937 .await?;
938 Ok(crate::outcome::Outcome::fresh(
940 crate::usage::VendorSnapshot::NousResearch(account),
941 ))
942 }
943 VendorId::OpenCodeGo => {
944 let api_key = crate::opencode_go::creds::resolve_key(
945 &config.opencode_go.api_key_env,
946 config.opencode_go.api_key.as_deref(),
947 config.opencode_go.auth_paths.as_deref(),
948 )?;
949 let cache = crate::cache::Cache::for_vendor("opencode-go")?;
950 let endpoints = crate::opencode_go::fetch::Endpoints::default();
951 let outcome = crate::opencode_go::fetch::fetch_snapshot(
952 client,
953 &api_key,
954 &cache,
955 &endpoints,
956 DEFAULT_TTL,
957 )
958 .await?;
959 Ok(outcome.into())
960 }
961 VendorId::CommandCode => {
962 let credential =
963 crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref())?;
964 let cache = crate::cache::Cache::for_vendor("commandcode")?;
965 let endpoints = crate::commandcode::fetch::Endpoints::default();
966 let outcome = crate::commandcode::fetch::fetch_snapshot(
967 client,
968 &credential.token,
969 &cache,
970 &endpoints,
971 DEFAULT_TTL,
972 )
973 .await?;
974 Ok(outcome.into())
975 }
976 VendorId::Ollama => {
977 let api_key = crate::config::resolve_api_key(
978 "Ollama",
979 &config.ollama.api_key_env,
980 config.ollama.api_key.as_deref(),
981 )?;
982 let cache = crate::cache::Cache::for_vendor("ollama")?;
983 let endpoints = crate::ollama::fetch::Endpoints::default();
984 let outcome = crate::ollama::fetch_snapshot(
985 client,
986 &api_key,
987 &config.ollama.plan,
988 &cache,
989 &endpoints,
990 DEFAULT_TTL,
991 )
992 .await?;
993 Ok(outcome.into())
994 }
995 VendorId::OrcaRouter => {
996 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
997 let endpoints = crate::orcarouter::fetch::Endpoints::default();
998 let outcome = crate::orcarouter::fetch_snapshot(
999 client,
1000 &api_key,
1001 &cache,
1002 &endpoints,
1003 DEFAULT_TTL,
1004 )
1005 .await?;
1006 Ok(outcome.into())
1007 }
1008 VendorId::Lyceum => {
1009 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
1010 let outcome =
1011 crate::lyceum::fetch::fetch_snapshot(client, &api_key, &cache, DEFAULT_TTL).await?;
1012 Ok(outcome.into())
1013 }
1014 }
1015}
1016
1017pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
1020
1021pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
1028
1029pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
1035 let mut anthropic_seen: u32 = 0;
1036 tabs.iter()
1037 .map(|tab| {
1038 if tab.vendor_id() == Some(VendorId::Anthropic) {
1039 let delay = step * anthropic_seen;
1040 anthropic_seen += 1;
1041 delay
1042 } else {
1043 Duration::ZERO
1044 }
1045 })
1046 .collect()
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051 use super::*;
1052 use chrono::TimeZone;
1053
1054 #[test]
1058 fn refresh_stagger_spaces_out_anthropic_tabs_only() {
1059 let step = Duration::from_millis(800);
1060 let tabs = vec![
1061 TabId::vendor(VendorId::Anthropic), TabId::account("work"),
1063 TabId::account("personal"),
1064 TabId::vendor(VendorId::Openai),
1065 TabId::vendor(VendorId::Zai),
1066 ];
1067 let delays = refresh_stagger(&tabs, step);
1068 assert_eq!(
1069 delays,
1070 vec![
1071 Duration::ZERO, step, step * 2, Duration::ZERO, Duration::ZERO, ]
1077 );
1078 }
1079
1080 #[test]
1081 fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
1082 let tabs = vec![
1084 TabId::vendor(VendorId::Anthropic),
1085 TabId::vendor(VendorId::Openrouter),
1086 ];
1087 assert!(
1088 refresh_stagger(&tabs, Duration::from_millis(800))
1089 .iter()
1090 .all(|d| d.is_zero())
1091 );
1092 }
1093
1094 #[test]
1095 fn select_primary_moves_to_enabled_vendor() {
1096 let mut app = App::with_theme(
1097 vec![
1098 TabId::vendor(VendorId::Anthropic),
1099 TabId::vendor(VendorId::Openrouter),
1100 ],
1101 Theme::default(),
1102 );
1103 app.select_primary(Some(VendorId::Openrouter));
1104 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
1105 }
1106
1107 #[test]
1108 fn select_primary_ignores_disabled_vendor() {
1109 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1110 app.select_primary(Some(VendorId::Openai));
1111 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
1112 }
1113
1114 #[test]
1115 fn nav_ring_wraps_through_the_overview_at_both_ends() {
1116 let mut app = App::with_theme(
1117 vec![
1118 TabId::vendor(VendorId::Anthropic),
1119 TabId::vendor(VendorId::Openai),
1120 ],
1121 Theme::default(),
1122 );
1123 app.overview = true;
1124
1125 app.next_tab(); assert!(!app.overview);
1127 assert_eq!(app.active, 0);
1128 app.next_tab();
1129 assert_eq!(app.active, 1);
1130 app.next_tab(); assert!(app.overview);
1132
1133 app.prev_tab(); assert!(!app.overview);
1135 assert_eq!(app.active, 1);
1136 app.prev_tab();
1137 assert_eq!(app.active, 0);
1138 app.prev_tab(); assert!(app.overview);
1140 }
1141
1142 #[test]
1143 fn mouse_nav_targets_select_overview_and_tabs() {
1144 let mut app = App::with_theme(
1145 vec![
1146 TabId::vendor(VendorId::Anthropic),
1147 TabId::vendor(VendorId::Openai),
1148 ],
1149 Theme::default(),
1150 );
1151 app.overview = true;
1152 app.nav_from_target(NavTarget::Tab(1));
1153 assert!(!app.overview);
1154 assert_eq!(app.active, 1);
1155 app.nav_from_target(NavTarget::Overview);
1156 assert!(app.overview);
1157 app.nav_from_target(NavTarget::Tab(99));
1159 assert!(app.overview);
1160 }
1161
1162 #[test]
1163 fn hit_targets_default_to_empty() {
1164 let app = App::with_theme(Vec::new(), Theme::default());
1165 assert!(app.hit.borrow().nav_entries.is_empty());
1166 assert!(app.hit.borrow().footer_actions.is_empty());
1167 assert!(app.hit.borrow().settings_rows.is_empty());
1168 }
1169
1170 #[test]
1171 fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
1172 let mut app = App::with_theme(
1173 vec![
1174 TabId::vendor(VendorId::Anthropic),
1175 TabId::vendor(VendorId::Openai),
1176 TabId::vendor(VendorId::Zai),
1177 ],
1178 Theme::default(),
1179 );
1180 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1181
1182 app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
1184 assert_eq!(app.overview_tabs(), vec![2, 0]);
1185
1186 app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
1188 assert_eq!(app.overview_tabs(), vec![1]);
1189 }
1190
1191 fn config_with_accounts(labels: &[&str]) -> Config {
1192 let mut config = Config::default();
1193 config.openai.enabled = false;
1196 config.zai.enabled = false;
1197 config.openrouter.enabled = false;
1198 config.commandcode.enabled = false;
1199 config.anthropic.accounts = labels
1200 .iter()
1201 .map(|l| crate::config::AnthropicAccount {
1202 label: (*l).to_string(),
1203 credentials_path: format!("/creds/{l}.json").into(),
1204 })
1205 .collect();
1206 config
1207 }
1208
1209 #[test]
1210 fn show_default_account_false_hides_the_unnamed_claude_tab() {
1211 let mut config = config_with_accounts(&["work", "personal"]);
1214 config.anthropic.show_default_account = false;
1215 assert_eq!(
1216 tabs_from_config(&config),
1217 vec![TabId::account("work"), TabId::account("personal")]
1218 );
1219
1220 let mut empty = Config::default();
1223 empty.openai.enabled = false;
1224 empty.zai.enabled = false;
1225 empty.openrouter.enabled = false;
1226 empty.commandcode.enabled = false;
1227 empty.anthropic.show_default_account = false;
1228 assert_eq!(
1229 tabs_from_config(&empty),
1230 vec![TabId::vendor(VendorId::Anthropic)]
1231 );
1232 }
1233
1234 #[test]
1235 fn tabs_expand_anthropic_accounts_after_default() {
1236 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
1238 assert_eq!(
1239 tabs,
1240 vec![
1241 TabId::vendor(VendorId::Anthropic),
1242 TabId::account("work"),
1243 TabId::account("personal"),
1244 ]
1245 );
1246 }
1247
1248 #[test]
1249 fn tabs_without_accounts_are_just_enabled_vendors() {
1250 let config = Config::default();
1252 let tabs = tabs_from_config(&config);
1253 let vendors: Vec<VendorId> = tabs.iter().filter_map(TabId::vendor_id).collect();
1254 assert_eq!(vendors, config.enabled_vendors());
1255 assert!(tabs.iter().all(|t| t.account.is_none()));
1256 }
1257
1258 #[test]
1259 fn tabs_expand_openrouter_accounts_without_changing_other_vendors() {
1260 let mut config = Config::default();
1261 config.anthropic.enabled = false;
1262 config.openai.enabled = false;
1263 config.zai.enabled = false;
1264 config.commandcode.enabled = false;
1265 config.openrouter.accounts = vec![
1266 crate::config::ApiKeyAccount {
1267 label: "work".into(),
1268 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1269 api_key: None,
1270 management_api_key_env: None,
1271 },
1272 crate::config::ApiKeyAccount {
1273 label: "personal".into(),
1274 api_key_env: None,
1275 api_key: Some("personal-key".into()),
1276 management_api_key_env: None,
1277 },
1278 ];
1279 assert_eq!(
1280 tabs_from_config(&config),
1281 vec![
1282 TabId::vendor(VendorId::Openrouter),
1283 TabId::account_for(VendorId::Openrouter, "work"),
1284 TabId::account_for(VendorId::Openrouter, "personal"),
1285 ]
1286 );
1287 }
1288
1289 #[test]
1290 fn openai_named_accounts_get_their_own_tabs_after_the_default() {
1291 let mut config = Config::default();
1292 config.anthropic.enabled = false;
1293 config.zai.enabled = false;
1294 config.openrouter.enabled = false;
1295 config.commandcode.enabled = false;
1296 config.openai.accounts.push(crate::config::OpenAiAccount {
1297 label: "work".into(),
1298 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1299 });
1300 assert_eq!(
1301 tabs_from_config(&config),
1302 vec![
1303 TabId::vendor(VendorId::Openai),
1304 TabId::account_for(VendorId::Openai, "work"),
1305 ]
1306 );
1307 }
1308
1309 #[test]
1310 fn copilot_named_accounts_get_their_own_tabs_and_can_hide_the_default() {
1311 let base = || {
1312 let mut config = Config::default();
1313 config.anthropic.enabled = false;
1314 config.openai.enabled = false;
1315 config.zai.enabled = false;
1316 config.openrouter.enabled = false;
1317 config.commandcode.enabled = false;
1318 config.copilot.enabled = true;
1319 config
1320 };
1321
1322 assert_eq!(
1324 tabs_from_config(&base()),
1325 vec![TabId::vendor(VendorId::Copilot)]
1326 );
1327
1328 let mut config = base();
1330 config.copilot.accounts.push(crate::config::CopilotAccount {
1331 label: "work".into(),
1332 user: "octocat-work".into(),
1333 });
1334 config.copilot.accounts.push(crate::config::CopilotAccount {
1335 label: "personal".into(),
1336 user: "octocat".into(),
1337 });
1338 assert_eq!(
1339 tabs_from_config(&config),
1340 vec![
1341 TabId::vendor(VendorId::Copilot),
1342 TabId::account_for(VendorId::Copilot, "work"),
1343 TabId::account_for(VendorId::Copilot, "personal"),
1344 ]
1345 );
1346
1347 config.copilot.show_default_account = false;
1349 assert_eq!(
1350 tabs_from_config(&config),
1351 vec![
1352 TabId::account_for(VendorId::Copilot, "work"),
1353 TabId::account_for(VendorId::Copilot, "personal"),
1354 ]
1355 );
1356
1357 let mut empty = base();
1359 empty.copilot.show_default_account = false;
1360 assert_eq!(
1361 tabs_from_config(&empty),
1362 vec![TabId::vendor(VendorId::Copilot)]
1363 );
1364 }
1365
1366 #[test]
1367 fn openrouter_can_hide_default_only_when_named_accounts_exist() {
1368 let mut config = Config::default();
1369 config.anthropic.enabled = false;
1370 config.openai.enabled = false;
1371 config.zai.enabled = false;
1372 config.commandcode.enabled = false;
1373 config.openrouter.show_default_account = false;
1374 assert_eq!(
1375 tabs_from_config(&config),
1376 vec![TabId::vendor(VendorId::Openrouter)]
1377 );
1378
1379 config
1380 .openrouter
1381 .accounts
1382 .push(crate::config::ApiKeyAccount {
1383 label: "work".into(),
1384 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1385 api_key: None,
1386 management_api_key_env: None,
1387 });
1388 assert_eq!(
1389 tabs_from_config(&config),
1390 vec![TabId::account_for(VendorId::Openrouter, "work")]
1391 );
1392 }
1393
1394 #[test]
1395 fn every_api_key_account_vendor_fans_out_like_openrouter() {
1396 let config_for = |vendor: VendorId, show_default: bool| -> Config {
1397 let section = vendor.config_section();
1398 let mut text: String = ["anthropic", "openai", "commandcode", "zai", "openrouter"]
1401 .into_iter()
1402 .filter(|other| *other != section)
1403 .map(|other| format!("[{other}]\nenabled = false\n"))
1404 .collect();
1405 text.push_str(&format!(
1406 "[{section}]\nenabled = true\nshow_default_account = {show_default}\n\
1407 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k1\"\n\
1408 [[{section}.accounts]]\nlabel = \"personal\"\napi_key = \"k2\"\n"
1409 ));
1410 let config: Config = toml::from_str(&text).unwrap();
1411 config.validate().unwrap();
1412 config
1413 };
1414 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
1415 let named = vec![
1416 TabId::account_for(vendor, "work"),
1417 TabId::account_for(vendor, "personal"),
1418 ];
1419 let mut with_default = vec![TabId::vendor(vendor)];
1420 with_default.extend(named.clone());
1421 assert_eq!(
1422 tabs_from_config(&config_for(vendor, true)),
1423 with_default,
1424 "{vendor:?}"
1425 );
1426 assert_eq!(
1427 tabs_from_config(&config_for(vendor, false)),
1428 named,
1429 "{vendor:?}"
1430 );
1431 }
1432 }
1433
1434 #[test]
1435 fn codex_can_hide_default_only_when_named_accounts_exist() {
1436 let mut config = Config::default();
1437 config.anthropic.enabled = false;
1438 config.zai.enabled = false;
1439 config.openrouter.enabled = false;
1440 config.commandcode.enabled = false;
1441 config.openai.show_default_account = false;
1442 assert_eq!(
1443 tabs_from_config(&config),
1444 vec![TabId::vendor(VendorId::Openai)]
1445 );
1446
1447 config.openai.accounts.push(crate::config::OpenAiAccount {
1448 label: "work".into(),
1449 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1450 });
1451 assert_eq!(
1452 tabs_from_config(&config),
1453 vec![TabId::account_for(VendorId::Openai, "work")]
1454 );
1455 }
1456
1457 #[test]
1458 fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
1459 let td = tempfile::tempdir().unwrap();
1462 for label in ["work", "personal"] {
1463 let dir = td.path().join(label);
1464 std::fs::create_dir_all(&dir).unwrap();
1465 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1466 }
1467 let mut config = Config::default();
1468 config.openai.enabled = false;
1469 config.zai.enabled = false;
1470 config.openrouter.enabled = false;
1471 config.commandcode.enabled = false;
1472 config.anthropic.accounts_dir = Some(td.path().to_path_buf());
1473
1474 let tabs = tabs_from_config(&config);
1475 assert_eq!(
1476 tabs,
1477 vec![
1478 TabId::vendor(VendorId::Anthropic),
1479 TabId::account("personal"), TabId::account("work"),
1481 ]
1482 );
1483 }
1484
1485 #[test]
1486 fn desktop_labels_become_account_tabs_after_cli_accounts() {
1487 let config = config_with_accounts(&["work"]);
1489 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1490 assert_eq!(
1491 tabs,
1492 vec![
1493 TabId::vendor(VendorId::Anthropic),
1494 TabId::account("work"),
1495 TabId::desktop_account("gmail"),
1496 TabId::desktop_account("hotmail"),
1497 ]
1498 );
1499 }
1500
1501 #[test]
1502 fn a_desktop_profile_wins_a_label_collision_with_a_cli_account() {
1503 let config = config_with_accounts(&["gmail", "work"]);
1508 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1509 assert_eq!(
1510 tabs,
1511 vec![
1512 TabId::vendor(VendorId::Anthropic),
1513 TabId::account("work"),
1514 TabId::desktop_account("gmail"),
1515 TabId::desktop_account("hotmail"),
1516 ]
1517 );
1518 }
1519
1520 #[test]
1521 fn desktop_accounts_suppress_the_default_tab_like_named_ones() {
1522 let mut config = config_with_accounts(&[]);
1525 config.cursor.enabled = false;
1526 config.anthropic.show_default_account = false;
1527
1528 assert_eq!(
1531 build_tabs(&config, &[]),
1532 vec![TabId::vendor(VendorId::Anthropic)]
1533 );
1534 assert_eq!(
1536 build_tabs(&config, &["gmail".into()]),
1537 vec![TabId::desktop_account("gmail")]
1538 );
1539 }
1540
1541 #[test]
1542 fn set_tabs_resets_states_and_clamps_selection() {
1543 let mut app = App::with_theme(
1547 tabs_from_config(&config_with_accounts(&["work", "personal"])),
1548 Theme::default(),
1549 );
1550 app.active = 2; app.tabs[0] = TabState::error("old");
1552 let old_tab = app.tabs_meta[0].clone();
1553 assert!(app.begin_refresh(&old_tab));
1554
1555 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
1556 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
1557 assert_eq!(app.active, 0, "selection clamped after shrink");
1558 assert!(matches!(app.tabs[0], TabState::Loading));
1559 assert!(!app.is_refreshing(&old_tab));
1560 }
1561
1562 #[test]
1563 fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
1564 let mut app = App::with_theme(
1565 vec![
1566 TabId::vendor(VendorId::Anthropic),
1567 TabId::vendor(VendorId::Openai),
1568 ],
1569 Theme::default(),
1570 );
1571 app.active = 1;
1572
1573 app.set_tabs(vec![
1574 TabId::vendor(VendorId::Anthropic),
1575 TabId::account("work"),
1576 TabId::vendor(VendorId::Openai),
1577 ]);
1578
1579 assert_eq!(app.active, 2);
1580 assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
1581 }
1582
1583 #[test]
1584 fn refresh_from_old_generation_is_discarded() {
1585 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1586 let old_generation = app.tab_generation;
1587 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
1588
1589 assert!(!app.apply_refresh(
1590 old_generation,
1591 &TabId::vendor(VendorId::Anthropic),
1592 TabState::error("old result"),
1593 ));
1594 assert!(matches!(app.tabs[0], TabState::Loading));
1595 }
1596
1597 #[test]
1598 fn refresh_identity_mismatch_is_discarded() {
1599 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1600 let generation = app.tab_generation;
1601
1602 assert!(!app.apply_refresh(
1603 generation,
1604 &TabId::vendor(VendorId::Openai),
1605 TabState::error("wrong tab"),
1606 ));
1607 assert!(matches!(app.tabs[0], TabState::Loading));
1608 }
1609
1610 #[test]
1611 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
1612 let anthropic = TabId::vendor(VendorId::Anthropic);
1613 let openai = TabId::vendor(VendorId::Openai);
1614 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
1615 let generation = app.tab_generation;
1616 assert!(app.begin_refresh(&anthropic));
1617
1618 app.tabs_meta.swap(0, 1);
1621 app.tabs.swap(0, 1);
1622 assert!(app.apply_refresh(generation, &anthropic, TabState::error("ready")));
1623 assert!(matches!(app.tabs[0], TabState::Loading));
1624 assert!(matches!(&app.tabs[1], TabState::Error { message, .. } if message == "ready"));
1625 assert!(!app.is_refreshing(&anthropic));
1626 }
1627
1628 fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
1629 TabState::Ready(Box::new(ReadyTab {
1630 snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
1631 label: "test".into(),
1632 total_credits: 0.0,
1633 total_usage: 0.0,
1634 usage_daily: 0.0,
1635 usage_weekly: 0.0,
1636 usage_monthly: 0.0,
1637 is_free_tier: false,
1638 limit: None,
1639 limit_remaining: None,
1640 recent_models: Vec::new(),
1641 }),
1642 stale: false,
1643 last_error: None,
1644 fetched_at: Some(fetched_at),
1645 display: Default::default(),
1646 }))
1647 }
1648
1649 #[test]
1650 fn refresh_keeps_ready_snapshot_visible_and_suppresses_duplicates() {
1651 let tab = TabId::vendor(VendorId::Openrouter);
1652 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1653 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1654 app.tabs[0] = ready_at(fetched_at);
1655
1656 assert!(app.begin_refresh(&tab));
1657 assert!(
1658 !app.begin_refresh(&tab),
1659 "duplicate request must be suppressed"
1660 );
1661 assert!(app.is_refreshing(&tab));
1662 match &app.tabs[0] {
1663 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1664 other => panic!("ready snapshot disappeared during refresh: {other:?}"),
1665 }
1666 }
1667
1668 #[test]
1669 fn first_refresh_still_uses_loading_until_data_arrives() {
1670 let tab = TabId::vendor(VendorId::Openrouter);
1671 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1672
1673 assert!(app.begin_refresh(&tab));
1674 assert!(app.is_refreshing(&tab));
1675 assert!(matches!(app.tabs[0], TabState::Loading));
1676
1677 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("not signed in"),));
1678 assert!(!app.is_refreshing(&tab));
1679 assert!(
1680 matches!(&app.tabs[0], TabState::Error { message, .. } if message == "not signed in")
1681 );
1682 }
1683
1684 #[test]
1685 fn successful_revalidation_replaces_snapshot_and_clears_indicator() {
1686 let tab = TabId::vendor(VendorId::Openrouter);
1687 let old_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1688 let new_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 1, 0).unwrap();
1689 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1690 app.tabs[0] = ready_at(old_at);
1691
1692 assert!(app.begin_refresh(&tab));
1693 assert!(app.apply_refresh(app.tab_generation, &tab, ready_at(new_at)));
1694 assert!(!app.is_refreshing(&tab));
1695 match &app.tabs[0] {
1696 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(new_at)),
1697 other => panic!("expected replacement snapshot, got {other:?}"),
1698 }
1699 }
1700
1701 #[test]
1702 fn failed_revalidation_preserves_snapshot_with_visible_warning() {
1703 let tab = TabId::vendor(VendorId::Openrouter);
1704 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1705 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1706 app.tabs[0] = ready_at(fetched_at);
1707
1708 assert!(app.begin_refresh(&tab));
1709 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("refresh failed"),));
1710 assert!(!app.is_refreshing(&tab));
1711 match &app.tabs[0] {
1712 TabState::Ready(ready) => {
1713 assert_eq!(ready.fetched_at, Some(fetched_at));
1714 assert!(ready.stale);
1715 assert_eq!(ready.last_error, Some((0, "refresh failed".into())));
1716 }
1717 other => panic!("last successful snapshot was lost: {other:?}"),
1718 }
1719 let sections = crate::tui::panels::sections_for(&app.tabs[0], Utc::now(), 5);
1720 assert!(sections.iter().any(|section| matches!(
1721 section,
1722 crate::tui::panels::Section::Text { label, value }
1723 if label == "Warning" && value == "refresh failed"
1724 )));
1725 }
1726
1727 #[test]
1728 fn old_generation_result_does_not_clear_current_refresh() {
1729 let tab = TabId::vendor(VendorId::Openrouter);
1730 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1731 let old_generation = app.tab_generation;
1732 app.set_tabs(vec![tab.clone()]);
1733 assert!(app.begin_refresh(&tab));
1734
1735 assert!(!app.apply_refresh(old_generation, &tab, TabState::error("old result"),));
1736 assert!(app.is_refreshing(&tab));
1737 assert!(matches!(app.tabs[0], TabState::Loading));
1738 }
1739
1740 #[test]
1741 fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
1742 let anthropic = TabId::vendor(VendorId::Anthropic);
1748 let openai = TabId::vendor(VendorId::Openai);
1749 let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
1750 let generation = app.tab_generation;
1751 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1752
1753 assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
1754 match &app.tabs[0] {
1755 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1756 other => panic!("expected Anthropic tab Ready, got {other:?}"),
1757 }
1758 assert!(matches!(app.tabs[1], TabState::Loading));
1759 }
1760
1761 #[test]
1762 fn select_primary_lands_on_default_account_tab() {
1763 let app = {
1766 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
1767 let mut a = App::with_theme(tabs, Theme::default());
1768 a.select_primary(Some(VendorId::Anthropic));
1769 a
1770 };
1771 assert_eq!(app.active, 0);
1772 assert_eq!(
1773 app.active_tab_id(),
1774 Some(&TabId::vendor(VendorId::Anthropic))
1775 );
1776 }
1777
1778 fn custom_spec(id: &str, enabled: bool) -> CustomProviderConfig {
1779 CustomProviderConfig {
1780 id: id.into(),
1781 name: "My Tool".into(),
1782 short_name: "myt".into(),
1783 enabled,
1784 ..Default::default()
1785 }
1786 }
1787
1788 #[test]
1789 fn custom_providers_get_tabs_after_every_builtin() {
1790 let config = Config {
1791 custom: vec![custom_spec("mytool", true), custom_spec("other", true)],
1792 ..Default::default()
1793 };
1794 let tabs = tabs_from_config(&config);
1795 let builtin_count = config.enabled_vendors().len();
1796 assert_eq!(tabs.len(), builtin_count + 2);
1797 assert!(
1798 tabs[..builtin_count]
1799 .iter()
1800 .all(|t| matches!(t.source, TabSource::Builtin(_)))
1801 );
1802 assert_eq!(tabs[builtin_count], TabId::custom(&config.custom[0]));
1803 assert_eq!(tabs[builtin_count + 1], TabId::custom(&config.custom[1]));
1804 assert_eq!(
1805 tabs[builtin_count].source,
1806 TabSource::Custom {
1807 id: "mytool".into(),
1808 name: "My Tool".into(),
1809 short_name: "myt".into(),
1810 }
1811 );
1812 assert!(tabs[builtin_count].account.is_none());
1813 assert!(!tabs[builtin_count].desktop);
1814 assert_eq!(tabs[builtin_count].vendor_id(), None);
1815 }
1816
1817 #[test]
1818 fn disabled_custom_provider_has_no_tab() {
1819 let config = Config {
1820 custom: vec![custom_spec("mytool", false)],
1821 ..Default::default()
1822 };
1823 let tabs = tabs_from_config(&config);
1824 assert!(
1825 tabs.iter()
1826 .all(|t| matches!(t.source, TabSource::Builtin(_)))
1827 );
1828 assert_eq!(tabs.len(), config.enabled_vendors().len());
1829 }
1830
1831 #[test]
1832 fn custom_tabs_are_listed_by_the_default_overview_but_not_by_a_vendor_filter() {
1833 let spec = custom_spec("mytool", true);
1834 let mut app = App::with_theme(
1835 vec![
1836 TabId::vendor(VendorId::Anthropic),
1837 TabId::custom(&spec),
1838 TabId::vendor(VendorId::Openai),
1839 ],
1840 Theme::default(),
1841 );
1842 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1843
1844 app.overview_vendors = Some(vec![VendorId::Openai, VendorId::Anthropic]);
1846 assert_eq!(app.overview_tabs(), vec![2, 0]);
1847 }
1848
1849 #[test]
1850 fn a_custom_active_tab_has_no_vendor_and_never_staggers() {
1851 let spec = custom_spec("mytool", true);
1852 let tabs = vec![
1853 TabId::vendor(VendorId::Anthropic),
1854 TabId::custom(&spec),
1855 TabId::account("work"),
1856 ];
1857 let mut app = App::with_theme(tabs.clone(), Theme::default());
1858 app.active = 1;
1859 assert_eq!(app.active_vendor(), None);
1860 assert_eq!(app.active_tab_id(), Some(&TabId::custom(&spec)));
1861
1862 app.select_primary(Some(VendorId::Anthropic));
1864 assert_eq!(app.active, 0);
1865
1866 let step = Duration::from_millis(800);
1867 assert_eq!(
1868 refresh_stagger(&tabs, step),
1869 vec![Duration::ZERO, Duration::ZERO, step]
1870 );
1871 }
1872}