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 if vendor == VendorId::Openai {
150 tabs.push(TabId::vendor(vendor));
151 for account in &config.openai.accounts {
152 tabs.push(TabId::account_for(vendor, account.label.clone()));
153 }
154 } else {
155 tabs.push(TabId::vendor(vendor));
156 }
157 }
158 tabs
159}
160
161#[cfg(target_os = "macos")]
165fn desktop_profile_labels(config: &Config) -> Vec<String> {
166 let Ok(paths) = crate::claude_desktop::Paths::resolve(&config.anthropic) else {
167 return Vec::new();
168 };
169 if !paths.available() {
170 return Vec::new();
171 }
172 crate::claude_desktop::load_profiles(&paths.profiles_dir)
173 .into_iter()
174 .filter(|p| p.has_credentials)
175 .map(|p| p.label)
176 .collect()
177}
178
179#[cfg(not(target_os = "macos"))]
180fn desktop_profile_labels(_config: &Config) -> Vec<String> {
181 Vec::new()
182}
183
184#[derive(Debug)]
185pub struct App {
186 pub tabs_meta: Vec<TabId>,
187 pub active: usize,
188 pub tabs: Vec<TabState>,
189 refreshing_tabs: HashSet<TabId>,
192 pub tab_generation: u64,
196 pub overview: bool,
199 pub overview_vendors: Option<Vec<VendorId>>,
201 pub theme: Theme,
202 pub quit: bool,
203 pub settings: Option<crate::tui::settings::SettingsState>,
205 pub context_enabled: bool,
208 pub context_generation: u64,
211 pub context: Option<crate::tui::context::ContextState>,
213 pub vendor_box: crate::config::VendorBoxStyle,
215}
216
217impl App {
218 pub fn new(tabs_meta: Vec<TabId>) -> Self {
219 Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
222 }
223
224 pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
230 let n = tabs_meta.len();
231 Self {
232 tabs_meta,
233 active: 0,
234 tabs: vec![TabState::Loading; n],
235 refreshing_tabs: HashSet::new(),
236 tab_generation: 0,
237 overview: false,
238 overview_vendors: None,
239 theme,
240 quit: false,
241 settings: None,
242 context_enabled: false,
243 context_generation: 0,
244 context: None,
245 vendor_box: crate::config::VendorBoxStyle::Sidebar,
246 }
247 }
248
249 pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
253 let mut app = Self::new(tabs_meta);
254 if primary.is_some() {
257 app.select_primary(primary);
258 } else {
259 app.overview = true;
260 }
261 app
262 }
263
264 pub fn active_tab_id(&self) -> Option<&TabId> {
265 self.tabs_meta.get(self.active)
266 }
267
268 pub fn active_vendor(&self) -> Option<VendorId> {
269 self.tabs_meta.get(self.active).map(|t| t.vendor)
270 }
271
272 pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
279 let selected = self.active_tab_id().cloned();
280 let fallback = self.active.min(tabs_meta.len().saturating_sub(1));
281 self.tab_generation = self.tab_generation.wrapping_add(1);
282 self.active = selected
283 .as_ref()
284 .and_then(|tab| tabs_meta.iter().position(|candidate| candidate == tab))
285 .unwrap_or(fallback);
286 self.tabs = vec![TabState::Loading; tabs_meta.len()];
287 self.tabs_meta = tabs_meta;
288 self.refreshing_tabs.clear();
289 }
290
291 pub fn begin_refresh(&mut self, tab: &TabId) -> bool {
295 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
296 return false;
297 };
298 if !self.refreshing_tabs.insert(tab.clone()) {
299 return false;
300 }
301 if !matches!(self.tabs[index], TabState::Ready(_)) {
302 self.tabs[index] = TabState::Loading;
303 }
304 true
305 }
306
307 pub fn is_refreshing(&self, tab: &TabId) -> bool {
308 self.refreshing_tabs.contains(tab)
309 }
310
311 pub fn tab_is_refreshing(&self, index: usize) -> bool {
312 self.tabs_meta
313 .get(index)
314 .is_some_and(|tab| self.is_refreshing(tab))
315 }
316
317 pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
322 if generation != self.tab_generation {
323 return false;
324 }
325 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
326 return false;
327 };
328 let was_refreshing = self.refreshing_tabs.remove(tab);
329 if was_refreshing
333 && let TabState::Ready(ready) = &mut self.tabs[index]
334 && let TabState::Error(message) = state
335 {
336 ready.stale = true;
337 ready.last_error = Some((0, message));
338 } else {
339 self.tabs[index] = state;
340 }
341 true
342 }
343
344 pub fn select_primary(&mut self, primary: Option<VendorId>) {
347 if let Some(p) = primary
348 && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor == p)
349 {
350 self.active = idx;
351 self.overview = false;
352 }
353 }
354
355 pub fn next_tab(&mut self) {
358 if self.overview {
359 if !self.tabs_meta.is_empty() {
360 self.overview = false;
361 self.active = 0;
362 }
363 } else if self.active + 1 < self.tabs_meta.len() {
364 self.active += 1;
365 } else {
366 self.overview = true;
367 }
368 }
369
370 pub fn prev_tab(&mut self) {
371 if self.overview {
372 if !self.tabs_meta.is_empty() {
373 self.overview = false;
374 self.active = self.tabs_meta.len() - 1;
375 }
376 } else if self.active > 0 {
377 self.active -= 1;
378 } else {
379 self.overview = true;
380 }
381 }
382
383 pub fn overview_tabs(&self) -> Vec<usize> {
386 match &self.overview_vendors {
387 None => (0..self.tabs_meta.len()).collect(),
388 Some(wanted) => wanted
389 .iter()
390 .flat_map(|v| {
391 self.tabs_meta
392 .iter()
393 .enumerate()
394 .filter(move |(_, t)| t.vendor == *v)
395 .map(|(i, _)| i)
396 })
397 .collect(),
398 }
399 }
400}
401
402pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
404 match build_outcome(client, config, tab).await {
405 Ok(outcome) => {
406 let now = Utc::now();
411 let fetched_at = outcome
412 .cache_age
413 .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
414 TabState::Ready(Box::new(ReadyTab {
415 snapshot: outcome.snapshot,
416 stale: outcome.stale,
417 last_error: outcome.last_error.map(|(code, message)| {
418 (code, crate::display::sanitize_untrusted_field(&message))
419 }),
420 fetched_at,
421 }))
422 }
423 Err(e) => TabState::Error(crate::display::sanitize_untrusted_field(&e.user_message())),
424 }
425}
426
427async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
428 match tab.vendor {
429 VendorId::Anthropic => {
430 let (creds_target, cache) = match tab.account.as_deref() {
436 Some(label) if tab.desktop => {
437 crate::anthropic::desktop_creds::account_target(config, label)?
438 }
439 Some(label) => config.anthropic.account_target(label)?,
440 None => {
441 let target = match config.anthropic.credentials_path.clone() {
442 Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
443 None => crate::anthropic::creds::CredsTarget::Default(
444 crate::anthropic::creds::default_path().unwrap_or_default(),
445 ),
446 };
447 (target, crate::cache::Cache::for_vendor("anthropic")?)
448 }
449 };
450 let endpoints = crate::anthropic::fetch::Endpoints::default();
451 let outcome = crate::anthropic::fetch_snapshot(
452 client,
453 &creds_target,
454 &cache,
455 &endpoints,
456 DEFAULT_TTL,
457 )
458 .await?;
459 Ok(outcome.map(crate::usage::VendorSnapshot::Anthropic))
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 label = tab.account.as_deref();
518 let cache = match label {
519 Some(label) => crate::cache::Cache::for_vendor_account("openai", label)?,
520 None => crate::cache::Cache::for_vendor("openai")?,
521 };
522 let creds_path = config.openai.resolve_auth_path(label)?;
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::Copilot => {
530 let token = config.copilot.resolve_token()?;
531 let cache = crate::cache::Cache::for_vendor("copilot")?;
532 let endpoints = crate::copilot::fetch::Endpoints::default();
533 let outcome =
534 crate::copilot::fetch_snapshot(client, &token, &cache, &endpoints, DEFAULT_TTL)
535 .await?;
536 Ok(outcome.into())
537 }
538 VendorId::Deepseek => {
539 let api_key = crate::config::resolve_api_key(
540 "DeepSeek",
541 &config.deepseek.api_key_env,
542 config.deepseek.api_key.as_deref(),
543 )?;
544 let cache = crate::cache::Cache::for_vendor("deepseek")?;
545 let endpoints = crate::deepseek::fetch::Endpoints::default();
546 let outcome =
547 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
548 .await?;
549 Ok(outcome.into())
550 }
551 VendorId::Kimi => {
552 let (auth, endpoints) = crate::kimi::resolve_auth(&config.kimi)?;
553 let cache = crate::cache::Cache::for_vendor("kimi")?;
554 let outcome = crate::kimi::fetch::fetch_snapshot_with_auth(
555 client,
556 &auth,
557 &cache,
558 &endpoints,
559 DEFAULT_TTL,
560 )
561 .await?;
562 Ok(outcome.into())
563 }
564 VendorId::Kilo => {
565 let api_key = crate::config::resolve_api_key(
566 "Kilo",
567 &config.kilo.api_key_env,
568 config.kilo.api_key.as_deref(),
569 )?;
570 let cache = crate::cache::Cache::for_vendor("kilo")?;
571 let endpoints = crate::kilo::fetch::Endpoints::default();
572 let outcome = crate::kilo::fetch_snapshot(
573 client,
574 &api_key,
575 &cache,
576 &endpoints,
577 DEFAULT_TTL,
578 config.kilo.organization_id.as_deref(),
579 )
580 .await?;
581 Ok(outcome.into())
582 }
583 VendorId::Novita => {
584 let api_key = crate::config::resolve_api_key(
585 "Novita",
586 &config.novita.api_key_env,
587 config.novita.api_key.as_deref(),
588 )?;
589 let cache = crate::cache::Cache::for_vendor("novita")?;
590 let endpoints = crate::novita::fetch::Endpoints::default();
591 let outcome =
592 crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
593 .await?;
594 Ok(outcome.into())
595 }
596 VendorId::Moonshot => {
597 let api_key = crate::config::resolve_api_key(
598 "Moonshot",
599 &config.moonshot.api_key_env,
600 config.moonshot.api_key.as_deref(),
601 )?;
602 let cache = crate::cache::Cache::for_vendor("moonshot")?;
603 let (endpoints, currency) =
604 crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
605 let outcome = crate::moonshot::fetch_snapshot(
606 client,
607 &api_key,
608 &cache,
609 &endpoints,
610 DEFAULT_TTL,
611 currency,
612 )
613 .await?;
614 Ok(outcome.into())
615 }
616 VendorId::Grok => {
617 let key = crate::config::resolve_api_key(
618 "Grok",
619 &config.grok.api_key_env,
620 config.grok.api_key.as_deref(),
621 )?;
622 let cache = crate::cache::Cache::for_vendor("grok")?;
623 let endpoints = crate::grok::fetch::Endpoints::default();
624 let outcome = crate::grok::fetch_snapshot(
625 client,
626 &key,
627 &cache,
628 &endpoints,
629 DEFAULT_TTL,
630 config.grok.team_id.as_deref(),
631 )
632 .await?;
633 Ok(outcome.into())
634 }
635 VendorId::Supergrok => {
636 let cache = crate::cache::Cache::for_vendor("supergrok")?;
637 let scope_paths = crate::supergrok::scope::ScopePaths::with_overrides(
638 config.supergrok.auth_path.as_deref(),
639 config.supergrok.config_path.as_deref(),
640 )?;
641 let outcome = crate::supergrok::fetch_snapshot(
642 &config.supergrok.grok_binary,
643 &scope_paths,
644 &cache,
645 DEFAULT_TTL,
646 )
647 .await?;
648 Ok(outcome.into())
649 }
650 VendorId::Antigravity => {
651 let cache = crate::cache::Cache::for_vendor("antigravity")?;
653 let outcome = crate::antigravity::fetch_snapshot(client, &cache, DEFAULT_TTL).await?;
654 Ok(outcome.into())
655 }
656 VendorId::Minimax => {
657 let api_key = crate::config::resolve_api_key(
658 "MiniMax",
659 &config.minimax.api_key_env,
660 config.minimax.api_key.as_deref(),
661 )?;
662 let cache = crate::cache::Cache::for_vendor("minimax")?;
663 let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
664 let outcome =
665 crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
666 .await?;
667 Ok(outcome.into())
668 }
669 VendorId::Cursor => {
670 let cache = crate::cache::Cache::for_vendor("cursor")?;
671 let db_path = config
672 .cursor
673 .db_path
674 .clone()
675 .map(Ok)
676 .unwrap_or_else(crate::cursor::db::default_db_path)?;
677 let agent_auth_path = config
678 .cursor
679 .agent_auth_path
680 .clone()
681 .map(Ok)
682 .unwrap_or_else(crate::cursor::db::default_agent_auth_path)?;
683 let endpoints = crate::cursor::fetch::Endpoints::default();
684 let outcome = crate::cursor::fetch_snapshot(
685 client,
686 &db_path,
687 &agent_auth_path,
688 &cache,
689 &endpoints,
690 DEFAULT_TTL,
691 )
692 .await?;
693 Ok(outcome.into())
694 }
695 VendorId::Kiro => {
696 let cache = crate::cache::Cache::for_vendor("kiro")?;
697 let db_path = config
698 .kiro
699 .db_path
700 .clone()
701 .map(Ok)
702 .unwrap_or_else(crate::kiro::db::default_db_path)?;
703 let outcome =
704 crate::kiro::fetch_snapshot(client, &db_path, &cache, DEFAULT_TTL).await?;
705 Ok(outcome.into())
706 }
707 VendorId::NousResearch => {
708 let store = crate::nous::credentials::CredentialStore::default();
709 let endpoints = crate::nous::fetch::Endpoints::default();
710 let account = crate::nous::fetch::fetch_account_with_refresh(
711 client,
712 &store,
713 &endpoints,
714 Utc::now(),
715 )
716 .await?;
717 Ok(crate::outcome::Outcome::fresh(
719 crate::usage::VendorSnapshot::NousResearch(account),
720 ))
721 }
722 VendorId::OpenCodeGo => {
723 let api_key = crate::config::resolve_api_key(
724 "OpenCode Go",
725 &config.opencode_go.api_key_env,
726 config.opencode_go.api_key.as_deref(),
727 )?;
728 let cache = crate::cache::Cache::for_vendor("opencode-go")?;
729 let endpoints = crate::opencode_go::fetch::Endpoints::default();
730 let outcome = crate::opencode_go::fetch::fetch_snapshot(
731 client,
732 &api_key,
733 &cache,
734 &endpoints,
735 DEFAULT_TTL,
736 )
737 .await?;
738 Ok(outcome.into())
739 }
740 VendorId::CommandCode => {
741 let credential =
742 crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref())?;
743 let cache = crate::cache::Cache::for_vendor("commandcode")?;
744 let endpoints = crate::commandcode::fetch::Endpoints::default();
745 let outcome = crate::commandcode::fetch::fetch_snapshot(
746 client,
747 &credential.token,
748 &cache,
749 &endpoints,
750 DEFAULT_TTL,
751 )
752 .await?;
753 Ok(outcome.into())
754 }
755 }
756}
757
758pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
761
762pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
769
770pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
776 let mut anthropic_seen: u32 = 0;
777 tabs.iter()
778 .map(|tab| {
779 if tab.vendor == VendorId::Anthropic {
780 let delay = step * anthropic_seen;
781 anthropic_seen += 1;
782 delay
783 } else {
784 Duration::ZERO
785 }
786 })
787 .collect()
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793 use chrono::TimeZone;
794
795 #[test]
799 fn refresh_stagger_spaces_out_anthropic_tabs_only() {
800 let step = Duration::from_millis(800);
801 let tabs = vec![
802 TabId::vendor(VendorId::Anthropic), TabId::account("work"),
804 TabId::account("personal"),
805 TabId::vendor(VendorId::Openai),
806 TabId::vendor(VendorId::Zai),
807 ];
808 let delays = refresh_stagger(&tabs, step);
809 assert_eq!(
810 delays,
811 vec![
812 Duration::ZERO, step, step * 2, Duration::ZERO, Duration::ZERO, ]
818 );
819 }
820
821 #[test]
822 fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
823 let tabs = vec![
825 TabId::vendor(VendorId::Anthropic),
826 TabId::vendor(VendorId::Openrouter),
827 ];
828 assert!(
829 refresh_stagger(&tabs, Duration::from_millis(800))
830 .iter()
831 .all(|d| d.is_zero())
832 );
833 }
834
835 #[test]
836 fn select_primary_moves_to_enabled_vendor() {
837 let mut app = App::with_theme(
838 vec![
839 TabId::vendor(VendorId::Anthropic),
840 TabId::vendor(VendorId::Openrouter),
841 ],
842 Theme::default(),
843 );
844 app.select_primary(Some(VendorId::Openrouter));
845 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
846 }
847
848 #[test]
849 fn select_primary_ignores_disabled_vendor() {
850 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
851 app.select_primary(Some(VendorId::Openai));
852 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
853 }
854
855 #[test]
856 fn nav_ring_wraps_through_the_overview_at_both_ends() {
857 let mut app = App::with_theme(
858 vec![
859 TabId::vendor(VendorId::Anthropic),
860 TabId::vendor(VendorId::Openai),
861 ],
862 Theme::default(),
863 );
864 app.overview = true;
865
866 app.next_tab(); assert!(!app.overview);
868 assert_eq!(app.active, 0);
869 app.next_tab();
870 assert_eq!(app.active, 1);
871 app.next_tab(); assert!(app.overview);
873
874 app.prev_tab(); assert!(!app.overview);
876 assert_eq!(app.active, 1);
877 app.prev_tab();
878 assert_eq!(app.active, 0);
879 app.prev_tab(); assert!(app.overview);
881 }
882
883 #[test]
884 fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
885 let mut app = App::with_theme(
886 vec![
887 TabId::vendor(VendorId::Anthropic),
888 TabId::vendor(VendorId::Openai),
889 TabId::vendor(VendorId::Zai),
890 ],
891 Theme::default(),
892 );
893 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
894
895 app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
897 assert_eq!(app.overview_tabs(), vec![2, 0]);
898
899 app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
901 assert_eq!(app.overview_tabs(), vec![1]);
902 }
903
904 fn config_with_accounts(labels: &[&str]) -> Config {
905 let mut config = Config::default();
906 config.openai.enabled = false;
909 config.zai.enabled = false;
910 config.openrouter.enabled = false;
911 config.anthropic.accounts = labels
912 .iter()
913 .map(|l| crate::config::AnthropicAccount {
914 label: (*l).to_string(),
915 credentials_path: format!("/creds/{l}.json").into(),
916 })
917 .collect();
918 config
919 }
920
921 #[test]
922 fn show_default_account_false_hides_the_unnamed_claude_tab() {
923 let mut config = config_with_accounts(&["work", "personal"]);
926 config.anthropic.show_default_account = false;
927 assert_eq!(
928 tabs_from_config(&config),
929 vec![TabId::account("work"), TabId::account("personal")]
930 );
931
932 let mut empty = Config::default();
935 empty.openai.enabled = false;
936 empty.zai.enabled = false;
937 empty.openrouter.enabled = false;
938 empty.anthropic.show_default_account = false;
939 assert_eq!(
940 tabs_from_config(&empty),
941 vec![TabId::vendor(VendorId::Anthropic)]
942 );
943 }
944
945 #[test]
946 fn tabs_expand_anthropic_accounts_after_default() {
947 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
949 assert_eq!(
950 tabs,
951 vec![
952 TabId::vendor(VendorId::Anthropic),
953 TabId::account("work"),
954 TabId::account("personal"),
955 ]
956 );
957 }
958
959 #[test]
960 fn tabs_without_accounts_are_just_enabled_vendors() {
961 let config = Config::default();
963 let tabs = tabs_from_config(&config);
964 let vendors: Vec<VendorId> = tabs.iter().map(|t| t.vendor).collect();
965 assert_eq!(vendors, config.enabled_vendors());
966 assert!(tabs.iter().all(|t| t.account.is_none()));
967 }
968
969 #[test]
970 fn tabs_expand_openrouter_accounts_without_changing_other_vendors() {
971 let mut config = Config::default();
972 config.anthropic.enabled = false;
973 config.openai.enabled = false;
974 config.zai.enabled = false;
975 config.openrouter.accounts = vec![
976 crate::config::OpenRouterAccount {
977 label: "work".into(),
978 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
979 api_key: None,
980 },
981 crate::config::OpenRouterAccount {
982 label: "personal".into(),
983 api_key_env: None,
984 api_key: Some("personal-key".into()),
985 },
986 ];
987 assert_eq!(
988 tabs_from_config(&config),
989 vec![
990 TabId::vendor(VendorId::Openrouter),
991 TabId::account_for(VendorId::Openrouter, "work"),
992 TabId::account_for(VendorId::Openrouter, "personal"),
993 ]
994 );
995 }
996
997 #[test]
998 fn openai_named_accounts_get_their_own_tabs_after_the_default() {
999 let mut config = Config::default();
1000 config.anthropic.enabled = false;
1001 config.zai.enabled = false;
1002 config.openrouter.enabled = false;
1003 config.openai.accounts.push(crate::config::OpenAiAccount {
1004 label: "work".into(),
1005 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1006 });
1007 assert_eq!(
1008 tabs_from_config(&config),
1009 vec![
1010 TabId::vendor(VendorId::Openai),
1011 TabId::account_for(VendorId::Openai, "work"),
1012 ]
1013 );
1014 }
1015
1016 #[test]
1017 fn openrouter_can_hide_default_only_when_named_accounts_exist() {
1018 let mut config = Config::default();
1019 config.anthropic.enabled = false;
1020 config.openai.enabled = false;
1021 config.zai.enabled = false;
1022 config.openrouter.show_default_account = false;
1023 assert_eq!(
1024 tabs_from_config(&config),
1025 vec![TabId::vendor(VendorId::Openrouter)]
1026 );
1027
1028 config
1029 .openrouter
1030 .accounts
1031 .push(crate::config::OpenRouterAccount {
1032 label: "work".into(),
1033 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1034 api_key: None,
1035 });
1036 assert_eq!(
1037 tabs_from_config(&config),
1038 vec![TabId::account_for(VendorId::Openrouter, "work")]
1039 );
1040 }
1041
1042 #[test]
1043 fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
1044 let td = tempfile::tempdir().unwrap();
1047 for label in ["work", "personal"] {
1048 let dir = td.path().join(label);
1049 std::fs::create_dir_all(&dir).unwrap();
1050 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1051 }
1052 let mut config = Config::default();
1053 config.openai.enabled = false;
1054 config.zai.enabled = false;
1055 config.openrouter.enabled = false;
1056 config.anthropic.accounts_dir = Some(td.path().to_path_buf());
1057
1058 let tabs = tabs_from_config(&config);
1059 assert_eq!(
1060 tabs,
1061 vec![
1062 TabId::vendor(VendorId::Anthropic),
1063 TabId::account("personal"), TabId::account("work"),
1065 ]
1066 );
1067 }
1068
1069 #[test]
1070 fn desktop_labels_become_account_tabs_after_cli_accounts() {
1071 let config = config_with_accounts(&["work"]);
1073 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1074 assert_eq!(
1075 tabs,
1076 vec![
1077 TabId::vendor(VendorId::Anthropic),
1078 TabId::account("work"),
1079 TabId::desktop_account("gmail"),
1080 TabId::desktop_account("hotmail"),
1081 ]
1082 );
1083 }
1084
1085 #[test]
1086 fn a_desktop_profile_wins_a_label_collision_with_a_cli_account() {
1087 let config = config_with_accounts(&["gmail", "work"]);
1092 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1093 assert_eq!(
1094 tabs,
1095 vec![
1096 TabId::vendor(VendorId::Anthropic),
1097 TabId::account("work"),
1098 TabId::desktop_account("gmail"),
1099 TabId::desktop_account("hotmail"),
1100 ]
1101 );
1102 }
1103
1104 #[test]
1105 fn desktop_accounts_suppress_the_default_tab_like_named_ones() {
1106 let mut config = config_with_accounts(&[]);
1109 config.cursor.enabled = false;
1110 config.anthropic.show_default_account = false;
1111
1112 assert_eq!(
1115 build_tabs(&config, &[]),
1116 vec![TabId::vendor(VendorId::Anthropic)]
1117 );
1118 assert_eq!(
1120 build_tabs(&config, &["gmail".into()]),
1121 vec![TabId::desktop_account("gmail")]
1122 );
1123 }
1124
1125 #[test]
1126 fn set_tabs_resets_states_and_clamps_selection() {
1127 let mut app = App::with_theme(
1131 tabs_from_config(&config_with_accounts(&["work", "personal"])),
1132 Theme::default(),
1133 );
1134 app.active = 2; app.tabs[0] = TabState::Error("old".into());
1136 let old_tab = app.tabs_meta[0].clone();
1137 assert!(app.begin_refresh(&old_tab));
1138
1139 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
1140 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
1141 assert_eq!(app.active, 0, "selection clamped after shrink");
1142 assert!(matches!(app.tabs[0], TabState::Loading));
1143 assert!(!app.is_refreshing(&old_tab));
1144 }
1145
1146 #[test]
1147 fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
1148 let mut app = App::with_theme(
1149 vec![
1150 TabId::vendor(VendorId::Anthropic),
1151 TabId::vendor(VendorId::Openai),
1152 ],
1153 Theme::default(),
1154 );
1155 app.active = 1;
1156
1157 app.set_tabs(vec![
1158 TabId::vendor(VendorId::Anthropic),
1159 TabId::account("work"),
1160 TabId::vendor(VendorId::Openai),
1161 ]);
1162
1163 assert_eq!(app.active, 2);
1164 assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
1165 }
1166
1167 #[test]
1168 fn refresh_from_old_generation_is_discarded() {
1169 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1170 let old_generation = app.tab_generation;
1171 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
1172
1173 assert!(!app.apply_refresh(
1174 old_generation,
1175 &TabId::vendor(VendorId::Anthropic),
1176 TabState::Error("old result".into()),
1177 ));
1178 assert!(matches!(app.tabs[0], TabState::Loading));
1179 }
1180
1181 #[test]
1182 fn refresh_identity_mismatch_is_discarded() {
1183 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1184 let generation = app.tab_generation;
1185
1186 assert!(!app.apply_refresh(
1187 generation,
1188 &TabId::vendor(VendorId::Openai),
1189 TabState::Error("wrong tab".into()),
1190 ));
1191 assert!(matches!(app.tabs[0], TabState::Loading));
1192 }
1193
1194 #[test]
1195 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
1196 let anthropic = TabId::vendor(VendorId::Anthropic);
1197 let openai = TabId::vendor(VendorId::Openai);
1198 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
1199 let generation = app.tab_generation;
1200 assert!(app.begin_refresh(&anthropic));
1201
1202 app.tabs_meta.swap(0, 1);
1205 app.tabs.swap(0, 1);
1206 assert!(app.apply_refresh(generation, &anthropic, TabState::Error("ready".into())));
1207 assert!(matches!(app.tabs[0], TabState::Loading));
1208 assert!(matches!(&app.tabs[1], TabState::Error(message) if message == "ready"));
1209 assert!(!app.is_refreshing(&anthropic));
1210 }
1211
1212 fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
1213 TabState::Ready(Box::new(ReadyTab {
1214 snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
1215 label: "test".into(),
1216 total_credits: 0.0,
1217 total_usage: 0.0,
1218 usage_daily: 0.0,
1219 usage_weekly: 0.0,
1220 usage_monthly: 0.0,
1221 is_free_tier: false,
1222 limit: None,
1223 limit_remaining: None,
1224 }),
1225 stale: false,
1226 last_error: None,
1227 fetched_at: Some(fetched_at),
1228 }))
1229 }
1230
1231 #[test]
1232 fn refresh_keeps_ready_snapshot_visible_and_suppresses_duplicates() {
1233 let tab = TabId::vendor(VendorId::Openrouter);
1234 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1235 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1236 app.tabs[0] = ready_at(fetched_at);
1237
1238 assert!(app.begin_refresh(&tab));
1239 assert!(
1240 !app.begin_refresh(&tab),
1241 "duplicate request must be suppressed"
1242 );
1243 assert!(app.is_refreshing(&tab));
1244 match &app.tabs[0] {
1245 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1246 other => panic!("ready snapshot disappeared during refresh: {other:?}"),
1247 }
1248 }
1249
1250 #[test]
1251 fn first_refresh_still_uses_loading_until_data_arrives() {
1252 let tab = TabId::vendor(VendorId::Openrouter);
1253 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1254
1255 assert!(app.begin_refresh(&tab));
1256 assert!(app.is_refreshing(&tab));
1257 assert!(matches!(app.tabs[0], TabState::Loading));
1258
1259 assert!(app.apply_refresh(
1260 app.tab_generation,
1261 &tab,
1262 TabState::Error("not signed in".into()),
1263 ));
1264 assert!(!app.is_refreshing(&tab));
1265 assert!(matches!(&app.tabs[0], TabState::Error(message) if message == "not signed in"));
1266 }
1267
1268 #[test]
1269 fn successful_revalidation_replaces_snapshot_and_clears_indicator() {
1270 let tab = TabId::vendor(VendorId::Openrouter);
1271 let old_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1272 let new_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 1, 0).unwrap();
1273 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1274 app.tabs[0] = ready_at(old_at);
1275
1276 assert!(app.begin_refresh(&tab));
1277 assert!(app.apply_refresh(app.tab_generation, &tab, ready_at(new_at)));
1278 assert!(!app.is_refreshing(&tab));
1279 match &app.tabs[0] {
1280 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(new_at)),
1281 other => panic!("expected replacement snapshot, got {other:?}"),
1282 }
1283 }
1284
1285 #[test]
1286 fn failed_revalidation_preserves_snapshot_with_visible_warning() {
1287 let tab = TabId::vendor(VendorId::Openrouter);
1288 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1289 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1290 app.tabs[0] = ready_at(fetched_at);
1291
1292 assert!(app.begin_refresh(&tab));
1293 assert!(app.apply_refresh(
1294 app.tab_generation,
1295 &tab,
1296 TabState::Error("refresh failed".into()),
1297 ));
1298 assert!(!app.is_refreshing(&tab));
1299 match &app.tabs[0] {
1300 TabState::Ready(ready) => {
1301 assert_eq!(ready.fetched_at, Some(fetched_at));
1302 assert!(ready.stale);
1303 assert_eq!(ready.last_error, Some((0, "refresh failed".into())));
1304 }
1305 other => panic!("last successful snapshot was lost: {other:?}"),
1306 }
1307 let sections = crate::tui::panels::sections_for(&app.tabs[0], Utc::now(), 5);
1308 assert!(sections.iter().any(|section| matches!(
1309 section,
1310 crate::tui::panels::Section::Text { label, value }
1311 if label == "Warning" && value == "refresh failed"
1312 )));
1313 }
1314
1315 #[test]
1316 fn old_generation_result_does_not_clear_current_refresh() {
1317 let tab = TabId::vendor(VendorId::Openrouter);
1318 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1319 let old_generation = app.tab_generation;
1320 app.set_tabs(vec![tab.clone()]);
1321 assert!(app.begin_refresh(&tab));
1322
1323 assert!(!app.apply_refresh(old_generation, &tab, TabState::Error("old result".into()),));
1324 assert!(app.is_refreshing(&tab));
1325 assert!(matches!(app.tabs[0], TabState::Loading));
1326 }
1327
1328 #[test]
1329 fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
1330 let anthropic = TabId::vendor(VendorId::Anthropic);
1336 let openai = TabId::vendor(VendorId::Openai);
1337 let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
1338 let generation = app.tab_generation;
1339 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1340
1341 assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
1342 match &app.tabs[0] {
1343 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1344 other => panic!("expected Anthropic tab Ready, got {other:?}"),
1345 }
1346 assert!(matches!(app.tabs[1], TabState::Loading));
1347 }
1348
1349 #[test]
1350 fn select_primary_lands_on_default_account_tab() {
1351 let app = {
1354 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
1355 let mut a = App::with_theme(tabs, Theme::default());
1356 a.select_primary(Some(VendorId::Anthropic));
1357 a
1358 };
1359 assert_eq!(app.active, 0);
1360 assert_eq!(
1361 app.active_tab_id(),
1362 Some(&TabId::vendor(VendorId::Anthropic))
1363 );
1364 }
1365}