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