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