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 {
64 vendor: VendorId::Anthropic,
65 account: Some(label.into()),
66 desktop: false,
67 }
68 }
69
70 pub fn desktop_account(label: impl Into<String>) -> Self {
73 Self {
74 vendor: VendorId::Anthropic,
75 account: Some(label.into()),
76 desktop: true,
77 }
78 }
79}
80
81pub fn tabs_from_config(config: &Config) -> Vec<TabId> {
91 build_tabs(config, &[], &[])
92}
93
94pub fn tabs_with_desktop(config: &Config) -> Vec<TabId> {
99 let desktop = desktop_profile_labels(config);
100 let broken = broken_cli_labels(config, &desktop);
108 build_tabs(config, &desktop, &broken)
109}
110
111fn build_tabs(config: &Config, desktop_labels: &[String], broken_cli: &[String]) -> Vec<TabId> {
118 let mut tabs = Vec::new();
119 for vendor in config.enabled_vendors() {
120 if vendor == VendorId::Anthropic {
121 let accounts: Vec<_> = config
122 .anthropic
123 .all_accounts()
124 .into_iter()
125 .filter(|a| !broken_cli.iter().any(|b| b == &a.label))
126 .collect();
127 let cli_labels: HashSet<&str> = accounts.iter().map(|a| a.label.as_str()).collect();
128 let desktop: Vec<&String> = desktop_labels
129 .iter()
130 .filter(|label| !cli_labels.contains(label.as_str()))
131 .collect();
132 if config.anthropic.show_default_account || (accounts.is_empty() && desktop.is_empty())
136 {
137 tabs.push(TabId::vendor(vendor));
138 }
139 for acct in accounts {
140 tabs.push(TabId::account(acct.label));
141 }
142 for label in desktop {
143 tabs.push(TabId::desktop_account(label.clone()));
144 }
145 } else {
146 tabs.push(TabId::vendor(vendor));
147 }
148 }
149 tabs
150}
151
152fn broken_cli_labels(config: &Config, desktop_labels: &[String]) -> Vec<String> {
157 use crate::anthropic::creds;
158 config
159 .anthropic
160 .all_accounts()
161 .into_iter()
162 .filter(|a| desktop_labels.iter().any(|d| d == &a.label))
163 .filter(|a| match config.anthropic.account_target(&a.label) {
164 Ok((target, _)) => creds::resolve(&target)
165 .map(|(c, _)| creds::is_unusable(&c.claude_ai_oauth))
166 .unwrap_or(true),
167 Err(_) => true,
168 })
169 .map(|a| a.label)
170 .collect()
171}
172
173#[cfg(target_os = "macos")]
177fn desktop_profile_labels(config: &Config) -> Vec<String> {
178 let Ok(paths) = crate::claude_desktop::Paths::resolve(&config.anthropic) else {
179 return Vec::new();
180 };
181 if !paths.available() {
182 return Vec::new();
183 }
184 crate::claude_desktop::load_profiles(&paths.profiles_dir)
185 .into_iter()
186 .filter(|p| p.has_credentials)
187 .map(|p| p.label)
188 .collect()
189}
190
191#[cfg(not(target_os = "macos"))]
192fn desktop_profile_labels(_config: &Config) -> Vec<String> {
193 Vec::new()
194}
195
196#[derive(Debug)]
197pub struct App {
198 pub tabs_meta: Vec<TabId>,
199 pub active: usize,
200 pub tabs: Vec<TabState>,
201 refreshing_tabs: HashSet<TabId>,
204 pub tab_generation: u64,
208 pub overview: bool,
211 pub overview_vendors: Option<Vec<VendorId>>,
213 pub theme: Theme,
214 pub quit: bool,
215 pub settings: Option<crate::tui::settings::SettingsState>,
217 pub context_enabled: bool,
220 pub context_generation: u64,
223 pub context: Option<crate::tui::context::ContextState>,
225 pub vendor_box: crate::config::VendorBoxStyle,
227}
228
229impl App {
230 pub fn new(tabs_meta: Vec<TabId>) -> Self {
231 Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
234 }
235
236 pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
242 let n = tabs_meta.len();
243 Self {
244 tabs_meta,
245 active: 0,
246 tabs: vec![TabState::Loading; n],
247 refreshing_tabs: HashSet::new(),
248 tab_generation: 0,
249 overview: false,
250 overview_vendors: None,
251 theme,
252 quit: false,
253 settings: None,
254 context_enabled: false,
255 context_generation: 0,
256 context: None,
257 vendor_box: crate::config::VendorBoxStyle::Sidebar,
258 }
259 }
260
261 pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
265 let mut app = Self::new(tabs_meta);
266 if primary.is_some() {
269 app.select_primary(primary);
270 } else {
271 app.overview = true;
272 }
273 app
274 }
275
276 pub fn active_tab_id(&self) -> Option<&TabId> {
277 self.tabs_meta.get(self.active)
278 }
279
280 pub fn active_vendor(&self) -> Option<VendorId> {
281 self.tabs_meta.get(self.active).map(|t| t.vendor)
282 }
283
284 pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
291 let selected = self.active_tab_id().cloned();
292 let fallback = self.active.min(tabs_meta.len().saturating_sub(1));
293 self.tab_generation = self.tab_generation.wrapping_add(1);
294 self.active = selected
295 .as_ref()
296 .and_then(|tab| tabs_meta.iter().position(|candidate| candidate == tab))
297 .unwrap_or(fallback);
298 self.tabs = vec![TabState::Loading; tabs_meta.len()];
299 self.tabs_meta = tabs_meta;
300 self.refreshing_tabs.clear();
301 }
302
303 pub fn begin_refresh(&mut self, tab: &TabId) -> bool {
307 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
308 return false;
309 };
310 if !self.refreshing_tabs.insert(tab.clone()) {
311 return false;
312 }
313 if !matches!(self.tabs[index], TabState::Ready(_)) {
314 self.tabs[index] = TabState::Loading;
315 }
316 true
317 }
318
319 pub fn is_refreshing(&self, tab: &TabId) -> bool {
320 self.refreshing_tabs.contains(tab)
321 }
322
323 pub fn tab_is_refreshing(&self, index: usize) -> bool {
324 self.tabs_meta
325 .get(index)
326 .is_some_and(|tab| self.is_refreshing(tab))
327 }
328
329 pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
334 if generation != self.tab_generation {
335 return false;
336 }
337 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
338 return false;
339 };
340 let was_refreshing = self.refreshing_tabs.remove(tab);
341 if was_refreshing
345 && let TabState::Ready(ready) = &mut self.tabs[index]
346 && let TabState::Error(message) = state
347 {
348 ready.stale = true;
349 ready.last_error = Some((0, message));
350 } else {
351 self.tabs[index] = state;
352 }
353 true
354 }
355
356 pub fn select_primary(&mut self, primary: Option<VendorId>) {
359 if let Some(p) = primary
360 && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor == p)
361 {
362 self.active = idx;
363 self.overview = false;
364 }
365 }
366
367 pub fn next_tab(&mut self) {
370 if self.overview {
371 if !self.tabs_meta.is_empty() {
372 self.overview = false;
373 self.active = 0;
374 }
375 } else if self.active + 1 < self.tabs_meta.len() {
376 self.active += 1;
377 } else {
378 self.overview = true;
379 }
380 }
381
382 pub fn prev_tab(&mut self) {
383 if self.overview {
384 if !self.tabs_meta.is_empty() {
385 self.overview = false;
386 self.active = self.tabs_meta.len() - 1;
387 }
388 } else if self.active > 0 {
389 self.active -= 1;
390 } else {
391 self.overview = true;
392 }
393 }
394
395 pub fn overview_tabs(&self) -> Vec<usize> {
398 match &self.overview_vendors {
399 None => (0..self.tabs_meta.len()).collect(),
400 Some(wanted) => wanted
401 .iter()
402 .flat_map(|v| {
403 self.tabs_meta
404 .iter()
405 .enumerate()
406 .filter(move |(_, t)| t.vendor == *v)
407 .map(|(i, _)| i)
408 })
409 .collect(),
410 }
411 }
412}
413
414pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
416 match build_outcome(client, config, tab).await {
417 Ok(outcome) => {
418 let now = Utc::now();
423 let fetched_at = outcome
424 .cache_age
425 .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
426 TabState::Ready(Box::new(ReadyTab {
427 snapshot: outcome.snapshot,
428 stale: outcome.stale,
429 last_error: outcome.last_error.map(|(code, message)| {
430 (code, crate::display::sanitize_untrusted_field(&message))
431 }),
432 fetched_at,
433 }))
434 }
435 Err(e) => TabState::Error(crate::display::sanitize_untrusted_field(&e.user_message())),
436 }
437}
438
439async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
440 match tab.vendor {
441 VendorId::Anthropic => {
442 let (creds_target, cache) = match tab.account.as_deref() {
448 Some(label) if tab.desktop => {
449 crate::anthropic::desktop_creds::account_target(config, label)?
450 }
451 Some(label) => config.anthropic.account_target(label)?,
452 None => {
453 let target = match config.anthropic.credentials_path.clone() {
454 Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
455 None => crate::anthropic::creds::CredsTarget::Default(
456 crate::anthropic::creds::default_path().unwrap_or_default(),
457 ),
458 };
459 (target, crate::cache::Cache::for_vendor("anthropic")?)
460 }
461 };
462 let endpoints = crate::anthropic::fetch::Endpoints::default();
463 let outcome = crate::anthropic::fetch_snapshot(
464 client,
465 &creds_target,
466 &cache,
467 &endpoints,
468 DEFAULT_TTL,
469 )
470 .await?;
471 Ok(crate::vendor::VendorOutcome {
472 snapshot: crate::usage::VendorSnapshot::Anthropic(outcome.snapshot),
473 stale: outcome.stale,
474 last_error: outcome.last_error,
475 cache_age: outcome.cache_age,
476 })
477 }
478 VendorId::AnthropicApi => {
479 let key = crate::config::resolve_api_key(
480 "Anthropic_API",
481 &config.anthropic_api.api_key_env,
482 config.anthropic_api.api_key.as_deref(),
483 )?;
484 let cache = crate::cache::Cache::for_vendor("anthropic_api")?;
485 let endpoints = crate::anthropic_api::fetch::Endpoints::default();
486 let outcome = crate::anthropic_api::fetch_snapshot(
487 client,
488 &key,
489 &cache,
490 &endpoints,
491 DEFAULT_TTL,
492 config.anthropic_api.monthly_limit,
493 )
494 .await?;
495 Ok(outcome.into())
496 }
497 VendorId::Openrouter => {
498 let api_key = crate::config::resolve_api_key(
499 "OpenRouter",
500 &config.openrouter.api_key_env,
501 config.openrouter.api_key.as_deref(),
502 )?;
503 let cache = crate::cache::Cache::for_vendor("openrouter")?;
504 let endpoints = crate::openrouter::fetch::Endpoints::default();
505 let outcome = crate::openrouter::fetch_snapshot(
506 client,
507 &api_key,
508 &cache,
509 &endpoints,
510 DEFAULT_TTL,
511 )
512 .await?;
513 Ok(outcome.into())
514 }
515 VendorId::Zai => {
516 let api_key = crate::config::resolve_api_key(
517 "Zai",
518 &config.zai.api_key_env,
519 config.zai.api_key.as_deref(),
520 )?;
521 let cache = crate::cache::Cache::for_vendor("zai")?;
522 let endpoints = crate::zai::fetch::Endpoints::default();
523 let outcome = crate::zai::fetch_snapshot(
524 client,
525 &api_key,
526 &cache,
527 &endpoints,
528 DEFAULT_TTL,
529 config.zai.plan_tier.as_deref(),
530 )
531 .await?;
532 Ok(outcome.into())
533 }
534 VendorId::Openai => {
535 let cache = crate::cache::Cache::for_vendor("openai")?;
536 let creds_path = config
537 .openai
538 .codex_auth_path
539 .clone()
540 .unwrap_or_else(|| crate::openai::creds::default_path().unwrap_or_default());
541 let endpoints = crate::openai::fetch::Endpoints::default();
542 let outcome =
543 crate::openai::fetch_snapshot(client, &creds_path, &cache, &endpoints, DEFAULT_TTL)
544 .await?;
545 Ok(outcome.into())
546 }
547 VendorId::Deepseek => {
548 let api_key = crate::config::resolve_api_key(
549 "DeepSeek",
550 &config.deepseek.api_key_env,
551 config.deepseek.api_key.as_deref(),
552 )?;
553 let cache = crate::cache::Cache::for_vendor("deepseek")?;
554 let endpoints = crate::deepseek::fetch::Endpoints::default();
555 let outcome =
556 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
557 .await?;
558 Ok(outcome.into())
559 }
560 VendorId::Kimi => {
561 let api_key = crate::config::resolve_api_key(
562 "Kimi",
563 &config.kimi.api_key_env,
564 config.kimi.api_key.as_deref(),
565 )?;
566 let cache = crate::cache::Cache::for_vendor("kimi")?;
567 let endpoints = crate::kimi::fetch::Endpoints::default();
568 let outcome =
569 crate::kimi::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
570 .await?;
571 Ok(outcome.into())
572 }
573 VendorId::Kilo => {
574 let api_key = crate::config::resolve_api_key(
575 "Kilo",
576 &config.kilo.api_key_env,
577 config.kilo.api_key.as_deref(),
578 )?;
579 let cache = crate::cache::Cache::for_vendor("kilo")?;
580 let endpoints = crate::kilo::fetch::Endpoints::default();
581 let outcome = crate::kilo::fetch_snapshot(
582 client,
583 &api_key,
584 &cache,
585 &endpoints,
586 DEFAULT_TTL,
587 config.kilo.organization_id.as_deref(),
588 )
589 .await?;
590 Ok(outcome.into())
591 }
592 VendorId::Novita => {
593 let api_key = crate::config::resolve_api_key(
594 "Novita",
595 &config.novita.api_key_env,
596 config.novita.api_key.as_deref(),
597 )?;
598 let cache = crate::cache::Cache::for_vendor("novita")?;
599 let endpoints = crate::novita::fetch::Endpoints::default();
600 let outcome =
601 crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
602 .await?;
603 Ok(outcome.into())
604 }
605 VendorId::Moonshot => {
606 let api_key = crate::config::resolve_api_key(
607 "Moonshot",
608 &config.moonshot.api_key_env,
609 config.moonshot.api_key.as_deref(),
610 )?;
611 let cache = crate::cache::Cache::for_vendor("moonshot")?;
612 let (endpoints, currency) =
613 crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
614 let outcome = crate::moonshot::fetch_snapshot(
615 client,
616 &api_key,
617 &cache,
618 &endpoints,
619 DEFAULT_TTL,
620 currency,
621 )
622 .await?;
623 Ok(outcome.into())
624 }
625 VendorId::Grok => {
626 let key = crate::config::resolve_api_key(
627 "Grok",
628 &config.grok.api_key_env,
629 config.grok.api_key.as_deref(),
630 )?;
631 let cache = crate::cache::Cache::for_vendor("grok")?;
632 let endpoints = crate::grok::fetch::Endpoints::default();
633 let outcome = crate::grok::fetch_snapshot(
634 client,
635 &key,
636 &cache,
637 &endpoints,
638 DEFAULT_TTL,
639 config.grok.team_id.as_deref(),
640 )
641 .await?;
642 Ok(outcome.into())
643 }
644 VendorId::Supergrok => {
645 let cache = crate::cache::Cache::for_vendor("supergrok")?;
646 let scope_paths = crate::supergrok::scope::ScopePaths::with_overrides(
647 config.supergrok.auth_path.as_deref(),
648 config.supergrok.config_path.as_deref(),
649 )?;
650 let outcome = crate::supergrok::fetch_snapshot(
651 &config.supergrok.grok_binary,
652 &scope_paths,
653 &cache,
654 DEFAULT_TTL,
655 )
656 .await?;
657 Ok(outcome.into())
658 }
659 VendorId::Antigravity => {
660 let cache = crate::cache::Cache::for_vendor("antigravity")?;
662 let outcome = crate::antigravity::fetch_snapshot(client, &cache, DEFAULT_TTL).await?;
663 Ok(outcome.into())
664 }
665 VendorId::Minimax => {
666 let api_key = crate::config::resolve_api_key(
667 "MiniMax",
668 &config.minimax.api_key_env,
669 config.minimax.api_key.as_deref(),
670 )?;
671 let cache = crate::cache::Cache::for_vendor("minimax")?;
672 let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
673 let outcome =
674 crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
675 .await?;
676 Ok(outcome.into())
677 }
678 VendorId::Cursor => {
679 let cache = crate::cache::Cache::for_vendor("cursor")?;
680 let db_path = config
681 .cursor
682 .db_path
683 .clone()
684 .map(Ok)
685 .unwrap_or_else(crate::cursor::db::default_db_path)?;
686 let agent_auth_path = config
687 .cursor
688 .agent_auth_path
689 .clone()
690 .map(Ok)
691 .unwrap_or_else(crate::cursor::db::default_agent_auth_path)?;
692 let endpoints = crate::cursor::fetch::Endpoints::default();
693 let outcome = crate::cursor::fetch_snapshot(
694 client,
695 &db_path,
696 &agent_auth_path,
697 &cache,
698 &endpoints,
699 DEFAULT_TTL,
700 )
701 .await?;
702 Ok(outcome.into())
703 }
704 VendorId::Kiro => {
705 let cache = crate::cache::Cache::for_vendor("kiro")?;
706 let db_path = config
707 .kiro
708 .db_path
709 .clone()
710 .map(Ok)
711 .unwrap_or_else(crate::kiro::db::default_db_path)?;
712 let outcome =
713 crate::kiro::fetch_snapshot(client, &db_path, &cache, DEFAULT_TTL).await?;
714 Ok(outcome.into())
715 }
716 }
717}
718
719pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
722
723pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
730
731pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
737 let mut anthropic_seen: u32 = 0;
738 tabs.iter()
739 .map(|tab| {
740 if tab.vendor == VendorId::Anthropic {
741 let delay = step * anthropic_seen;
742 anthropic_seen += 1;
743 delay
744 } else {
745 Duration::ZERO
746 }
747 })
748 .collect()
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754 use chrono::TimeZone;
755
756 #[test]
760 fn refresh_stagger_spaces_out_anthropic_tabs_only() {
761 let step = Duration::from_millis(800);
762 let tabs = vec![
763 TabId::vendor(VendorId::Anthropic), TabId::account("work"),
765 TabId::account("personal"),
766 TabId::vendor(VendorId::Openai),
767 TabId::vendor(VendorId::Zai),
768 ];
769 let delays = refresh_stagger(&tabs, step);
770 assert_eq!(
771 delays,
772 vec![
773 Duration::ZERO, step, step * 2, Duration::ZERO, Duration::ZERO, ]
779 );
780 }
781
782 #[test]
783 fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
784 let tabs = vec![
786 TabId::vendor(VendorId::Anthropic),
787 TabId::vendor(VendorId::Openrouter),
788 ];
789 assert!(
790 refresh_stagger(&tabs, Duration::from_millis(800))
791 .iter()
792 .all(|d| d.is_zero())
793 );
794 }
795
796 #[test]
797 fn select_primary_moves_to_enabled_vendor() {
798 let mut app = App::with_theme(
799 vec![
800 TabId::vendor(VendorId::Anthropic),
801 TabId::vendor(VendorId::Openrouter),
802 ],
803 Theme::default(),
804 );
805 app.select_primary(Some(VendorId::Openrouter));
806 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
807 }
808
809 #[test]
810 fn select_primary_ignores_disabled_vendor() {
811 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
812 app.select_primary(Some(VendorId::Openai));
813 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
814 }
815
816 #[test]
817 fn nav_ring_wraps_through_the_overview_at_both_ends() {
818 let mut app = App::with_theme(
819 vec![
820 TabId::vendor(VendorId::Anthropic),
821 TabId::vendor(VendorId::Openai),
822 ],
823 Theme::default(),
824 );
825 app.overview = true;
826
827 app.next_tab(); assert!(!app.overview);
829 assert_eq!(app.active, 0);
830 app.next_tab();
831 assert_eq!(app.active, 1);
832 app.next_tab(); assert!(app.overview);
834
835 app.prev_tab(); assert!(!app.overview);
837 assert_eq!(app.active, 1);
838 app.prev_tab();
839 assert_eq!(app.active, 0);
840 app.prev_tab(); assert!(app.overview);
842 }
843
844 #[test]
845 fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
846 let mut app = App::with_theme(
847 vec![
848 TabId::vendor(VendorId::Anthropic),
849 TabId::vendor(VendorId::Openai),
850 TabId::vendor(VendorId::Zai),
851 ],
852 Theme::default(),
853 );
854 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
855
856 app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
858 assert_eq!(app.overview_tabs(), vec![2, 0]);
859
860 app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
862 assert_eq!(app.overview_tabs(), vec![1]);
863 }
864
865 fn config_with_accounts(labels: &[&str]) -> Config {
866 let mut config = Config::default();
867 config.openai.enabled = false;
870 config.zai.enabled = false;
871 config.openrouter.enabled = false;
872 config.anthropic.accounts = labels
873 .iter()
874 .map(|l| crate::config::AnthropicAccount {
875 label: (*l).to_string(),
876 credentials_path: format!("/creds/{l}.json").into(),
877 })
878 .collect();
879 config
880 }
881
882 #[test]
883 fn show_default_account_false_hides_the_unnamed_claude_tab() {
884 let mut config = config_with_accounts(&["work", "personal"]);
887 config.anthropic.show_default_account = false;
888 assert_eq!(
889 tabs_from_config(&config),
890 vec![TabId::account("work"), TabId::account("personal")]
891 );
892
893 let mut empty = Config::default();
896 empty.openai.enabled = false;
897 empty.zai.enabled = false;
898 empty.openrouter.enabled = false;
899 empty.anthropic.show_default_account = false;
900 assert_eq!(
901 tabs_from_config(&empty),
902 vec![TabId::vendor(VendorId::Anthropic)]
903 );
904 }
905
906 #[test]
907 fn tabs_expand_anthropic_accounts_after_default() {
908 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
910 assert_eq!(
911 tabs,
912 vec![
913 TabId::vendor(VendorId::Anthropic),
914 TabId::account("work"),
915 TabId::account("personal"),
916 ]
917 );
918 }
919
920 #[test]
921 fn tabs_without_accounts_are_just_enabled_vendors() {
922 let config = Config::default();
924 let tabs = tabs_from_config(&config);
925 let vendors: Vec<VendorId> = tabs.iter().map(|t| t.vendor).collect();
926 assert_eq!(vendors, config.enabled_vendors());
927 assert!(tabs.iter().all(|t| t.account.is_none()));
928 }
929
930 #[test]
931 fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
932 let td = tempfile::tempdir().unwrap();
935 for label in ["work", "personal"] {
936 let dir = td.path().join(label);
937 std::fs::create_dir_all(&dir).unwrap();
938 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
939 }
940 let mut config = Config::default();
941 config.openai.enabled = false;
942 config.zai.enabled = false;
943 config.openrouter.enabled = false;
944 config.anthropic.accounts_dir = Some(td.path().to_path_buf());
945
946 let tabs = tabs_from_config(&config);
947 assert_eq!(
948 tabs,
949 vec![
950 TabId::vendor(VendorId::Anthropic),
951 TabId::account("personal"), TabId::account("work"),
953 ]
954 );
955 }
956
957 #[test]
958 fn desktop_labels_become_account_tabs_after_cli_accounts() {
959 let config = config_with_accounts(&["work"]);
961 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()], &[]);
962 assert_eq!(
963 tabs,
964 vec![
965 TabId::vendor(VendorId::Anthropic),
966 TabId::account("work"),
967 TabId::desktop_account("gmail"),
968 TabId::desktop_account("hotmail"),
969 ]
970 );
971 }
972
973 #[test]
974 fn a_cli_account_shadows_a_desktop_profile_of_the_same_label() {
975 let config = config_with_accounts(&["gmail"]);
977 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()], &[]);
978 assert_eq!(
979 tabs,
980 vec![
981 TabId::vendor(VendorId::Anthropic),
982 TabId::account("gmail"),
983 TabId::desktop_account("hotmail"),
984 ]
985 );
986 }
987
988 #[test]
989 fn a_broken_cli_account_yields_its_label_to_the_desktop_profile() {
990 let config = config_with_accounts(&["gmail"]);
995 let tabs = build_tabs(&config, &["gmail".into()], &["gmail".into()]);
996 assert_eq!(
997 tabs,
998 vec![
999 TabId::vendor(VendorId::Anthropic),
1000 TabId::desktop_account("gmail"),
1001 ]
1002 );
1003 }
1004
1005 #[test]
1006 fn desktop_accounts_suppress_the_default_tab_like_named_ones() {
1007 let mut config = config_with_accounts(&[]);
1010 config.cursor.enabled = false;
1011 config.anthropic.show_default_account = false;
1012
1013 assert_eq!(
1016 build_tabs(&config, &[], &[]),
1017 vec![TabId::vendor(VendorId::Anthropic)]
1018 );
1019 assert_eq!(
1021 build_tabs(&config, &["gmail".into()], &[]),
1022 vec![TabId::desktop_account("gmail")]
1023 );
1024 }
1025
1026 #[test]
1027 fn set_tabs_resets_states_and_clamps_selection() {
1028 let mut app = App::with_theme(
1032 tabs_from_config(&config_with_accounts(&["work", "personal"])),
1033 Theme::default(),
1034 );
1035 app.active = 2; app.tabs[0] = TabState::Error("old".into());
1037 let old_tab = app.tabs_meta[0].clone();
1038 assert!(app.begin_refresh(&old_tab));
1039
1040 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
1041 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
1042 assert_eq!(app.active, 0, "selection clamped after shrink");
1043 assert!(matches!(app.tabs[0], TabState::Loading));
1044 assert!(!app.is_refreshing(&old_tab));
1045 }
1046
1047 #[test]
1048 fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
1049 let mut app = App::with_theme(
1050 vec![
1051 TabId::vendor(VendorId::Anthropic),
1052 TabId::vendor(VendorId::Openai),
1053 ],
1054 Theme::default(),
1055 );
1056 app.active = 1;
1057
1058 app.set_tabs(vec![
1059 TabId::vendor(VendorId::Anthropic),
1060 TabId::account("work"),
1061 TabId::vendor(VendorId::Openai),
1062 ]);
1063
1064 assert_eq!(app.active, 2);
1065 assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
1066 }
1067
1068 #[test]
1069 fn refresh_from_old_generation_is_discarded() {
1070 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1071 let old_generation = app.tab_generation;
1072 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
1073
1074 assert!(!app.apply_refresh(
1075 old_generation,
1076 &TabId::vendor(VendorId::Anthropic),
1077 TabState::Error("old result".into()),
1078 ));
1079 assert!(matches!(app.tabs[0], TabState::Loading));
1080 }
1081
1082 #[test]
1083 fn refresh_identity_mismatch_is_discarded() {
1084 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1085 let generation = app.tab_generation;
1086
1087 assert!(!app.apply_refresh(
1088 generation,
1089 &TabId::vendor(VendorId::Openai),
1090 TabState::Error("wrong tab".into()),
1091 ));
1092 assert!(matches!(app.tabs[0], TabState::Loading));
1093 }
1094
1095 #[test]
1096 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
1097 let anthropic = TabId::vendor(VendorId::Anthropic);
1098 let openai = TabId::vendor(VendorId::Openai);
1099 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
1100 let generation = app.tab_generation;
1101 assert!(app.begin_refresh(&anthropic));
1102
1103 app.tabs_meta.swap(0, 1);
1106 app.tabs.swap(0, 1);
1107 assert!(app.apply_refresh(generation, &anthropic, TabState::Error("ready".into())));
1108 assert!(matches!(app.tabs[0], TabState::Loading));
1109 assert!(matches!(&app.tabs[1], TabState::Error(message) if message == "ready"));
1110 assert!(!app.is_refreshing(&anthropic));
1111 }
1112
1113 fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
1114 TabState::Ready(Box::new(ReadyTab {
1115 snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
1116 label: "test".into(),
1117 total_credits: 0.0,
1118 total_usage: 0.0,
1119 usage_daily: 0.0,
1120 usage_weekly: 0.0,
1121 usage_monthly: 0.0,
1122 is_free_tier: false,
1123 limit: None,
1124 limit_remaining: None,
1125 }),
1126 stale: false,
1127 last_error: None,
1128 fetched_at: Some(fetched_at),
1129 }))
1130 }
1131
1132 #[test]
1133 fn refresh_keeps_ready_snapshot_visible_and_suppresses_duplicates() {
1134 let tab = TabId::vendor(VendorId::Openrouter);
1135 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1136 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1137 app.tabs[0] = ready_at(fetched_at);
1138
1139 assert!(app.begin_refresh(&tab));
1140 assert!(
1141 !app.begin_refresh(&tab),
1142 "duplicate request must be suppressed"
1143 );
1144 assert!(app.is_refreshing(&tab));
1145 match &app.tabs[0] {
1146 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1147 other => panic!("ready snapshot disappeared during refresh: {other:?}"),
1148 }
1149 }
1150
1151 #[test]
1152 fn first_refresh_still_uses_loading_until_data_arrives() {
1153 let tab = TabId::vendor(VendorId::Openrouter);
1154 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1155
1156 assert!(app.begin_refresh(&tab));
1157 assert!(app.is_refreshing(&tab));
1158 assert!(matches!(app.tabs[0], TabState::Loading));
1159
1160 assert!(app.apply_refresh(
1161 app.tab_generation,
1162 &tab,
1163 TabState::Error("not signed in".into()),
1164 ));
1165 assert!(!app.is_refreshing(&tab));
1166 assert!(matches!(&app.tabs[0], TabState::Error(message) if message == "not signed in"));
1167 }
1168
1169 #[test]
1170 fn successful_revalidation_replaces_snapshot_and_clears_indicator() {
1171 let tab = TabId::vendor(VendorId::Openrouter);
1172 let old_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1173 let new_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 1, 0).unwrap();
1174 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1175 app.tabs[0] = ready_at(old_at);
1176
1177 assert!(app.begin_refresh(&tab));
1178 assert!(app.apply_refresh(app.tab_generation, &tab, ready_at(new_at)));
1179 assert!(!app.is_refreshing(&tab));
1180 match &app.tabs[0] {
1181 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(new_at)),
1182 other => panic!("expected replacement snapshot, got {other:?}"),
1183 }
1184 }
1185
1186 #[test]
1187 fn failed_revalidation_preserves_snapshot_with_visible_warning() {
1188 let tab = TabId::vendor(VendorId::Openrouter);
1189 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1190 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1191 app.tabs[0] = ready_at(fetched_at);
1192
1193 assert!(app.begin_refresh(&tab));
1194 assert!(app.apply_refresh(
1195 app.tab_generation,
1196 &tab,
1197 TabState::Error("refresh failed".into()),
1198 ));
1199 assert!(!app.is_refreshing(&tab));
1200 match &app.tabs[0] {
1201 TabState::Ready(ready) => {
1202 assert_eq!(ready.fetched_at, Some(fetched_at));
1203 assert!(ready.stale);
1204 assert_eq!(ready.last_error, Some((0, "refresh failed".into())));
1205 }
1206 other => panic!("last successful snapshot was lost: {other:?}"),
1207 }
1208 let sections = crate::tui::panels::sections_for(&app.tabs[0], Utc::now(), 5);
1209 assert!(sections.iter().any(|section| matches!(
1210 section,
1211 crate::tui::panels::Section::Text { label, value }
1212 if label == "Warning" && value == "refresh failed"
1213 )));
1214 }
1215
1216 #[test]
1217 fn old_generation_result_does_not_clear_current_refresh() {
1218 let tab = TabId::vendor(VendorId::Openrouter);
1219 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1220 let old_generation = app.tab_generation;
1221 app.set_tabs(vec![tab.clone()]);
1222 assert!(app.begin_refresh(&tab));
1223
1224 assert!(!app.apply_refresh(old_generation, &tab, TabState::Error("old result".into()),));
1225 assert!(app.is_refreshing(&tab));
1226 assert!(matches!(app.tabs[0], TabState::Loading));
1227 }
1228
1229 #[test]
1230 fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
1231 let anthropic = TabId::vendor(VendorId::Anthropic);
1237 let openai = TabId::vendor(VendorId::Openai);
1238 let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
1239 let generation = app.tab_generation;
1240 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1241
1242 assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
1243 match &app.tabs[0] {
1244 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1245 other => panic!("expected Anthropic tab Ready, got {other:?}"),
1246 }
1247 assert!(matches!(app.tabs[1], TabState::Loading));
1248 }
1249
1250 #[test]
1251 fn select_primary_lands_on_default_account_tab() {
1252 let app = {
1255 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
1256 let mut a = App::with_theme(tabs, Theme::default());
1257 a.select_primary(Some(VendorId::Anthropic));
1258 a
1259 };
1260 assert_eq!(app.active, 0);
1261 assert_eq!(
1262 app.active_tab_id(),
1263 Some(&TabId::vendor(VendorId::Anthropic))
1264 );
1265 }
1266}