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 endpoints = crate::openrouter::fetch::Endpoints::default();
671 let outcome = crate::openrouter::fetch_snapshot(
672 client,
673 &api_key,
674 &cache,
675 &endpoints,
676 DEFAULT_TTL,
677 )
678 .await?;
679 Ok(outcome.into())
680 }
681 VendorId::Zai => {
682 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
683 let endpoints = crate::zai::fetch::Endpoints::default();
684 let outcome = crate::zai::fetch_snapshot(
685 client,
686 &api_key,
687 &cache,
688 &endpoints,
689 DEFAULT_TTL,
690 config.zai.plan_tier.as_deref(),
691 )
692 .await?;
693 Ok(outcome.into())
694 }
695 VendorId::Openai => {
696 let label = tab.account.as_deref();
697 let cache = match label {
698 Some(label) => crate::cache::Cache::for_vendor_account("openai", label)?,
699 None => crate::cache::Cache::for_vendor("openai")?,
700 };
701 let route = || config.openai.fetch_auth_path(label);
702 let endpoints = crate::openai::fetch::Endpoints::default();
703 let outcome = crate::openai::fetch_snapshot_routed(
704 client,
705 route,
706 &cache,
707 &endpoints,
708 DEFAULT_TTL,
709 )
710 .await?;
711 Ok(outcome.into())
712 }
713 VendorId::Copilot => {
714 let token = config.copilot.resolve_token()?;
715 let cache = crate::cache::Cache::for_vendor("copilot")?;
716 let endpoints = crate::copilot::fetch::Endpoints::default();
717 let outcome =
718 crate::copilot::fetch_snapshot(client, &token, &cache, &endpoints, DEFAULT_TTL)
719 .await?;
720 Ok(outcome.into())
721 }
722 VendorId::Deepseek => {
723 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
724 let endpoints = crate::deepseek::fetch::Endpoints::default();
725 let outcome =
726 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
727 .await?;
728 Ok(outcome.into())
729 }
730 VendorId::Kimi => {
731 let (auth, endpoints) = crate::kimi::resolve_auth(&config.kimi)?;
732 let cache = crate::cache::Cache::for_vendor("kimi")?;
733 let outcome = crate::kimi::fetch::fetch_snapshot_with_auth(
734 client,
735 &auth,
736 &cache,
737 &endpoints,
738 DEFAULT_TTL,
739 )
740 .await?;
741 Ok(outcome.into())
742 }
743 VendorId::Kilo => {
744 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
745 let endpoints = crate::kilo::fetch::Endpoints::default();
746 let outcome = crate::kilo::fetch_snapshot(
747 client,
748 &api_key,
749 &cache,
750 &endpoints,
751 DEFAULT_TTL,
752 config.kilo.organization_id.as_deref(),
753 )
754 .await?;
755 Ok(outcome.into())
756 }
757 VendorId::Novita => {
758 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
759 let endpoints = crate::novita::fetch::Endpoints::default();
760 let outcome =
761 crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
762 .await?;
763 Ok(outcome.into())
764 }
765 VendorId::Moonshot => {
766 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
767 let (endpoints, currency) =
768 crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
769 let outcome = crate::moonshot::fetch_snapshot(
770 client,
771 &api_key,
772 &cache,
773 &endpoints,
774 DEFAULT_TTL,
775 currency,
776 )
777 .await?;
778 Ok(outcome.into())
779 }
780 VendorId::Grok => {
781 let (key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
782 let endpoints = crate::grok::fetch::Endpoints::default();
783 let outcome = crate::grok::fetch_snapshot(
784 client,
785 &key,
786 &cache,
787 &endpoints,
788 DEFAULT_TTL,
789 config.grok.team_id.as_deref(),
790 )
791 .await?;
792 Ok(outcome.into())
793 }
794 VendorId::Supergrok => {
795 let cache = crate::cache::Cache::for_vendor("supergrok")?;
796 let scope_paths = crate::supergrok::scope::ScopePaths::with_overrides(
797 config.supergrok.auth_path.as_deref(),
798 config.supergrok.config_path.as_deref(),
799 )?;
800 let outcome = crate::supergrok::fetch_snapshot(
801 &config.supergrok.grok_binary,
802 &scope_paths,
803 &cache,
804 DEFAULT_TTL,
805 )
806 .await?;
807 Ok(outcome.into())
808 }
809 VendorId::Antigravity => {
810 let cache = crate::cache::Cache::for_vendor("antigravity")?;
813 let oauth = crate::antigravity::cloud::OauthClient::from_config(
814 config.antigravity.oauth_client_id.as_deref(),
815 config.antigravity.oauth_client_secret.as_deref(),
816 );
817 let outcome =
818 crate::antigravity::fetch_snapshot(client, &cache, DEFAULT_TTL, oauth.as_ref())
819 .await?;
820 Ok(outcome.into())
821 }
822 VendorId::Grokbot => {
823 let creds = crate::grokbot::resolve_credentials(&config.grokbot)?;
826 let cache = crate::cache::Cache::for_vendor("grokbot")?;
827 let endpoints = crate::grokbot::fetch::Endpoints::default();
828 let outcome = crate::grokbot::fetch::fetch_snapshot_with(
829 client,
830 &creds,
831 &cache,
832 &endpoints,
833 DEFAULT_TTL,
834 )
835 .await?;
836 Ok(outcome.into())
837 }
838 VendorId::ModelStudio => {
839 let creds = crate::modelstudio::resolve_credentials(&config.modelstudio)?;
842 let cache = crate::cache::Cache::for_vendor("modelstudio")?;
843 let endpoints =
844 crate::modelstudio::fetch::Endpoints::for_gateway(creds.region, creds.site);
845 let outcome = crate::modelstudio::fetch_snapshot_with(
846 client,
847 &creds,
848 &cache,
849 &endpoints,
850 DEFAULT_TTL,
851 )
852 .await?;
853 Ok(outcome.into())
854 }
855 VendorId::Minimax => {
856 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
857 let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
858 let outcome =
859 crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
860 .await?;
861 Ok(outcome.into())
862 }
863 VendorId::Cursor => {
864 let cache = crate::cache::Cache::for_vendor("cursor")?;
865 let db_path = config
866 .cursor
867 .db_path
868 .clone()
869 .map(Ok)
870 .unwrap_or_else(crate::cursor::db::default_db_path)?;
871 let agent_auth_path = config
872 .cursor
873 .agent_auth_path
874 .clone()
875 .map(Ok)
876 .unwrap_or_else(crate::cursor::db::default_agent_auth_path)?;
877 let endpoints = crate::cursor::fetch::Endpoints::default();
878 let outcome = crate::cursor::fetch_snapshot(
879 client,
880 &db_path,
881 &agent_auth_path,
882 &cache,
883 &endpoints,
884 DEFAULT_TTL,
885 )
886 .await?;
887 Ok(outcome.into())
888 }
889 VendorId::Kiro => {
890 let cache = crate::cache::Cache::for_vendor("kiro")?;
891 let db_path = config
892 .kiro
893 .db_path
894 .clone()
895 .map(Ok)
896 .unwrap_or_else(crate::kiro::db::default_db_path)?;
897 let outcome =
898 crate::kiro::fetch_snapshot(client, &db_path, &cache, DEFAULT_TTL).await?;
899 Ok(outcome.into())
900 }
901 VendorId::NousResearch => {
902 let store = crate::nous::credentials::CredentialStore::default();
903 let endpoints = crate::nous::fetch::Endpoints::default();
904 let account = crate::nous::fetch::fetch_account_with_refresh(
905 client,
906 &store,
907 &endpoints,
908 Utc::now(),
909 )
910 .await?;
911 Ok(crate::outcome::Outcome::fresh(
913 crate::usage::VendorSnapshot::NousResearch(account),
914 ))
915 }
916 VendorId::OpenCodeGo => {
917 let api_key = crate::config::resolve_api_key(
918 "OpenCode Go",
919 &config.opencode_go.api_key_env,
920 config.opencode_go.api_key.as_deref(),
921 )?;
922 let cache = crate::cache::Cache::for_vendor("opencode-go")?;
923 let endpoints = crate::opencode_go::fetch::Endpoints::default();
924 let outcome = crate::opencode_go::fetch::fetch_snapshot(
925 client,
926 &api_key,
927 &cache,
928 &endpoints,
929 DEFAULT_TTL,
930 )
931 .await?;
932 Ok(outcome.into())
933 }
934 VendorId::CommandCode => {
935 let credential =
936 crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref())?;
937 let cache = crate::cache::Cache::for_vendor("commandcode")?;
938 let endpoints = crate::commandcode::fetch::Endpoints::default();
939 let outcome = crate::commandcode::fetch::fetch_snapshot(
940 client,
941 &credential.token,
942 &cache,
943 &endpoints,
944 DEFAULT_TTL,
945 )
946 .await?;
947 Ok(outcome.into())
948 }
949 VendorId::Ollama => {
950 let api_key = crate::config::resolve_api_key(
951 "Ollama",
952 &config.ollama.api_key_env,
953 config.ollama.api_key.as_deref(),
954 )?;
955 let cache = crate::cache::Cache::for_vendor("ollama")?;
956 let endpoints = crate::ollama::fetch::Endpoints::default();
957 let outcome = crate::ollama::fetch_snapshot(
958 client,
959 &api_key,
960 &config.ollama.plan,
961 &cache,
962 &endpoints,
963 DEFAULT_TTL,
964 )
965 .await?;
966 Ok(outcome.into())
967 }
968 VendorId::OrcaRouter => {
969 let (api_key, cache) = api_key_and_cache(config, vendor, tab.account.as_deref())?;
970 let endpoints = crate::orcarouter::fetch::Endpoints::default();
971 let outcome = crate::orcarouter::fetch_snapshot(
972 client,
973 &api_key,
974 &cache,
975 &endpoints,
976 DEFAULT_TTL,
977 )
978 .await?;
979 Ok(outcome.into())
980 }
981 }
982}
983
984pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
987
988pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
995
996pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
1002 let mut anthropic_seen: u32 = 0;
1003 tabs.iter()
1004 .map(|tab| {
1005 if tab.vendor_id() == Some(VendorId::Anthropic) {
1006 let delay = step * anthropic_seen;
1007 anthropic_seen += 1;
1008 delay
1009 } else {
1010 Duration::ZERO
1011 }
1012 })
1013 .collect()
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018 use super::*;
1019 use chrono::TimeZone;
1020
1021 #[test]
1025 fn refresh_stagger_spaces_out_anthropic_tabs_only() {
1026 let step = Duration::from_millis(800);
1027 let tabs = vec![
1028 TabId::vendor(VendorId::Anthropic), TabId::account("work"),
1030 TabId::account("personal"),
1031 TabId::vendor(VendorId::Openai),
1032 TabId::vendor(VendorId::Zai),
1033 ];
1034 let delays = refresh_stagger(&tabs, step);
1035 assert_eq!(
1036 delays,
1037 vec![
1038 Duration::ZERO, step, step * 2, Duration::ZERO, Duration::ZERO, ]
1044 );
1045 }
1046
1047 #[test]
1048 fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
1049 let tabs = vec![
1051 TabId::vendor(VendorId::Anthropic),
1052 TabId::vendor(VendorId::Openrouter),
1053 ];
1054 assert!(
1055 refresh_stagger(&tabs, Duration::from_millis(800))
1056 .iter()
1057 .all(|d| d.is_zero())
1058 );
1059 }
1060
1061 #[test]
1062 fn select_primary_moves_to_enabled_vendor() {
1063 let mut app = App::with_theme(
1064 vec![
1065 TabId::vendor(VendorId::Anthropic),
1066 TabId::vendor(VendorId::Openrouter),
1067 ],
1068 Theme::default(),
1069 );
1070 app.select_primary(Some(VendorId::Openrouter));
1071 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
1072 }
1073
1074 #[test]
1075 fn select_primary_ignores_disabled_vendor() {
1076 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1077 app.select_primary(Some(VendorId::Openai));
1078 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
1079 }
1080
1081 #[test]
1082 fn nav_ring_wraps_through_the_overview_at_both_ends() {
1083 let mut app = App::with_theme(
1084 vec![
1085 TabId::vendor(VendorId::Anthropic),
1086 TabId::vendor(VendorId::Openai),
1087 ],
1088 Theme::default(),
1089 );
1090 app.overview = true;
1091
1092 app.next_tab(); assert!(!app.overview);
1094 assert_eq!(app.active, 0);
1095 app.next_tab();
1096 assert_eq!(app.active, 1);
1097 app.next_tab(); assert!(app.overview);
1099
1100 app.prev_tab(); assert!(!app.overview);
1102 assert_eq!(app.active, 1);
1103 app.prev_tab();
1104 assert_eq!(app.active, 0);
1105 app.prev_tab(); assert!(app.overview);
1107 }
1108
1109 #[test]
1110 fn mouse_nav_targets_select_overview_and_tabs() {
1111 let mut app = App::with_theme(
1112 vec![
1113 TabId::vendor(VendorId::Anthropic),
1114 TabId::vendor(VendorId::Openai),
1115 ],
1116 Theme::default(),
1117 );
1118 app.overview = true;
1119 app.nav_from_target(NavTarget::Tab(1));
1120 assert!(!app.overview);
1121 assert_eq!(app.active, 1);
1122 app.nav_from_target(NavTarget::Overview);
1123 assert!(app.overview);
1124 app.nav_from_target(NavTarget::Tab(99));
1126 assert!(app.overview);
1127 }
1128
1129 #[test]
1130 fn hit_targets_default_to_empty() {
1131 let app = App::with_theme(Vec::new(), Theme::default());
1132 assert!(app.hit.borrow().nav_entries.is_empty());
1133 assert!(app.hit.borrow().footer_actions.is_empty());
1134 assert!(app.hit.borrow().settings_rows.is_empty());
1135 }
1136
1137 #[test]
1138 fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
1139 let mut app = App::with_theme(
1140 vec![
1141 TabId::vendor(VendorId::Anthropic),
1142 TabId::vendor(VendorId::Openai),
1143 TabId::vendor(VendorId::Zai),
1144 ],
1145 Theme::default(),
1146 );
1147 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1148
1149 app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
1151 assert_eq!(app.overview_tabs(), vec![2, 0]);
1152
1153 app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
1155 assert_eq!(app.overview_tabs(), vec![1]);
1156 }
1157
1158 fn config_with_accounts(labels: &[&str]) -> Config {
1159 let mut config = Config::default();
1160 config.openai.enabled = false;
1163 config.zai.enabled = false;
1164 config.openrouter.enabled = false;
1165 config.commandcode.enabled = false;
1166 config.anthropic.accounts = labels
1167 .iter()
1168 .map(|l| crate::config::AnthropicAccount {
1169 label: (*l).to_string(),
1170 credentials_path: format!("/creds/{l}.json").into(),
1171 })
1172 .collect();
1173 config
1174 }
1175
1176 #[test]
1177 fn show_default_account_false_hides_the_unnamed_claude_tab() {
1178 let mut config = config_with_accounts(&["work", "personal"]);
1181 config.anthropic.show_default_account = false;
1182 assert_eq!(
1183 tabs_from_config(&config),
1184 vec![TabId::account("work"), TabId::account("personal")]
1185 );
1186
1187 let mut empty = Config::default();
1190 empty.openai.enabled = false;
1191 empty.zai.enabled = false;
1192 empty.openrouter.enabled = false;
1193 empty.commandcode.enabled = false;
1194 empty.anthropic.show_default_account = false;
1195 assert_eq!(
1196 tabs_from_config(&empty),
1197 vec![TabId::vendor(VendorId::Anthropic)]
1198 );
1199 }
1200
1201 #[test]
1202 fn tabs_expand_anthropic_accounts_after_default() {
1203 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
1205 assert_eq!(
1206 tabs,
1207 vec![
1208 TabId::vendor(VendorId::Anthropic),
1209 TabId::account("work"),
1210 TabId::account("personal"),
1211 ]
1212 );
1213 }
1214
1215 #[test]
1216 fn tabs_without_accounts_are_just_enabled_vendors() {
1217 let config = Config::default();
1219 let tabs = tabs_from_config(&config);
1220 let vendors: Vec<VendorId> = tabs.iter().filter_map(TabId::vendor_id).collect();
1221 assert_eq!(vendors, config.enabled_vendors());
1222 assert!(tabs.iter().all(|t| t.account.is_none()));
1223 }
1224
1225 #[test]
1226 fn tabs_expand_openrouter_accounts_without_changing_other_vendors() {
1227 let mut config = Config::default();
1228 config.anthropic.enabled = false;
1229 config.openai.enabled = false;
1230 config.zai.enabled = false;
1231 config.commandcode.enabled = false;
1232 config.openrouter.accounts = vec![
1233 crate::config::ApiKeyAccount {
1234 label: "work".into(),
1235 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1236 api_key: None,
1237 },
1238 crate::config::ApiKeyAccount {
1239 label: "personal".into(),
1240 api_key_env: None,
1241 api_key: Some("personal-key".into()),
1242 },
1243 ];
1244 assert_eq!(
1245 tabs_from_config(&config),
1246 vec![
1247 TabId::vendor(VendorId::Openrouter),
1248 TabId::account_for(VendorId::Openrouter, "work"),
1249 TabId::account_for(VendorId::Openrouter, "personal"),
1250 ]
1251 );
1252 }
1253
1254 #[test]
1255 fn openai_named_accounts_get_their_own_tabs_after_the_default() {
1256 let mut config = Config::default();
1257 config.anthropic.enabled = false;
1258 config.zai.enabled = false;
1259 config.openrouter.enabled = false;
1260 config.commandcode.enabled = false;
1261 config.openai.accounts.push(crate::config::OpenAiAccount {
1262 label: "work".into(),
1263 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1264 });
1265 assert_eq!(
1266 tabs_from_config(&config),
1267 vec![
1268 TabId::vendor(VendorId::Openai),
1269 TabId::account_for(VendorId::Openai, "work"),
1270 ]
1271 );
1272 }
1273
1274 #[test]
1275 fn openrouter_can_hide_default_only_when_named_accounts_exist() {
1276 let mut config = Config::default();
1277 config.anthropic.enabled = false;
1278 config.openai.enabled = false;
1279 config.zai.enabled = false;
1280 config.commandcode.enabled = false;
1281 config.openrouter.show_default_account = false;
1282 assert_eq!(
1283 tabs_from_config(&config),
1284 vec![TabId::vendor(VendorId::Openrouter)]
1285 );
1286
1287 config
1288 .openrouter
1289 .accounts
1290 .push(crate::config::ApiKeyAccount {
1291 label: "work".into(),
1292 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1293 api_key: None,
1294 });
1295 assert_eq!(
1296 tabs_from_config(&config),
1297 vec![TabId::account_for(VendorId::Openrouter, "work")]
1298 );
1299 }
1300
1301 #[test]
1302 fn every_api_key_account_vendor_fans_out_like_openrouter() {
1303 let config_for = |vendor: VendorId, show_default: bool| -> Config {
1304 let section = vendor.config_section();
1305 let mut text: String = ["anthropic", "openai", "commandcode", "zai", "openrouter"]
1308 .into_iter()
1309 .filter(|other| *other != section)
1310 .map(|other| format!("[{other}]\nenabled = false\n"))
1311 .collect();
1312 text.push_str(&format!(
1313 "[{section}]\nenabled = true\nshow_default_account = {show_default}\n\
1314 [[{section}.accounts]]\nlabel = \"work\"\napi_key = \"k1\"\n\
1315 [[{section}.accounts]]\nlabel = \"personal\"\napi_key = \"k2\"\n"
1316 ));
1317 let config: Config = toml::from_str(&text).unwrap();
1318 config.validate().unwrap();
1319 config
1320 };
1321 for vendor in Config::API_KEY_ACCOUNT_VENDORS {
1322 let named = vec![
1323 TabId::account_for(vendor, "work"),
1324 TabId::account_for(vendor, "personal"),
1325 ];
1326 let mut with_default = vec![TabId::vendor(vendor)];
1327 with_default.extend(named.clone());
1328 assert_eq!(
1329 tabs_from_config(&config_for(vendor, true)),
1330 with_default,
1331 "{vendor:?}"
1332 );
1333 assert_eq!(
1334 tabs_from_config(&config_for(vendor, false)),
1335 named,
1336 "{vendor:?}"
1337 );
1338 }
1339 }
1340
1341 #[test]
1342 fn codex_can_hide_default_only_when_named_accounts_exist() {
1343 let mut config = Config::default();
1344 config.anthropic.enabled = false;
1345 config.zai.enabled = false;
1346 config.openrouter.enabled = false;
1347 config.commandcode.enabled = false;
1348 config.openai.show_default_account = false;
1349 assert_eq!(
1350 tabs_from_config(&config),
1351 vec![TabId::vendor(VendorId::Openai)]
1352 );
1353
1354 config.openai.accounts.push(crate::config::OpenAiAccount {
1355 label: "work".into(),
1356 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1357 });
1358 assert_eq!(
1359 tabs_from_config(&config),
1360 vec![TabId::account_for(VendorId::Openai, "work")]
1361 );
1362 }
1363
1364 #[test]
1365 fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
1366 let td = tempfile::tempdir().unwrap();
1369 for label in ["work", "personal"] {
1370 let dir = td.path().join(label);
1371 std::fs::create_dir_all(&dir).unwrap();
1372 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1373 }
1374 let mut config = Config::default();
1375 config.openai.enabled = false;
1376 config.zai.enabled = false;
1377 config.openrouter.enabled = false;
1378 config.commandcode.enabled = false;
1379 config.anthropic.accounts_dir = Some(td.path().to_path_buf());
1380
1381 let tabs = tabs_from_config(&config);
1382 assert_eq!(
1383 tabs,
1384 vec![
1385 TabId::vendor(VendorId::Anthropic),
1386 TabId::account("personal"), TabId::account("work"),
1388 ]
1389 );
1390 }
1391
1392 #[test]
1393 fn desktop_labels_become_account_tabs_after_cli_accounts() {
1394 let config = config_with_accounts(&["work"]);
1396 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1397 assert_eq!(
1398 tabs,
1399 vec![
1400 TabId::vendor(VendorId::Anthropic),
1401 TabId::account("work"),
1402 TabId::desktop_account("gmail"),
1403 TabId::desktop_account("hotmail"),
1404 ]
1405 );
1406 }
1407
1408 #[test]
1409 fn a_desktop_profile_wins_a_label_collision_with_a_cli_account() {
1410 let config = config_with_accounts(&["gmail", "work"]);
1415 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1416 assert_eq!(
1417 tabs,
1418 vec![
1419 TabId::vendor(VendorId::Anthropic),
1420 TabId::account("work"),
1421 TabId::desktop_account("gmail"),
1422 TabId::desktop_account("hotmail"),
1423 ]
1424 );
1425 }
1426
1427 #[test]
1428 fn desktop_accounts_suppress_the_default_tab_like_named_ones() {
1429 let mut config = config_with_accounts(&[]);
1432 config.cursor.enabled = false;
1433 config.anthropic.show_default_account = false;
1434
1435 assert_eq!(
1438 build_tabs(&config, &[]),
1439 vec![TabId::vendor(VendorId::Anthropic)]
1440 );
1441 assert_eq!(
1443 build_tabs(&config, &["gmail".into()]),
1444 vec![TabId::desktop_account("gmail")]
1445 );
1446 }
1447
1448 #[test]
1449 fn set_tabs_resets_states_and_clamps_selection() {
1450 let mut app = App::with_theme(
1454 tabs_from_config(&config_with_accounts(&["work", "personal"])),
1455 Theme::default(),
1456 );
1457 app.active = 2; app.tabs[0] = TabState::error("old");
1459 let old_tab = app.tabs_meta[0].clone();
1460 assert!(app.begin_refresh(&old_tab));
1461
1462 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
1463 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
1464 assert_eq!(app.active, 0, "selection clamped after shrink");
1465 assert!(matches!(app.tabs[0], TabState::Loading));
1466 assert!(!app.is_refreshing(&old_tab));
1467 }
1468
1469 #[test]
1470 fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
1471 let mut app = App::with_theme(
1472 vec![
1473 TabId::vendor(VendorId::Anthropic),
1474 TabId::vendor(VendorId::Openai),
1475 ],
1476 Theme::default(),
1477 );
1478 app.active = 1;
1479
1480 app.set_tabs(vec![
1481 TabId::vendor(VendorId::Anthropic),
1482 TabId::account("work"),
1483 TabId::vendor(VendorId::Openai),
1484 ]);
1485
1486 assert_eq!(app.active, 2);
1487 assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
1488 }
1489
1490 #[test]
1491 fn refresh_from_old_generation_is_discarded() {
1492 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1493 let old_generation = app.tab_generation;
1494 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
1495
1496 assert!(!app.apply_refresh(
1497 old_generation,
1498 &TabId::vendor(VendorId::Anthropic),
1499 TabState::error("old result"),
1500 ));
1501 assert!(matches!(app.tabs[0], TabState::Loading));
1502 }
1503
1504 #[test]
1505 fn refresh_identity_mismatch_is_discarded() {
1506 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1507 let generation = app.tab_generation;
1508
1509 assert!(!app.apply_refresh(
1510 generation,
1511 &TabId::vendor(VendorId::Openai),
1512 TabState::error("wrong tab"),
1513 ));
1514 assert!(matches!(app.tabs[0], TabState::Loading));
1515 }
1516
1517 #[test]
1518 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
1519 let anthropic = TabId::vendor(VendorId::Anthropic);
1520 let openai = TabId::vendor(VendorId::Openai);
1521 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
1522 let generation = app.tab_generation;
1523 assert!(app.begin_refresh(&anthropic));
1524
1525 app.tabs_meta.swap(0, 1);
1528 app.tabs.swap(0, 1);
1529 assert!(app.apply_refresh(generation, &anthropic, TabState::error("ready")));
1530 assert!(matches!(app.tabs[0], TabState::Loading));
1531 assert!(matches!(&app.tabs[1], TabState::Error { message, .. } if message == "ready"));
1532 assert!(!app.is_refreshing(&anthropic));
1533 }
1534
1535 fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
1536 TabState::Ready(Box::new(ReadyTab {
1537 snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
1538 label: "test".into(),
1539 total_credits: 0.0,
1540 total_usage: 0.0,
1541 usage_daily: 0.0,
1542 usage_weekly: 0.0,
1543 usage_monthly: 0.0,
1544 is_free_tier: false,
1545 limit: None,
1546 limit_remaining: None,
1547 }),
1548 stale: false,
1549 last_error: None,
1550 fetched_at: Some(fetched_at),
1551 display: Default::default(),
1552 }))
1553 }
1554
1555 #[test]
1556 fn refresh_keeps_ready_snapshot_visible_and_suppresses_duplicates() {
1557 let tab = TabId::vendor(VendorId::Openrouter);
1558 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1559 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1560 app.tabs[0] = ready_at(fetched_at);
1561
1562 assert!(app.begin_refresh(&tab));
1563 assert!(
1564 !app.begin_refresh(&tab),
1565 "duplicate request must be suppressed"
1566 );
1567 assert!(app.is_refreshing(&tab));
1568 match &app.tabs[0] {
1569 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1570 other => panic!("ready snapshot disappeared during refresh: {other:?}"),
1571 }
1572 }
1573
1574 #[test]
1575 fn first_refresh_still_uses_loading_until_data_arrives() {
1576 let tab = TabId::vendor(VendorId::Openrouter);
1577 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1578
1579 assert!(app.begin_refresh(&tab));
1580 assert!(app.is_refreshing(&tab));
1581 assert!(matches!(app.tabs[0], TabState::Loading));
1582
1583 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("not signed in"),));
1584 assert!(!app.is_refreshing(&tab));
1585 assert!(
1586 matches!(&app.tabs[0], TabState::Error { message, .. } if message == "not signed in")
1587 );
1588 }
1589
1590 #[test]
1591 fn successful_revalidation_replaces_snapshot_and_clears_indicator() {
1592 let tab = TabId::vendor(VendorId::Openrouter);
1593 let old_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1594 let new_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 1, 0).unwrap();
1595 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1596 app.tabs[0] = ready_at(old_at);
1597
1598 assert!(app.begin_refresh(&tab));
1599 assert!(app.apply_refresh(app.tab_generation, &tab, ready_at(new_at)));
1600 assert!(!app.is_refreshing(&tab));
1601 match &app.tabs[0] {
1602 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(new_at)),
1603 other => panic!("expected replacement snapshot, got {other:?}"),
1604 }
1605 }
1606
1607 #[test]
1608 fn failed_revalidation_preserves_snapshot_with_visible_warning() {
1609 let tab = TabId::vendor(VendorId::Openrouter);
1610 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1611 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1612 app.tabs[0] = ready_at(fetched_at);
1613
1614 assert!(app.begin_refresh(&tab));
1615 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("refresh failed"),));
1616 assert!(!app.is_refreshing(&tab));
1617 match &app.tabs[0] {
1618 TabState::Ready(ready) => {
1619 assert_eq!(ready.fetched_at, Some(fetched_at));
1620 assert!(ready.stale);
1621 assert_eq!(ready.last_error, Some((0, "refresh failed".into())));
1622 }
1623 other => panic!("last successful snapshot was lost: {other:?}"),
1624 }
1625 let sections = crate::tui::panels::sections_for(&app.tabs[0], Utc::now(), 5);
1626 assert!(sections.iter().any(|section| matches!(
1627 section,
1628 crate::tui::panels::Section::Text { label, value }
1629 if label == "Warning" && value == "refresh failed"
1630 )));
1631 }
1632
1633 #[test]
1634 fn old_generation_result_does_not_clear_current_refresh() {
1635 let tab = TabId::vendor(VendorId::Openrouter);
1636 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1637 let old_generation = app.tab_generation;
1638 app.set_tabs(vec![tab.clone()]);
1639 assert!(app.begin_refresh(&tab));
1640
1641 assert!(!app.apply_refresh(old_generation, &tab, TabState::error("old result"),));
1642 assert!(app.is_refreshing(&tab));
1643 assert!(matches!(app.tabs[0], TabState::Loading));
1644 }
1645
1646 #[test]
1647 fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
1648 let anthropic = TabId::vendor(VendorId::Anthropic);
1654 let openai = TabId::vendor(VendorId::Openai);
1655 let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
1656 let generation = app.tab_generation;
1657 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1658
1659 assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
1660 match &app.tabs[0] {
1661 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1662 other => panic!("expected Anthropic tab Ready, got {other:?}"),
1663 }
1664 assert!(matches!(app.tabs[1], TabState::Loading));
1665 }
1666
1667 #[test]
1668 fn select_primary_lands_on_default_account_tab() {
1669 let app = {
1672 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
1673 let mut a = App::with_theme(tabs, Theme::default());
1674 a.select_primary(Some(VendorId::Anthropic));
1675 a
1676 };
1677 assert_eq!(app.active, 0);
1678 assert_eq!(
1679 app.active_tab_id(),
1680 Some(&TabId::vendor(VendorId::Anthropic))
1681 );
1682 }
1683
1684 fn custom_spec(id: &str, enabled: bool) -> CustomProviderConfig {
1685 CustomProviderConfig {
1686 id: id.into(),
1687 name: "My Tool".into(),
1688 short_name: "myt".into(),
1689 enabled,
1690 ..Default::default()
1691 }
1692 }
1693
1694 #[test]
1695 fn custom_providers_get_tabs_after_every_builtin() {
1696 let config = Config {
1697 custom: vec![custom_spec("mytool", true), custom_spec("other", true)],
1698 ..Default::default()
1699 };
1700 let tabs = tabs_from_config(&config);
1701 let builtin_count = config.enabled_vendors().len();
1702 assert_eq!(tabs.len(), builtin_count + 2);
1703 assert!(
1704 tabs[..builtin_count]
1705 .iter()
1706 .all(|t| matches!(t.source, TabSource::Builtin(_)))
1707 );
1708 assert_eq!(tabs[builtin_count], TabId::custom(&config.custom[0]));
1709 assert_eq!(tabs[builtin_count + 1], TabId::custom(&config.custom[1]));
1710 assert_eq!(
1711 tabs[builtin_count].source,
1712 TabSource::Custom {
1713 id: "mytool".into(),
1714 name: "My Tool".into(),
1715 short_name: "myt".into(),
1716 }
1717 );
1718 assert!(tabs[builtin_count].account.is_none());
1719 assert!(!tabs[builtin_count].desktop);
1720 assert_eq!(tabs[builtin_count].vendor_id(), None);
1721 }
1722
1723 #[test]
1724 fn disabled_custom_provider_has_no_tab() {
1725 let config = Config {
1726 custom: vec![custom_spec("mytool", false)],
1727 ..Default::default()
1728 };
1729 let tabs = tabs_from_config(&config);
1730 assert!(
1731 tabs.iter()
1732 .all(|t| matches!(t.source, TabSource::Builtin(_)))
1733 );
1734 assert_eq!(tabs.len(), config.enabled_vendors().len());
1735 }
1736
1737 #[test]
1738 fn custom_tabs_are_listed_by_the_default_overview_but_not_by_a_vendor_filter() {
1739 let spec = custom_spec("mytool", true);
1740 let mut app = App::with_theme(
1741 vec![
1742 TabId::vendor(VendorId::Anthropic),
1743 TabId::custom(&spec),
1744 TabId::vendor(VendorId::Openai),
1745 ],
1746 Theme::default(),
1747 );
1748 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1749
1750 app.overview_vendors = Some(vec![VendorId::Openai, VendorId::Anthropic]);
1752 assert_eq!(app.overview_tabs(), vec![2, 0]);
1753 }
1754
1755 #[test]
1756 fn a_custom_active_tab_has_no_vendor_and_never_staggers() {
1757 let spec = custom_spec("mytool", true);
1758 let tabs = vec![
1759 TabId::vendor(VendorId::Anthropic),
1760 TabId::custom(&spec),
1761 TabId::account("work"),
1762 ];
1763 let mut app = App::with_theme(tabs.clone(), Theme::default());
1764 app.active = 1;
1765 assert_eq!(app.active_vendor(), None);
1766 assert_eq!(app.active_tab_id(), Some(&TabId::custom(&spec)));
1767
1768 app.select_primary(Some(VendorId::Anthropic));
1770 assert_eq!(app.active, 0);
1771
1772 let step = Duration::from_millis(800);
1773 assert_eq!(
1774 refresh_stagger(&tabs, step),
1775 vec![Duration::ZERO, Duration::ZERO, step]
1776 );
1777 }
1778}