1use std::collections::HashSet;
4use std::time::Duration;
5
6use chrono::Utc;
7use reqwest::Client;
8
9use crate::cache::DEFAULT_TTL;
10use crate::config::Config;
11use crate::error::Result;
12use crate::theme::Theme;
13use crate::vendor::{VendorId, VendorOutcome};
14
15#[derive(Debug, Clone)]
21pub enum TabState {
22 Loading,
23 Ready(Box<ReadyTab>),
24 Error(String),
25}
26
27#[derive(Debug, Clone)]
28pub struct ReadyTab {
29 pub snapshot: crate::usage::VendorSnapshot,
30 pub stale: bool,
31 pub last_error: Option<(u16, String)>,
32 pub fetched_at: Option<chrono::DateTime<chrono::Utc>>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45pub struct TabId {
46 pub vendor: VendorId,
47 pub account: Option<String>,
48 pub desktop: bool,
49}
50
51impl TabId {
52 pub fn vendor(vendor: VendorId) -> Self {
54 Self {
55 vendor,
56 account: None,
57 desktop: false,
58 }
59 }
60
61 pub fn account(label: impl Into<String>) -> Self {
63 Self::account_for(VendorId::Anthropic, label)
64 }
65
66 pub fn account_for(vendor: VendorId, label: impl Into<String>) -> Self {
68 Self {
69 vendor,
70 account: Some(label.into()),
71 desktop: false,
72 }
73 }
74
75 pub fn desktop_account(label: impl Into<String>) -> Self {
78 Self {
79 vendor: VendorId::Anthropic,
80 account: Some(label.into()),
81 desktop: true,
82 }
83 }
84}
85
86pub fn tabs_from_config(config: &Config) -> Vec<TabId> {
95 build_tabs(config, &[])
96}
97
98pub fn tabs_with_desktop(config: &Config) -> Vec<TabId> {
102 build_tabs(config, &desktop_profile_labels(config))
103}
104
105fn build_tabs(config: &Config, desktop_labels: &[String]) -> Vec<TabId> {
118 let desktop_set: HashSet<&str> = desktop_labels.iter().map(String::as_str).collect();
119 let mut tabs = Vec::new();
120 for vendor in config.enabled_vendors() {
121 if vendor == VendorId::Anthropic {
122 let accounts: Vec<_> = config
123 .anthropic
124 .all_accounts()
125 .into_iter()
126 .filter(|a| !desktop_set.contains(a.label.as_str()))
127 .collect();
128 if config.anthropic.show_default_account
132 || (accounts.is_empty() && desktop_labels.is_empty())
133 {
134 tabs.push(TabId::vendor(vendor));
135 }
136 for acct in accounts {
137 tabs.push(TabId::account(acct.label));
138 }
139 for label in desktop_labels {
140 tabs.push(TabId::desktop_account(label.clone()));
141 }
142 } else if vendor == VendorId::Openrouter {
143 if config.openrouter.show_default_account || config.openrouter.accounts.is_empty() {
144 tabs.push(TabId::vendor(vendor));
145 }
146 for account in &config.openrouter.accounts {
147 tabs.push(TabId::account_for(vendor, account.label.clone()));
148 }
149 } else {
150 tabs.push(TabId::vendor(vendor));
151 }
152 }
153 tabs
154}
155
156#[cfg(target_os = "macos")]
160fn desktop_profile_labels(config: &Config) -> Vec<String> {
161 let Ok(paths) = crate::claude_desktop::Paths::resolve(&config.anthropic) else {
162 return Vec::new();
163 };
164 if !paths.available() {
165 return Vec::new();
166 }
167 crate::claude_desktop::load_profiles(&paths.profiles_dir)
168 .into_iter()
169 .filter(|p| p.has_credentials)
170 .map(|p| p.label)
171 .collect()
172}
173
174#[cfg(not(target_os = "macos"))]
175fn desktop_profile_labels(_config: &Config) -> Vec<String> {
176 Vec::new()
177}
178
179#[derive(Debug)]
180pub struct App {
181 pub tabs_meta: Vec<TabId>,
182 pub active: usize,
183 pub tabs: Vec<TabState>,
184 refreshing_tabs: HashSet<TabId>,
187 pub tab_generation: u64,
191 pub overview: bool,
194 pub overview_vendors: Option<Vec<VendorId>>,
196 pub theme: Theme,
197 pub quit: bool,
198 pub settings: Option<crate::tui::settings::SettingsState>,
200 pub context_enabled: bool,
203 pub context_generation: u64,
206 pub context: Option<crate::tui::context::ContextState>,
208 pub vendor_box: crate::config::VendorBoxStyle,
210}
211
212impl App {
213 pub fn new(tabs_meta: Vec<TabId>) -> Self {
214 Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
217 }
218
219 pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
225 let n = tabs_meta.len();
226 Self {
227 tabs_meta,
228 active: 0,
229 tabs: vec![TabState::Loading; n],
230 refreshing_tabs: HashSet::new(),
231 tab_generation: 0,
232 overview: false,
233 overview_vendors: None,
234 theme,
235 quit: false,
236 settings: None,
237 context_enabled: false,
238 context_generation: 0,
239 context: None,
240 vendor_box: crate::config::VendorBoxStyle::Sidebar,
241 }
242 }
243
244 pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
248 let mut app = Self::new(tabs_meta);
249 if primary.is_some() {
252 app.select_primary(primary);
253 } else {
254 app.overview = true;
255 }
256 app
257 }
258
259 pub fn active_tab_id(&self) -> Option<&TabId> {
260 self.tabs_meta.get(self.active)
261 }
262
263 pub fn active_vendor(&self) -> Option<VendorId> {
264 self.tabs_meta.get(self.active).map(|t| t.vendor)
265 }
266
267 pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
274 let selected = self.active_tab_id().cloned();
275 let fallback = self.active.min(tabs_meta.len().saturating_sub(1));
276 self.tab_generation = self.tab_generation.wrapping_add(1);
277 self.active = selected
278 .as_ref()
279 .and_then(|tab| tabs_meta.iter().position(|candidate| candidate == tab))
280 .unwrap_or(fallback);
281 self.tabs = vec![TabState::Loading; tabs_meta.len()];
282 self.tabs_meta = tabs_meta;
283 self.refreshing_tabs.clear();
284 }
285
286 pub fn begin_refresh(&mut self, tab: &TabId) -> bool {
290 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
291 return false;
292 };
293 if !self.refreshing_tabs.insert(tab.clone()) {
294 return false;
295 }
296 if !matches!(self.tabs[index], TabState::Ready(_)) {
297 self.tabs[index] = TabState::Loading;
298 }
299 true
300 }
301
302 pub fn is_refreshing(&self, tab: &TabId) -> bool {
303 self.refreshing_tabs.contains(tab)
304 }
305
306 pub fn tab_is_refreshing(&self, index: usize) -> bool {
307 self.tabs_meta
308 .get(index)
309 .is_some_and(|tab| self.is_refreshing(tab))
310 }
311
312 pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
317 if generation != self.tab_generation {
318 return false;
319 }
320 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
321 return false;
322 };
323 let was_refreshing = self.refreshing_tabs.remove(tab);
324 if was_refreshing
328 && let TabState::Ready(ready) = &mut self.tabs[index]
329 && let TabState::Error(message) = state
330 {
331 ready.stale = true;
332 ready.last_error = Some((0, message));
333 } else {
334 self.tabs[index] = state;
335 }
336 true
337 }
338
339 pub fn select_primary(&mut self, primary: Option<VendorId>) {
342 if let Some(p) = primary
343 && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor == p)
344 {
345 self.active = idx;
346 self.overview = false;
347 }
348 }
349
350 pub fn next_tab(&mut self) {
353 if self.overview {
354 if !self.tabs_meta.is_empty() {
355 self.overview = false;
356 self.active = 0;
357 }
358 } else if self.active + 1 < self.tabs_meta.len() {
359 self.active += 1;
360 } else {
361 self.overview = true;
362 }
363 }
364
365 pub fn prev_tab(&mut self) {
366 if self.overview {
367 if !self.tabs_meta.is_empty() {
368 self.overview = false;
369 self.active = self.tabs_meta.len() - 1;
370 }
371 } else if self.active > 0 {
372 self.active -= 1;
373 } else {
374 self.overview = true;
375 }
376 }
377
378 pub fn overview_tabs(&self) -> Vec<usize> {
381 match &self.overview_vendors {
382 None => (0..self.tabs_meta.len()).collect(),
383 Some(wanted) => wanted
384 .iter()
385 .flat_map(|v| {
386 self.tabs_meta
387 .iter()
388 .enumerate()
389 .filter(move |(_, t)| t.vendor == *v)
390 .map(|(i, _)| i)
391 })
392 .collect(),
393 }
394 }
395}
396
397pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
399 match build_outcome(client, config, tab).await {
400 Ok(outcome) => {
401 let now = Utc::now();
406 let fetched_at = outcome
407 .cache_age
408 .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
409 TabState::Ready(Box::new(ReadyTab {
410 snapshot: outcome.snapshot,
411 stale: outcome.stale,
412 last_error: outcome.last_error.map(|(code, message)| {
413 (code, crate::display::sanitize_untrusted_field(&message))
414 }),
415 fetched_at,
416 }))
417 }
418 Err(e) => TabState::Error(crate::display::sanitize_untrusted_field(&e.user_message())),
419 }
420}
421
422async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
423 match tab.vendor {
424 VendorId::Anthropic => {
425 let (creds_target, cache) = match tab.account.as_deref() {
431 Some(label) if tab.desktop => {
432 crate::anthropic::desktop_creds::account_target(config, label)?
433 }
434 Some(label) => config.anthropic.account_target(label)?,
435 None => {
436 let target = match config.anthropic.credentials_path.clone() {
437 Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
438 None => crate::anthropic::creds::CredsTarget::Default(
439 crate::anthropic::creds::default_path().unwrap_or_default(),
440 ),
441 };
442 (target, crate::cache::Cache::for_vendor("anthropic")?)
443 }
444 };
445 let endpoints = crate::anthropic::fetch::Endpoints::default();
446 let outcome = crate::anthropic::fetch_snapshot(
447 client,
448 &creds_target,
449 &cache,
450 &endpoints,
451 DEFAULT_TTL,
452 )
453 .await?;
454 Ok(crate::vendor::VendorOutcome {
455 snapshot: crate::usage::VendorSnapshot::Anthropic(outcome.snapshot),
456 stale: outcome.stale,
457 last_error: outcome.last_error,
458 cache_age: outcome.cache_age,
459 })
460 }
461 VendorId::AnthropicApi => {
462 let key = crate::config::resolve_api_key(
463 "Anthropic_API",
464 &config.anthropic_api.api_key_env,
465 config.anthropic_api.api_key.as_deref(),
466 )?;
467 let cache = crate::cache::Cache::for_vendor("anthropic_api")?;
468 let endpoints = crate::anthropic_api::fetch::Endpoints::default();
469 let outcome = crate::anthropic_api::fetch_snapshot(
470 client,
471 &key,
472 &cache,
473 &endpoints,
474 DEFAULT_TTL,
475 config.anthropic_api.monthly_limit,
476 )
477 .await?;
478 Ok(outcome.into())
479 }
480 VendorId::Openrouter => {
481 let api_key = config.openrouter.resolve_api_key(tab.account.as_deref())?;
482 let cache = match tab.account.as_deref() {
483 Some(label) => crate::cache::Cache::for_vendor_account("openrouter", label)?,
484 None => crate::cache::Cache::for_vendor("openrouter")?,
485 };
486 let endpoints = crate::openrouter::fetch::Endpoints::default();
487 let outcome = crate::openrouter::fetch_snapshot(
488 client,
489 &api_key,
490 &cache,
491 &endpoints,
492 DEFAULT_TTL,
493 )
494 .await?;
495 Ok(outcome.into())
496 }
497 VendorId::Zai => {
498 let api_key = crate::config::resolve_api_key(
499 "Zai",
500 &config.zai.api_key_env,
501 config.zai.api_key.as_deref(),
502 )?;
503 let cache = crate::cache::Cache::for_vendor("zai")?;
504 let endpoints = crate::zai::fetch::Endpoints::default();
505 let outcome = crate::zai::fetch_snapshot(
506 client,
507 &api_key,
508 &cache,
509 &endpoints,
510 DEFAULT_TTL,
511 config.zai.plan_tier.as_deref(),
512 )
513 .await?;
514 Ok(outcome.into())
515 }
516 VendorId::Openai => {
517 let cache = crate::cache::Cache::for_vendor("openai")?;
518 let creds_path = config
519 .openai
520 .codex_auth_path
521 .clone()
522 .unwrap_or_else(|| crate::openai::creds::default_path().unwrap_or_default());
523 let endpoints = crate::openai::fetch::Endpoints::default();
524 let outcome =
525 crate::openai::fetch_snapshot(client, &creds_path, &cache, &endpoints, DEFAULT_TTL)
526 .await?;
527 Ok(outcome.into())
528 }
529 VendorId::Deepseek => {
530 let api_key = crate::config::resolve_api_key(
531 "DeepSeek",
532 &config.deepseek.api_key_env,
533 config.deepseek.api_key.as_deref(),
534 )?;
535 let cache = crate::cache::Cache::for_vendor("deepseek")?;
536 let endpoints = crate::deepseek::fetch::Endpoints::default();
537 let outcome =
538 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
539 .await?;
540 Ok(outcome.into())
541 }
542 VendorId::Kimi => {
543 let api_key = crate::config::resolve_api_key(
544 "Kimi",
545 &config.kimi.api_key_env,
546 config.kimi.api_key.as_deref(),
547 )?;
548 let cache = crate::cache::Cache::for_vendor("kimi")?;
549 let endpoints = crate::kimi::fetch::Endpoints::default();
550 let outcome =
551 crate::kimi::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
552 .await?;
553 Ok(outcome.into())
554 }
555 VendorId::Kilo => {
556 let api_key = crate::config::resolve_api_key(
557 "Kilo",
558 &config.kilo.api_key_env,
559 config.kilo.api_key.as_deref(),
560 )?;
561 let cache = crate::cache::Cache::for_vendor("kilo")?;
562 let endpoints = crate::kilo::fetch::Endpoints::default();
563 let outcome = crate::kilo::fetch_snapshot(
564 client,
565 &api_key,
566 &cache,
567 &endpoints,
568 DEFAULT_TTL,
569 config.kilo.organization_id.as_deref(),
570 )
571 .await?;
572 Ok(outcome.into())
573 }
574 VendorId::Novita => {
575 let api_key = crate::config::resolve_api_key(
576 "Novita",
577 &config.novita.api_key_env,
578 config.novita.api_key.as_deref(),
579 )?;
580 let cache = crate::cache::Cache::for_vendor("novita")?;
581 let endpoints = crate::novita::fetch::Endpoints::default();
582 let outcome =
583 crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
584 .await?;
585 Ok(outcome.into())
586 }
587 VendorId::Moonshot => {
588 let api_key = crate::config::resolve_api_key(
589 "Moonshot",
590 &config.moonshot.api_key_env,
591 config.moonshot.api_key.as_deref(),
592 )?;
593 let cache = crate::cache::Cache::for_vendor("moonshot")?;
594 let (endpoints, currency) =
595 crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
596 let outcome = crate::moonshot::fetch_snapshot(
597 client,
598 &api_key,
599 &cache,
600 &endpoints,
601 DEFAULT_TTL,
602 currency,
603 )
604 .await?;
605 Ok(outcome.into())
606 }
607 VendorId::Grok => {
608 let key = crate::config::resolve_api_key(
609 "Grok",
610 &config.grok.api_key_env,
611 config.grok.api_key.as_deref(),
612 )?;
613 let cache = crate::cache::Cache::for_vendor("grok")?;
614 let endpoints = crate::grok::fetch::Endpoints::default();
615 let outcome = crate::grok::fetch_snapshot(
616 client,
617 &key,
618 &cache,
619 &endpoints,
620 DEFAULT_TTL,
621 config.grok.team_id.as_deref(),
622 )
623 .await?;
624 Ok(outcome.into())
625 }
626 VendorId::Supergrok => {
627 let cache = crate::cache::Cache::for_vendor("supergrok")?;
628 let scope_paths = crate::supergrok::scope::ScopePaths::with_overrides(
629 config.supergrok.auth_path.as_deref(),
630 config.supergrok.config_path.as_deref(),
631 )?;
632 let outcome = crate::supergrok::fetch_snapshot(
633 &config.supergrok.grok_binary,
634 &scope_paths,
635 &cache,
636 DEFAULT_TTL,
637 )
638 .await?;
639 Ok(outcome.into())
640 }
641 VendorId::Antigravity => {
642 let cache = crate::cache::Cache::for_vendor("antigravity")?;
644 let outcome = crate::antigravity::fetch_snapshot(client, &cache, DEFAULT_TTL).await?;
645 Ok(outcome.into())
646 }
647 VendorId::Minimax => {
648 let api_key = crate::config::resolve_api_key(
649 "MiniMax",
650 &config.minimax.api_key_env,
651 config.minimax.api_key.as_deref(),
652 )?;
653 let cache = crate::cache::Cache::for_vendor("minimax")?;
654 let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
655 let outcome =
656 crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
657 .await?;
658 Ok(outcome.into())
659 }
660 VendorId::Cursor => {
661 let cache = crate::cache::Cache::for_vendor("cursor")?;
662 let db_path = config
663 .cursor
664 .db_path
665 .clone()
666 .map(Ok)
667 .unwrap_or_else(crate::cursor::db::default_db_path)?;
668 let agent_auth_path = config
669 .cursor
670 .agent_auth_path
671 .clone()
672 .map(Ok)
673 .unwrap_or_else(crate::cursor::db::default_agent_auth_path)?;
674 let endpoints = crate::cursor::fetch::Endpoints::default();
675 let outcome = crate::cursor::fetch_snapshot(
676 client,
677 &db_path,
678 &agent_auth_path,
679 &cache,
680 &endpoints,
681 DEFAULT_TTL,
682 )
683 .await?;
684 Ok(outcome.into())
685 }
686 VendorId::Kiro => {
687 let cache = crate::cache::Cache::for_vendor("kiro")?;
688 let db_path = config
689 .kiro
690 .db_path
691 .clone()
692 .map(Ok)
693 .unwrap_or_else(crate::kiro::db::default_db_path)?;
694 let outcome =
695 crate::kiro::fetch_snapshot(client, &db_path, &cache, DEFAULT_TTL).await?;
696 Ok(outcome.into())
697 }
698 VendorId::NousResearch => {
699 let store = crate::nous::credentials::CredentialStore::default();
700 let endpoints = crate::nous::fetch::Endpoints::default();
701 let account = crate::nous::fetch::fetch_account_with_refresh(
702 client,
703 &store,
704 &endpoints,
705 Utc::now(),
706 )
707 .await?;
708 Ok(crate::vendor::VendorOutcome {
709 snapshot: crate::usage::VendorSnapshot::NousResearch(account),
710 stale: false,
711 last_error: None,
712 cache_age: Some(Duration::ZERO),
713 })
714 }
715 VendorId::OpenCodeGo => {
716 let api_key = crate::config::resolve_api_key(
717 "OpenCode Go",
718 &config.opencode_go.api_key_env,
719 config.opencode_go.api_key.as_deref(),
720 )?;
721 let cache = crate::cache::Cache::for_vendor("opencode-go")?;
722 let endpoints = crate::opencode_go::fetch::Endpoints::default();
723 let outcome = crate::opencode_go::fetch::fetch_snapshot(
724 client,
725 &api_key,
726 &cache,
727 &endpoints,
728 DEFAULT_TTL,
729 )
730 .await?;
731 Ok(outcome.into())
732 }
733 }
734}
735
736pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
739
740pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
747
748pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
754 let mut anthropic_seen: u32 = 0;
755 tabs.iter()
756 .map(|tab| {
757 if tab.vendor == VendorId::Anthropic {
758 let delay = step * anthropic_seen;
759 anthropic_seen += 1;
760 delay
761 } else {
762 Duration::ZERO
763 }
764 })
765 .collect()
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771 use chrono::TimeZone;
772
773 #[test]
777 fn refresh_stagger_spaces_out_anthropic_tabs_only() {
778 let step = Duration::from_millis(800);
779 let tabs = vec![
780 TabId::vendor(VendorId::Anthropic), TabId::account("work"),
782 TabId::account("personal"),
783 TabId::vendor(VendorId::Openai),
784 TabId::vendor(VendorId::Zai),
785 ];
786 let delays = refresh_stagger(&tabs, step);
787 assert_eq!(
788 delays,
789 vec![
790 Duration::ZERO, step, step * 2, Duration::ZERO, Duration::ZERO, ]
796 );
797 }
798
799 #[test]
800 fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
801 let tabs = vec![
803 TabId::vendor(VendorId::Anthropic),
804 TabId::vendor(VendorId::Openrouter),
805 ];
806 assert!(
807 refresh_stagger(&tabs, Duration::from_millis(800))
808 .iter()
809 .all(|d| d.is_zero())
810 );
811 }
812
813 #[test]
814 fn select_primary_moves_to_enabled_vendor() {
815 let mut app = App::with_theme(
816 vec![
817 TabId::vendor(VendorId::Anthropic),
818 TabId::vendor(VendorId::Openrouter),
819 ],
820 Theme::default(),
821 );
822 app.select_primary(Some(VendorId::Openrouter));
823 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
824 }
825
826 #[test]
827 fn select_primary_ignores_disabled_vendor() {
828 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
829 app.select_primary(Some(VendorId::Openai));
830 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
831 }
832
833 #[test]
834 fn nav_ring_wraps_through_the_overview_at_both_ends() {
835 let mut app = App::with_theme(
836 vec![
837 TabId::vendor(VendorId::Anthropic),
838 TabId::vendor(VendorId::Openai),
839 ],
840 Theme::default(),
841 );
842 app.overview = true;
843
844 app.next_tab(); assert!(!app.overview);
846 assert_eq!(app.active, 0);
847 app.next_tab();
848 assert_eq!(app.active, 1);
849 app.next_tab(); assert!(app.overview);
851
852 app.prev_tab(); assert!(!app.overview);
854 assert_eq!(app.active, 1);
855 app.prev_tab();
856 assert_eq!(app.active, 0);
857 app.prev_tab(); assert!(app.overview);
859 }
860
861 #[test]
862 fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
863 let mut app = App::with_theme(
864 vec![
865 TabId::vendor(VendorId::Anthropic),
866 TabId::vendor(VendorId::Openai),
867 TabId::vendor(VendorId::Zai),
868 ],
869 Theme::default(),
870 );
871 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
872
873 app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
875 assert_eq!(app.overview_tabs(), vec![2, 0]);
876
877 app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
879 assert_eq!(app.overview_tabs(), vec![1]);
880 }
881
882 fn config_with_accounts(labels: &[&str]) -> Config {
883 let mut config = Config::default();
884 config.openai.enabled = false;
887 config.zai.enabled = false;
888 config.openrouter.enabled = false;
889 config.anthropic.accounts = labels
890 .iter()
891 .map(|l| crate::config::AnthropicAccount {
892 label: (*l).to_string(),
893 credentials_path: format!("/creds/{l}.json").into(),
894 })
895 .collect();
896 config
897 }
898
899 #[test]
900 fn show_default_account_false_hides_the_unnamed_claude_tab() {
901 let mut config = config_with_accounts(&["work", "personal"]);
904 config.anthropic.show_default_account = false;
905 assert_eq!(
906 tabs_from_config(&config),
907 vec![TabId::account("work"), TabId::account("personal")]
908 );
909
910 let mut empty = Config::default();
913 empty.openai.enabled = false;
914 empty.zai.enabled = false;
915 empty.openrouter.enabled = false;
916 empty.anthropic.show_default_account = false;
917 assert_eq!(
918 tabs_from_config(&empty),
919 vec![TabId::vendor(VendorId::Anthropic)]
920 );
921 }
922
923 #[test]
924 fn tabs_expand_anthropic_accounts_after_default() {
925 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
927 assert_eq!(
928 tabs,
929 vec![
930 TabId::vendor(VendorId::Anthropic),
931 TabId::account("work"),
932 TabId::account("personal"),
933 ]
934 );
935 }
936
937 #[test]
938 fn tabs_without_accounts_are_just_enabled_vendors() {
939 let config = Config::default();
941 let tabs = tabs_from_config(&config);
942 let vendors: Vec<VendorId> = tabs.iter().map(|t| t.vendor).collect();
943 assert_eq!(vendors, config.enabled_vendors());
944 assert!(tabs.iter().all(|t| t.account.is_none()));
945 }
946
947 #[test]
948 fn tabs_expand_openrouter_accounts_without_changing_other_vendors() {
949 let mut config = Config::default();
950 config.anthropic.enabled = false;
951 config.openai.enabled = false;
952 config.zai.enabled = false;
953 config.openrouter.accounts = vec![
954 crate::config::OpenRouterAccount {
955 label: "work".into(),
956 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
957 api_key: None,
958 },
959 crate::config::OpenRouterAccount {
960 label: "personal".into(),
961 api_key_env: None,
962 api_key: Some("personal-key".into()),
963 },
964 ];
965 assert_eq!(
966 tabs_from_config(&config),
967 vec![
968 TabId::vendor(VendorId::Openrouter),
969 TabId::account_for(VendorId::Openrouter, "work"),
970 TabId::account_for(VendorId::Openrouter, "personal"),
971 ]
972 );
973 }
974
975 #[test]
976 fn openrouter_can_hide_default_only_when_named_accounts_exist() {
977 let mut config = Config::default();
978 config.anthropic.enabled = false;
979 config.openai.enabled = false;
980 config.zai.enabled = false;
981 config.openrouter.show_default_account = false;
982 assert_eq!(
983 tabs_from_config(&config),
984 vec![TabId::vendor(VendorId::Openrouter)]
985 );
986
987 config
988 .openrouter
989 .accounts
990 .push(crate::config::OpenRouterAccount {
991 label: "work".into(),
992 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
993 api_key: None,
994 });
995 assert_eq!(
996 tabs_from_config(&config),
997 vec![TabId::account_for(VendorId::Openrouter, "work")]
998 );
999 }
1000
1001 #[test]
1002 fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
1003 let td = tempfile::tempdir().unwrap();
1006 for label in ["work", "personal"] {
1007 let dir = td.path().join(label);
1008 std::fs::create_dir_all(&dir).unwrap();
1009 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1010 }
1011 let mut config = Config::default();
1012 config.openai.enabled = false;
1013 config.zai.enabled = false;
1014 config.openrouter.enabled = false;
1015 config.anthropic.accounts_dir = Some(td.path().to_path_buf());
1016
1017 let tabs = tabs_from_config(&config);
1018 assert_eq!(
1019 tabs,
1020 vec![
1021 TabId::vendor(VendorId::Anthropic),
1022 TabId::account("personal"), TabId::account("work"),
1024 ]
1025 );
1026 }
1027
1028 #[test]
1029 fn desktop_labels_become_account_tabs_after_cli_accounts() {
1030 let config = config_with_accounts(&["work"]);
1032 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1033 assert_eq!(
1034 tabs,
1035 vec![
1036 TabId::vendor(VendorId::Anthropic),
1037 TabId::account("work"),
1038 TabId::desktop_account("gmail"),
1039 TabId::desktop_account("hotmail"),
1040 ]
1041 );
1042 }
1043
1044 #[test]
1045 fn a_desktop_profile_wins_a_label_collision_with_a_cli_account() {
1046 let config = config_with_accounts(&["gmail", "work"]);
1051 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1052 assert_eq!(
1053 tabs,
1054 vec![
1055 TabId::vendor(VendorId::Anthropic),
1056 TabId::account("work"),
1057 TabId::desktop_account("gmail"),
1058 TabId::desktop_account("hotmail"),
1059 ]
1060 );
1061 }
1062
1063 #[test]
1064 fn desktop_accounts_suppress_the_default_tab_like_named_ones() {
1065 let mut config = config_with_accounts(&[]);
1068 config.cursor.enabled = false;
1069 config.anthropic.show_default_account = false;
1070
1071 assert_eq!(
1074 build_tabs(&config, &[]),
1075 vec![TabId::vendor(VendorId::Anthropic)]
1076 );
1077 assert_eq!(
1079 build_tabs(&config, &["gmail".into()]),
1080 vec![TabId::desktop_account("gmail")]
1081 );
1082 }
1083
1084 #[test]
1085 fn set_tabs_resets_states_and_clamps_selection() {
1086 let mut app = App::with_theme(
1090 tabs_from_config(&config_with_accounts(&["work", "personal"])),
1091 Theme::default(),
1092 );
1093 app.active = 2; app.tabs[0] = TabState::Error("old".into());
1095 let old_tab = app.tabs_meta[0].clone();
1096 assert!(app.begin_refresh(&old_tab));
1097
1098 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
1099 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
1100 assert_eq!(app.active, 0, "selection clamped after shrink");
1101 assert!(matches!(app.tabs[0], TabState::Loading));
1102 assert!(!app.is_refreshing(&old_tab));
1103 }
1104
1105 #[test]
1106 fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
1107 let mut app = App::with_theme(
1108 vec![
1109 TabId::vendor(VendorId::Anthropic),
1110 TabId::vendor(VendorId::Openai),
1111 ],
1112 Theme::default(),
1113 );
1114 app.active = 1;
1115
1116 app.set_tabs(vec![
1117 TabId::vendor(VendorId::Anthropic),
1118 TabId::account("work"),
1119 TabId::vendor(VendorId::Openai),
1120 ]);
1121
1122 assert_eq!(app.active, 2);
1123 assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
1124 }
1125
1126 #[test]
1127 fn refresh_from_old_generation_is_discarded() {
1128 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1129 let old_generation = app.tab_generation;
1130 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
1131
1132 assert!(!app.apply_refresh(
1133 old_generation,
1134 &TabId::vendor(VendorId::Anthropic),
1135 TabState::Error("old result".into()),
1136 ));
1137 assert!(matches!(app.tabs[0], TabState::Loading));
1138 }
1139
1140 #[test]
1141 fn refresh_identity_mismatch_is_discarded() {
1142 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1143 let generation = app.tab_generation;
1144
1145 assert!(!app.apply_refresh(
1146 generation,
1147 &TabId::vendor(VendorId::Openai),
1148 TabState::Error("wrong tab".into()),
1149 ));
1150 assert!(matches!(app.tabs[0], TabState::Loading));
1151 }
1152
1153 #[test]
1154 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
1155 let anthropic = TabId::vendor(VendorId::Anthropic);
1156 let openai = TabId::vendor(VendorId::Openai);
1157 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
1158 let generation = app.tab_generation;
1159 assert!(app.begin_refresh(&anthropic));
1160
1161 app.tabs_meta.swap(0, 1);
1164 app.tabs.swap(0, 1);
1165 assert!(app.apply_refresh(generation, &anthropic, TabState::Error("ready".into())));
1166 assert!(matches!(app.tabs[0], TabState::Loading));
1167 assert!(matches!(&app.tabs[1], TabState::Error(message) if message == "ready"));
1168 assert!(!app.is_refreshing(&anthropic));
1169 }
1170
1171 fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
1172 TabState::Ready(Box::new(ReadyTab {
1173 snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
1174 label: "test".into(),
1175 total_credits: 0.0,
1176 total_usage: 0.0,
1177 usage_daily: 0.0,
1178 usage_weekly: 0.0,
1179 usage_monthly: 0.0,
1180 is_free_tier: false,
1181 limit: None,
1182 limit_remaining: None,
1183 }),
1184 stale: false,
1185 last_error: None,
1186 fetched_at: Some(fetched_at),
1187 }))
1188 }
1189
1190 #[test]
1191 fn refresh_keeps_ready_snapshot_visible_and_suppresses_duplicates() {
1192 let tab = TabId::vendor(VendorId::Openrouter);
1193 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1194 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1195 app.tabs[0] = ready_at(fetched_at);
1196
1197 assert!(app.begin_refresh(&tab));
1198 assert!(
1199 !app.begin_refresh(&tab),
1200 "duplicate request must be suppressed"
1201 );
1202 assert!(app.is_refreshing(&tab));
1203 match &app.tabs[0] {
1204 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1205 other => panic!("ready snapshot disappeared during refresh: {other:?}"),
1206 }
1207 }
1208
1209 #[test]
1210 fn first_refresh_still_uses_loading_until_data_arrives() {
1211 let tab = TabId::vendor(VendorId::Openrouter);
1212 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1213
1214 assert!(app.begin_refresh(&tab));
1215 assert!(app.is_refreshing(&tab));
1216 assert!(matches!(app.tabs[0], TabState::Loading));
1217
1218 assert!(app.apply_refresh(
1219 app.tab_generation,
1220 &tab,
1221 TabState::Error("not signed in".into()),
1222 ));
1223 assert!(!app.is_refreshing(&tab));
1224 assert!(matches!(&app.tabs[0], TabState::Error(message) if message == "not signed in"));
1225 }
1226
1227 #[test]
1228 fn successful_revalidation_replaces_snapshot_and_clears_indicator() {
1229 let tab = TabId::vendor(VendorId::Openrouter);
1230 let old_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1231 let new_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 1, 0).unwrap();
1232 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1233 app.tabs[0] = ready_at(old_at);
1234
1235 assert!(app.begin_refresh(&tab));
1236 assert!(app.apply_refresh(app.tab_generation, &tab, ready_at(new_at)));
1237 assert!(!app.is_refreshing(&tab));
1238 match &app.tabs[0] {
1239 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(new_at)),
1240 other => panic!("expected replacement snapshot, got {other:?}"),
1241 }
1242 }
1243
1244 #[test]
1245 fn failed_revalidation_preserves_snapshot_with_visible_warning() {
1246 let tab = TabId::vendor(VendorId::Openrouter);
1247 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1248 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1249 app.tabs[0] = ready_at(fetched_at);
1250
1251 assert!(app.begin_refresh(&tab));
1252 assert!(app.apply_refresh(
1253 app.tab_generation,
1254 &tab,
1255 TabState::Error("refresh failed".into()),
1256 ));
1257 assert!(!app.is_refreshing(&tab));
1258 match &app.tabs[0] {
1259 TabState::Ready(ready) => {
1260 assert_eq!(ready.fetched_at, Some(fetched_at));
1261 assert!(ready.stale);
1262 assert_eq!(ready.last_error, Some((0, "refresh failed".into())));
1263 }
1264 other => panic!("last successful snapshot was lost: {other:?}"),
1265 }
1266 let sections = crate::tui::panels::sections_for(&app.tabs[0], Utc::now(), 5);
1267 assert!(sections.iter().any(|section| matches!(
1268 section,
1269 crate::tui::panels::Section::Text { label, value }
1270 if label == "Warning" && value == "refresh failed"
1271 )));
1272 }
1273
1274 #[test]
1275 fn old_generation_result_does_not_clear_current_refresh() {
1276 let tab = TabId::vendor(VendorId::Openrouter);
1277 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1278 let old_generation = app.tab_generation;
1279 app.set_tabs(vec![tab.clone()]);
1280 assert!(app.begin_refresh(&tab));
1281
1282 assert!(!app.apply_refresh(old_generation, &tab, TabState::Error("old result".into()),));
1283 assert!(app.is_refreshing(&tab));
1284 assert!(matches!(app.tabs[0], TabState::Loading));
1285 }
1286
1287 #[test]
1288 fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
1289 let anthropic = TabId::vendor(VendorId::Anthropic);
1295 let openai = TabId::vendor(VendorId::Openai);
1296 let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
1297 let generation = app.tab_generation;
1298 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1299
1300 assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
1301 match &app.tabs[0] {
1302 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1303 other => panic!("expected Anthropic tab Ready, got {other:?}"),
1304 }
1305 assert!(matches!(app.tabs[1], TabState::Loading));
1306 }
1307
1308 #[test]
1309 fn select_primary_lands_on_default_account_tab() {
1310 let app = {
1313 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
1314 let mut a = App::with_theme(tabs, Theme::default());
1315 a.select_primary(Some(VendorId::Anthropic));
1316 a
1317 };
1318 assert_eq!(app.active, 0);
1319 assert_eq!(
1320 app.active_tab_id(),
1321 Some(&TabId::vendor(VendorId::Anthropic))
1322 );
1323 }
1324}