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, CustomProviderConfig};
11use crate::error::{AppError, 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 {
25 message: String,
26 plan: Option<String>,
30 },
31}
32
33#[derive(Debug, Clone)]
34pub struct ReadyTab {
35 pub snapshot: crate::usage::VendorSnapshot,
36 pub stale: bool,
37 pub last_error: Option<(u16, String)>,
38 pub fetched_at: Option<chrono::DateTime<chrono::Utc>>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Hash)]
49pub enum TabSource {
50 Builtin(VendorId),
51 Custom {
52 id: String,
53 name: String,
54 short_name: String,
55 },
56}
57
58impl TabState {
59 pub fn error(message: impl AsRef<str>) -> Self {
60 Self::error_with_plan(message, None)
61 }
62
63 pub fn error_with_plan(message: impl AsRef<str>, plan: Option<String>) -> Self {
64 let plan = plan.and_then(|plan| {
65 let cleaned = crate::display::sanitize_untrusted_field(plan.trim());
66 if cleaned.is_empty() || cleaned.eq_ignore_ascii_case("unknown") {
67 None
68 } else {
69 Some(cleaned)
70 }
71 });
72 Self::Error {
73 message: message.as_ref().to_string(),
74 plan,
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Hash)]
86pub struct TabId {
87 pub source: TabSource,
88 pub account: Option<String>,
89 pub desktop: bool,
90}
91
92impl TabId {
93 pub fn vendor(vendor: VendorId) -> Self {
95 Self {
96 source: TabSource::Builtin(vendor),
97 account: None,
98 desktop: false,
99 }
100 }
101
102 pub fn account(label: impl Into<String>) -> Self {
104 Self::account_for(VendorId::Anthropic, label)
105 }
106
107 pub fn account_for(vendor: VendorId, label: impl Into<String>) -> Self {
109 Self {
110 source: TabSource::Builtin(vendor),
111 account: Some(label.into()),
112 desktop: false,
113 }
114 }
115
116 pub fn desktop_account(label: impl Into<String>) -> Self {
119 Self {
120 source: TabSource::Builtin(VendorId::Anthropic),
121 account: Some(label.into()),
122 desktop: true,
123 }
124 }
125
126 pub fn custom(spec: &CustomProviderConfig) -> Self {
128 Self {
129 source: TabSource::Custom {
130 id: spec.id.clone(),
131 name: spec.name.clone(),
132 short_name: spec.short_name.clone(),
133 },
134 account: None,
135 desktop: false,
136 }
137 }
138
139 pub fn vendor_id(&self) -> Option<VendorId> {
142 match &self.source {
143 TabSource::Builtin(vendor) => Some(*vendor),
144 TabSource::Custom { .. } => None,
145 }
146 }
147}
148
149pub fn tabs_from_config(config: &Config) -> Vec<TabId> {
158 build_tabs(config, &[])
159}
160
161pub fn tabs_with_desktop(config: &Config) -> Vec<TabId> {
165 build_tabs(config, &desktop_profile_labels(config))
166}
167
168fn build_tabs(config: &Config, desktop_labels: &[String]) -> Vec<TabId> {
181 let desktop_set: HashSet<&str> = desktop_labels.iter().map(String::as_str).collect();
182 let mut tabs = Vec::new();
183 for vendor in config.enabled_vendors() {
184 if vendor == VendorId::Anthropic {
185 let accounts: Vec<_> = config
186 .anthropic
187 .all_accounts()
188 .into_iter()
189 .filter(|a| !desktop_set.contains(a.label.as_str()))
190 .collect();
191 if config.anthropic.show_default_account
195 || (accounts.is_empty() && desktop_labels.is_empty())
196 {
197 tabs.push(TabId::vendor(vendor));
198 }
199 for acct in accounts {
200 tabs.push(TabId::account(acct.label));
201 }
202 for label in desktop_labels {
203 tabs.push(TabId::desktop_account(label.clone()));
204 }
205 } else if vendor == VendorId::Openrouter {
206 if config.openrouter.show_default_account || config.openrouter.accounts.is_empty() {
207 tabs.push(TabId::vendor(vendor));
208 }
209 for account in &config.openrouter.accounts {
210 tabs.push(TabId::account_for(vendor, account.label.clone()));
211 }
212 } else if vendor == VendorId::Openai {
213 tabs.push(TabId::vendor(vendor));
214 for account in &config.openai.accounts {
215 tabs.push(TabId::account_for(vendor, account.label.clone()));
216 }
217 } else {
218 tabs.push(TabId::vendor(vendor));
219 }
220 }
221 for spec in config.enabled_custom() {
223 tabs.push(TabId::custom(spec));
224 }
225 tabs
226}
227
228#[cfg(target_os = "macos")]
232fn desktop_profile_labels(config: &Config) -> Vec<String> {
233 let Ok(paths) = crate::claude_desktop::Paths::resolve(&config.anthropic) else {
234 return Vec::new();
235 };
236 if !paths.available() {
237 return Vec::new();
238 }
239 crate::claude_desktop::load_profiles(&paths.profiles_dir)
240 .into_iter()
241 .filter(|p| p.has_credentials)
242 .map(|p| p.label)
243 .collect()
244}
245
246#[cfg(not(target_os = "macos"))]
247fn desktop_profile_labels(_config: &Config) -> Vec<String> {
248 Vec::new()
249}
250
251#[derive(Debug)]
252pub struct App {
253 pub tabs_meta: Vec<TabId>,
254 pub active: usize,
255 pub tabs: Vec<TabState>,
256 refreshing_tabs: HashSet<TabId>,
259 pub tab_generation: u64,
263 pub overview: bool,
266 pub overview_vendors: Option<Vec<VendorId>>,
268 pub theme: Theme,
269 pub quit: bool,
270 pub settings: Option<crate::tui::settings::SettingsState>,
272 pub context_enabled: bool,
275 pub context_generation: u64,
278 pub context: Option<crate::tui::context::ContextState>,
280 pub vendor_box: crate::config::VendorBoxStyle,
282}
283
284impl App {
285 pub fn new(tabs_meta: Vec<TabId>) -> Self {
286 Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
289 }
290
291 pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
297 let n = tabs_meta.len();
298 Self {
299 tabs_meta,
300 active: 0,
301 tabs: vec![TabState::Loading; n],
302 refreshing_tabs: HashSet::new(),
303 tab_generation: 0,
304 overview: false,
305 overview_vendors: None,
306 theme,
307 quit: false,
308 settings: None,
309 context_enabled: false,
310 context_generation: 0,
311 context: None,
312 vendor_box: crate::config::VendorBoxStyle::Sidebar,
313 }
314 }
315
316 pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
320 let mut app = Self::new(tabs_meta);
321 if primary.is_some() {
324 app.select_primary(primary);
325 } else {
326 app.overview = true;
327 }
328 app
329 }
330
331 pub fn active_tab_id(&self) -> Option<&TabId> {
332 self.tabs_meta.get(self.active)
333 }
334
335 pub fn active_vendor(&self) -> Option<VendorId> {
336 self.tabs_meta.get(self.active).and_then(TabId::vendor_id)
337 }
338
339 pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
346 let selected = self.active_tab_id().cloned();
347 let fallback = self.active.min(tabs_meta.len().saturating_sub(1));
348 self.tab_generation = self.tab_generation.wrapping_add(1);
349 self.active = selected
350 .as_ref()
351 .and_then(|tab| tabs_meta.iter().position(|candidate| candidate == tab))
352 .unwrap_or(fallback);
353 self.tabs = vec![TabState::Loading; tabs_meta.len()];
354 self.tabs_meta = tabs_meta;
355 self.refreshing_tabs.clear();
356 }
357
358 pub fn begin_refresh(&mut self, tab: &TabId) -> bool {
362 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
363 return false;
364 };
365 if !self.refreshing_tabs.insert(tab.clone()) {
366 return false;
367 }
368 if !matches!(self.tabs[index], TabState::Ready(_)) {
369 self.tabs[index] = TabState::Loading;
370 }
371 true
372 }
373
374 pub fn is_refreshing(&self, tab: &TabId) -> bool {
375 self.refreshing_tabs.contains(tab)
376 }
377
378 pub fn tab_is_refreshing(&self, index: usize) -> bool {
379 self.tabs_meta
380 .get(index)
381 .is_some_and(|tab| self.is_refreshing(tab))
382 }
383
384 pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
389 if generation != self.tab_generation {
390 return false;
391 }
392 let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
393 return false;
394 };
395 let was_refreshing = self.refreshing_tabs.remove(tab);
396 if was_refreshing
400 && let TabState::Ready(ready) = &mut self.tabs[index]
401 && let TabState::Error { message, .. } = state
402 {
403 ready.stale = true;
404 ready.last_error = Some((0, message));
405 } else {
406 self.tabs[index] = state;
407 }
408 true
409 }
410
411 pub fn select_primary(&mut self, primary: Option<VendorId>) {
414 if let Some(p) = primary
415 && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor_id() == Some(p))
416 {
417 self.active = idx;
418 self.overview = false;
419 }
420 }
421
422 pub fn next_tab(&mut self) {
425 if self.overview {
426 if !self.tabs_meta.is_empty() {
427 self.overview = false;
428 self.active = 0;
429 }
430 } else if self.active + 1 < self.tabs_meta.len() {
431 self.active += 1;
432 } else {
433 self.overview = true;
434 }
435 }
436
437 pub fn prev_tab(&mut self) {
438 if self.overview {
439 if !self.tabs_meta.is_empty() {
440 self.overview = false;
441 self.active = self.tabs_meta.len() - 1;
442 }
443 } else if self.active > 0 {
444 self.active -= 1;
445 } else {
446 self.overview = true;
447 }
448 }
449
450 pub fn overview_tabs(&self) -> Vec<usize> {
455 match &self.overview_vendors {
456 None => (0..self.tabs_meta.len()).collect(),
457 Some(wanted) => wanted
458 .iter()
459 .flat_map(|v| {
460 self.tabs_meta
461 .iter()
462 .enumerate()
463 .filter(move |(_, t)| t.vendor_id() == Some(*v))
464 .map(|(i, _)| i)
465 })
466 .collect(),
467 }
468 }
469}
470
471pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
473 match build_outcome(client, config, tab).await {
474 Ok(outcome) => {
475 let now = Utc::now();
480 let fetched_at = outcome
481 .cache_age
482 .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
483 TabState::Ready(Box::new(ReadyTab {
484 snapshot: outcome.snapshot,
485 stale: outcome.stale,
486 last_error: outcome.last_error.map(|(code, message)| {
487 (code, crate::display::sanitize_untrusted_field(&message))
488 }),
489 fetched_at,
490 }))
491 }
492 Err(e) => TabState::error_with_plan(
493 crate::display::sanitize_untrusted_field(&e.user_message()),
494 e.plan().map(str::to_string),
495 ),
496 }
497}
498
499async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
500 let vendor = match &tab.source {
501 TabSource::Builtin(vendor) => *vendor,
502 TabSource::Custom { id, .. } => {
503 let spec = config.custom_by_id(id).ok_or_else(|| {
506 AppError::Other(format!("custom provider {id} is not configured"))
507 })?;
508 let api_key = spec.resolve_api_key()?;
509 let cache = crate::cache::Cache::for_vendor_account("custom", id)?;
510 let outcome =
511 crate::custom::fetch_snapshot(client, spec, &api_key, &cache, spec.cache_ttl())
512 .await?;
513 return Ok(outcome.into());
514 }
515 };
516 match vendor {
517 VendorId::Anthropic => {
518 let (creds_target, cache) = match tab.account.as_deref() {
524 Some(label) if tab.desktop => {
525 crate::anthropic::desktop_creds::account_target(config, label)?
526 }
527 Some(label) => config.anthropic.account_target(label)?,
528 None => {
529 let target = match config.anthropic.credentials_path.clone() {
530 Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
531 None => crate::anthropic::creds::CredsTarget::Default(
532 crate::anthropic::creds::default_path().unwrap_or_default(),
533 ),
534 };
535 (target, crate::cache::Cache::for_vendor("anthropic")?)
536 }
537 };
538 let endpoints = crate::anthropic::fetch::Endpoints::default();
539 let outcome = crate::anthropic::fetch_snapshot(
540 client,
541 &creds_target,
542 &cache,
543 &endpoints,
544 DEFAULT_TTL,
545 )
546 .await?;
547 Ok(outcome.map(crate::usage::VendorSnapshot::Anthropic))
548 }
549 VendorId::AnthropicApi => {
550 let key = crate::config::resolve_api_key(
551 "Anthropic_API",
552 &config.anthropic_api.api_key_env,
553 config.anthropic_api.api_key.as_deref(),
554 )?;
555 let cache = crate::cache::Cache::for_vendor("anthropic_api")?;
556 let endpoints = crate::anthropic_api::fetch::Endpoints::default();
557 let outcome = crate::anthropic_api::fetch_snapshot(
558 client,
559 &key,
560 &cache,
561 &endpoints,
562 DEFAULT_TTL,
563 config.anthropic_api.monthly_limit,
564 )
565 .await?;
566 Ok(outcome.into())
567 }
568 VendorId::Openrouter => {
569 let api_key = config.openrouter.resolve_api_key(tab.account.as_deref())?;
570 let cache = match tab.account.as_deref() {
571 Some(label) => crate::cache::Cache::for_vendor_account("openrouter", label)?,
572 None => crate::cache::Cache::for_vendor("openrouter")?,
573 };
574 let endpoints = crate::openrouter::fetch::Endpoints::default();
575 let outcome = crate::openrouter::fetch_snapshot(
576 client,
577 &api_key,
578 &cache,
579 &endpoints,
580 DEFAULT_TTL,
581 )
582 .await?;
583 Ok(outcome.into())
584 }
585 VendorId::Zai => {
586 let api_key = crate::config::resolve_api_key(
587 "Zai",
588 &config.zai.api_key_env,
589 config.zai.api_key.as_deref(),
590 )?;
591 let cache = crate::cache::Cache::for_vendor("zai")?;
592 let endpoints = crate::zai::fetch::Endpoints::default();
593 let outcome = crate::zai::fetch_snapshot(
594 client,
595 &api_key,
596 &cache,
597 &endpoints,
598 DEFAULT_TTL,
599 config.zai.plan_tier.as_deref(),
600 )
601 .await?;
602 Ok(outcome.into())
603 }
604 VendorId::Openai => {
605 let label = tab.account.as_deref();
606 let cache = match label {
607 Some(label) => crate::cache::Cache::for_vendor_account("openai", label)?,
608 None => crate::cache::Cache::for_vendor("openai")?,
609 };
610 let creds_path = config.openai.resolve_auth_path(label)?;
611 let endpoints = crate::openai::fetch::Endpoints::default();
612 let outcome =
613 crate::openai::fetch_snapshot(client, &creds_path, &cache, &endpoints, DEFAULT_TTL)
614 .await?;
615 Ok(outcome.into())
616 }
617 VendorId::Copilot => {
618 let token = config.copilot.resolve_token()?;
619 let cache = crate::cache::Cache::for_vendor("copilot")?;
620 let endpoints = crate::copilot::fetch::Endpoints::default();
621 let outcome =
622 crate::copilot::fetch_snapshot(client, &token, &cache, &endpoints, DEFAULT_TTL)
623 .await?;
624 Ok(outcome.into())
625 }
626 VendorId::Deepseek => {
627 let api_key = crate::config::resolve_api_key(
628 "DeepSeek",
629 &config.deepseek.api_key_env,
630 config.deepseek.api_key.as_deref(),
631 )?;
632 let cache = crate::cache::Cache::for_vendor("deepseek")?;
633 let endpoints = crate::deepseek::fetch::Endpoints::default();
634 let outcome =
635 crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
636 .await?;
637 Ok(outcome.into())
638 }
639 VendorId::Kimi => {
640 let (auth, endpoints) = crate::kimi::resolve_auth(&config.kimi)?;
641 let cache = crate::cache::Cache::for_vendor("kimi")?;
642 let outcome = crate::kimi::fetch::fetch_snapshot_with_auth(
643 client,
644 &auth,
645 &cache,
646 &endpoints,
647 DEFAULT_TTL,
648 )
649 .await?;
650 Ok(outcome.into())
651 }
652 VendorId::Kilo => {
653 let api_key = crate::config::resolve_api_key(
654 "Kilo",
655 &config.kilo.api_key_env,
656 config.kilo.api_key.as_deref(),
657 )?;
658 let cache = crate::cache::Cache::for_vendor("kilo")?;
659 let endpoints = crate::kilo::fetch::Endpoints::default();
660 let outcome = crate::kilo::fetch_snapshot(
661 client,
662 &api_key,
663 &cache,
664 &endpoints,
665 DEFAULT_TTL,
666 config.kilo.organization_id.as_deref(),
667 )
668 .await?;
669 Ok(outcome.into())
670 }
671 VendorId::Novita => {
672 let api_key = crate::config::resolve_api_key(
673 "Novita",
674 &config.novita.api_key_env,
675 config.novita.api_key.as_deref(),
676 )?;
677 let cache = crate::cache::Cache::for_vendor("novita")?;
678 let endpoints = crate::novita::fetch::Endpoints::default();
679 let outcome =
680 crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
681 .await?;
682 Ok(outcome.into())
683 }
684 VendorId::Moonshot => {
685 let api_key = crate::config::resolve_api_key(
686 "Moonshot",
687 &config.moonshot.api_key_env,
688 config.moonshot.api_key.as_deref(),
689 )?;
690 let cache = crate::cache::Cache::for_vendor("moonshot")?;
691 let (endpoints, currency) =
692 crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
693 let outcome = crate::moonshot::fetch_snapshot(
694 client,
695 &api_key,
696 &cache,
697 &endpoints,
698 DEFAULT_TTL,
699 currency,
700 )
701 .await?;
702 Ok(outcome.into())
703 }
704 VendorId::Grok => {
705 let key = crate::config::resolve_api_key(
706 "Grok",
707 &config.grok.api_key_env,
708 config.grok.api_key.as_deref(),
709 )?;
710 let cache = crate::cache::Cache::for_vendor("grok")?;
711 let endpoints = crate::grok::fetch::Endpoints::default();
712 let outcome = crate::grok::fetch_snapshot(
713 client,
714 &key,
715 &cache,
716 &endpoints,
717 DEFAULT_TTL,
718 config.grok.team_id.as_deref(),
719 )
720 .await?;
721 Ok(outcome.into())
722 }
723 VendorId::Supergrok => {
724 let cache = crate::cache::Cache::for_vendor("supergrok")?;
725 let scope_paths = crate::supergrok::scope::ScopePaths::with_overrides(
726 config.supergrok.auth_path.as_deref(),
727 config.supergrok.config_path.as_deref(),
728 )?;
729 let outcome = crate::supergrok::fetch_snapshot(
730 &config.supergrok.grok_binary,
731 &scope_paths,
732 &cache,
733 DEFAULT_TTL,
734 )
735 .await?;
736 Ok(outcome.into())
737 }
738 VendorId::Antigravity => {
739 let cache = crate::cache::Cache::for_vendor("antigravity")?;
742 let oauth = crate::antigravity::cloud::OauthClient::from_config(
743 config.antigravity.oauth_client_id.as_deref(),
744 config.antigravity.oauth_client_secret.as_deref(),
745 );
746 let outcome =
747 crate::antigravity::fetch_snapshot(client, &cache, DEFAULT_TTL, oauth.as_ref())
748 .await?;
749 Ok(outcome.into())
750 }
751 VendorId::Grokbot => {
752 let creds = crate::grokbot::resolve_credentials(&config.grokbot)?;
755 let cache = crate::cache::Cache::for_vendor("grokbot")?;
756 let endpoints = crate::grokbot::fetch::Endpoints::default();
757 let outcome = crate::grokbot::fetch::fetch_snapshot_with(
758 client,
759 &creds,
760 &cache,
761 &endpoints,
762 DEFAULT_TTL,
763 )
764 .await?;
765 Ok(outcome.into())
766 }
767 VendorId::Minimax => {
768 let api_key = crate::config::resolve_api_key(
769 "MiniMax",
770 &config.minimax.api_key_env,
771 config.minimax.api_key.as_deref(),
772 )?;
773 let cache = crate::cache::Cache::for_vendor("minimax")?;
774 let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
775 let outcome =
776 crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
777 .await?;
778 Ok(outcome.into())
779 }
780 VendorId::Cursor => {
781 let cache = crate::cache::Cache::for_vendor("cursor")?;
782 let db_path = config
783 .cursor
784 .db_path
785 .clone()
786 .map(Ok)
787 .unwrap_or_else(crate::cursor::db::default_db_path)?;
788 let agent_auth_path = config
789 .cursor
790 .agent_auth_path
791 .clone()
792 .map(Ok)
793 .unwrap_or_else(crate::cursor::db::default_agent_auth_path)?;
794 let endpoints = crate::cursor::fetch::Endpoints::default();
795 let outcome = crate::cursor::fetch_snapshot(
796 client,
797 &db_path,
798 &agent_auth_path,
799 &cache,
800 &endpoints,
801 DEFAULT_TTL,
802 )
803 .await?;
804 Ok(outcome.into())
805 }
806 VendorId::Kiro => {
807 let cache = crate::cache::Cache::for_vendor("kiro")?;
808 let db_path = config
809 .kiro
810 .db_path
811 .clone()
812 .map(Ok)
813 .unwrap_or_else(crate::kiro::db::default_db_path)?;
814 let outcome =
815 crate::kiro::fetch_snapshot(client, &db_path, &cache, DEFAULT_TTL).await?;
816 Ok(outcome.into())
817 }
818 VendorId::NousResearch => {
819 let store = crate::nous::credentials::CredentialStore::default();
820 let endpoints = crate::nous::fetch::Endpoints::default();
821 let account = crate::nous::fetch::fetch_account_with_refresh(
822 client,
823 &store,
824 &endpoints,
825 Utc::now(),
826 )
827 .await?;
828 Ok(crate::outcome::Outcome::fresh(
830 crate::usage::VendorSnapshot::NousResearch(account),
831 ))
832 }
833 VendorId::OpenCodeGo => {
834 let api_key = crate::config::resolve_api_key(
835 "OpenCode Go",
836 &config.opencode_go.api_key_env,
837 config.opencode_go.api_key.as_deref(),
838 )?;
839 let cache = crate::cache::Cache::for_vendor("opencode-go")?;
840 let endpoints = crate::opencode_go::fetch::Endpoints::default();
841 let outcome = crate::opencode_go::fetch::fetch_snapshot(
842 client,
843 &api_key,
844 &cache,
845 &endpoints,
846 DEFAULT_TTL,
847 )
848 .await?;
849 Ok(outcome.into())
850 }
851 VendorId::CommandCode => {
852 let credential =
853 crate::commandcode::creds::resolve(config.commandcode.auth_paths.as_deref())?;
854 let cache = crate::cache::Cache::for_vendor("commandcode")?;
855 let endpoints = crate::commandcode::fetch::Endpoints::default();
856 let outcome = crate::commandcode::fetch::fetch_snapshot(
857 client,
858 &credential.token,
859 &cache,
860 &endpoints,
861 DEFAULT_TTL,
862 )
863 .await?;
864 Ok(outcome.into())
865 }
866 VendorId::Ollama => {
867 let api_key = crate::config::resolve_api_key(
868 "Ollama",
869 &config.ollama.api_key_env,
870 config.ollama.api_key.as_deref(),
871 )?;
872 let cache = crate::cache::Cache::for_vendor("ollama")?;
873 let endpoints = crate::ollama::fetch::Endpoints::default();
874 let outcome = crate::ollama::fetch_snapshot(
875 client,
876 &api_key,
877 &config.ollama.plan,
878 &cache,
879 &endpoints,
880 DEFAULT_TTL,
881 )
882 .await?;
883 Ok(outcome.into())
884 }
885 }
886}
887
888pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
891
892pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
899
900pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
906 let mut anthropic_seen: u32 = 0;
907 tabs.iter()
908 .map(|tab| {
909 if tab.vendor_id() == Some(VendorId::Anthropic) {
910 let delay = step * anthropic_seen;
911 anthropic_seen += 1;
912 delay
913 } else {
914 Duration::ZERO
915 }
916 })
917 .collect()
918}
919
920#[cfg(test)]
921mod tests {
922 use super::*;
923 use chrono::TimeZone;
924
925 #[test]
929 fn refresh_stagger_spaces_out_anthropic_tabs_only() {
930 let step = Duration::from_millis(800);
931 let tabs = vec![
932 TabId::vendor(VendorId::Anthropic), TabId::account("work"),
934 TabId::account("personal"),
935 TabId::vendor(VendorId::Openai),
936 TabId::vendor(VendorId::Zai),
937 ];
938 let delays = refresh_stagger(&tabs, step);
939 assert_eq!(
940 delays,
941 vec![
942 Duration::ZERO, step, step * 2, Duration::ZERO, Duration::ZERO, ]
948 );
949 }
950
951 #[test]
952 fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
953 let tabs = vec![
955 TabId::vendor(VendorId::Anthropic),
956 TabId::vendor(VendorId::Openrouter),
957 ];
958 assert!(
959 refresh_stagger(&tabs, Duration::from_millis(800))
960 .iter()
961 .all(|d| d.is_zero())
962 );
963 }
964
965 #[test]
966 fn select_primary_moves_to_enabled_vendor() {
967 let mut app = App::with_theme(
968 vec![
969 TabId::vendor(VendorId::Anthropic),
970 TabId::vendor(VendorId::Openrouter),
971 ],
972 Theme::default(),
973 );
974 app.select_primary(Some(VendorId::Openrouter));
975 assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
976 }
977
978 #[test]
979 fn select_primary_ignores_disabled_vendor() {
980 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
981 app.select_primary(Some(VendorId::Openai));
982 assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
983 }
984
985 #[test]
986 fn nav_ring_wraps_through_the_overview_at_both_ends() {
987 let mut app = App::with_theme(
988 vec![
989 TabId::vendor(VendorId::Anthropic),
990 TabId::vendor(VendorId::Openai),
991 ],
992 Theme::default(),
993 );
994 app.overview = true;
995
996 app.next_tab(); assert!(!app.overview);
998 assert_eq!(app.active, 0);
999 app.next_tab();
1000 assert_eq!(app.active, 1);
1001 app.next_tab(); assert!(app.overview);
1003
1004 app.prev_tab(); assert!(!app.overview);
1006 assert_eq!(app.active, 1);
1007 app.prev_tab();
1008 assert_eq!(app.active, 0);
1009 app.prev_tab(); assert!(app.overview);
1011 }
1012
1013 #[test]
1014 fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
1015 let mut app = App::with_theme(
1016 vec![
1017 TabId::vendor(VendorId::Anthropic),
1018 TabId::vendor(VendorId::Openai),
1019 TabId::vendor(VendorId::Zai),
1020 ],
1021 Theme::default(),
1022 );
1023 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1024
1025 app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
1027 assert_eq!(app.overview_tabs(), vec![2, 0]);
1028
1029 app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
1031 assert_eq!(app.overview_tabs(), vec![1]);
1032 }
1033
1034 fn config_with_accounts(labels: &[&str]) -> Config {
1035 let mut config = Config::default();
1036 config.openai.enabled = false;
1039 config.zai.enabled = false;
1040 config.openrouter.enabled = false;
1041 config.anthropic.accounts = labels
1042 .iter()
1043 .map(|l| crate::config::AnthropicAccount {
1044 label: (*l).to_string(),
1045 credentials_path: format!("/creds/{l}.json").into(),
1046 })
1047 .collect();
1048 config
1049 }
1050
1051 #[test]
1052 fn show_default_account_false_hides_the_unnamed_claude_tab() {
1053 let mut config = config_with_accounts(&["work", "personal"]);
1056 config.anthropic.show_default_account = false;
1057 assert_eq!(
1058 tabs_from_config(&config),
1059 vec![TabId::account("work"), TabId::account("personal")]
1060 );
1061
1062 let mut empty = Config::default();
1065 empty.openai.enabled = false;
1066 empty.zai.enabled = false;
1067 empty.openrouter.enabled = false;
1068 empty.anthropic.show_default_account = false;
1069 assert_eq!(
1070 tabs_from_config(&empty),
1071 vec![TabId::vendor(VendorId::Anthropic)]
1072 );
1073 }
1074
1075 #[test]
1076 fn tabs_expand_anthropic_accounts_after_default() {
1077 let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
1079 assert_eq!(
1080 tabs,
1081 vec![
1082 TabId::vendor(VendorId::Anthropic),
1083 TabId::account("work"),
1084 TabId::account("personal"),
1085 ]
1086 );
1087 }
1088
1089 #[test]
1090 fn tabs_without_accounts_are_just_enabled_vendors() {
1091 let config = Config::default();
1093 let tabs = tabs_from_config(&config);
1094 let vendors: Vec<VendorId> = tabs.iter().filter_map(TabId::vendor_id).collect();
1095 assert_eq!(vendors, config.enabled_vendors());
1096 assert!(tabs.iter().all(|t| t.account.is_none()));
1097 }
1098
1099 #[test]
1100 fn tabs_expand_openrouter_accounts_without_changing_other_vendors() {
1101 let mut config = Config::default();
1102 config.anthropic.enabled = false;
1103 config.openai.enabled = false;
1104 config.zai.enabled = false;
1105 config.openrouter.accounts = vec![
1106 crate::config::OpenRouterAccount {
1107 label: "work".into(),
1108 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1109 api_key: None,
1110 },
1111 crate::config::OpenRouterAccount {
1112 label: "personal".into(),
1113 api_key_env: None,
1114 api_key: Some("personal-key".into()),
1115 },
1116 ];
1117 assert_eq!(
1118 tabs_from_config(&config),
1119 vec![
1120 TabId::vendor(VendorId::Openrouter),
1121 TabId::account_for(VendorId::Openrouter, "work"),
1122 TabId::account_for(VendorId::Openrouter, "personal"),
1123 ]
1124 );
1125 }
1126
1127 #[test]
1128 fn openai_named_accounts_get_their_own_tabs_after_the_default() {
1129 let mut config = Config::default();
1130 config.anthropic.enabled = false;
1131 config.zai.enabled = false;
1132 config.openrouter.enabled = false;
1133 config.openai.accounts.push(crate::config::OpenAiAccount {
1134 label: "work".into(),
1135 codex_auth_path: "/tmp/codex-work/auth.json".into(),
1136 });
1137 assert_eq!(
1138 tabs_from_config(&config),
1139 vec![
1140 TabId::vendor(VendorId::Openai),
1141 TabId::account_for(VendorId::Openai, "work"),
1142 ]
1143 );
1144 }
1145
1146 #[test]
1147 fn openrouter_can_hide_default_only_when_named_accounts_exist() {
1148 let mut config = Config::default();
1149 config.anthropic.enabled = false;
1150 config.openai.enabled = false;
1151 config.zai.enabled = false;
1152 config.openrouter.show_default_account = false;
1153 assert_eq!(
1154 tabs_from_config(&config),
1155 vec![TabId::vendor(VendorId::Openrouter)]
1156 );
1157
1158 config
1159 .openrouter
1160 .accounts
1161 .push(crate::config::OpenRouterAccount {
1162 label: "work".into(),
1163 api_key_env: Some("OPENROUTER_WORK_API_KEY".into()),
1164 api_key: None,
1165 });
1166 assert_eq!(
1167 tabs_from_config(&config),
1168 vec![TabId::account_for(VendorId::Openrouter, "work")]
1169 );
1170 }
1171
1172 #[test]
1173 fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
1174 let td = tempfile::tempdir().unwrap();
1177 for label in ["work", "personal"] {
1178 let dir = td.path().join(label);
1179 std::fs::create_dir_all(&dir).unwrap();
1180 std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
1181 }
1182 let mut config = Config::default();
1183 config.openai.enabled = false;
1184 config.zai.enabled = false;
1185 config.openrouter.enabled = false;
1186 config.anthropic.accounts_dir = Some(td.path().to_path_buf());
1187
1188 let tabs = tabs_from_config(&config);
1189 assert_eq!(
1190 tabs,
1191 vec![
1192 TabId::vendor(VendorId::Anthropic),
1193 TabId::account("personal"), TabId::account("work"),
1195 ]
1196 );
1197 }
1198
1199 #[test]
1200 fn desktop_labels_become_account_tabs_after_cli_accounts() {
1201 let config = config_with_accounts(&["work"]);
1203 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1204 assert_eq!(
1205 tabs,
1206 vec![
1207 TabId::vendor(VendorId::Anthropic),
1208 TabId::account("work"),
1209 TabId::desktop_account("gmail"),
1210 TabId::desktop_account("hotmail"),
1211 ]
1212 );
1213 }
1214
1215 #[test]
1216 fn a_desktop_profile_wins_a_label_collision_with_a_cli_account() {
1217 let config = config_with_accounts(&["gmail", "work"]);
1222 let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()]);
1223 assert_eq!(
1224 tabs,
1225 vec![
1226 TabId::vendor(VendorId::Anthropic),
1227 TabId::account("work"),
1228 TabId::desktop_account("gmail"),
1229 TabId::desktop_account("hotmail"),
1230 ]
1231 );
1232 }
1233
1234 #[test]
1235 fn desktop_accounts_suppress_the_default_tab_like_named_ones() {
1236 let mut config = config_with_accounts(&[]);
1239 config.cursor.enabled = false;
1240 config.anthropic.show_default_account = false;
1241
1242 assert_eq!(
1245 build_tabs(&config, &[]),
1246 vec![TabId::vendor(VendorId::Anthropic)]
1247 );
1248 assert_eq!(
1250 build_tabs(&config, &["gmail".into()]),
1251 vec![TabId::desktop_account("gmail")]
1252 );
1253 }
1254
1255 #[test]
1256 fn set_tabs_resets_states_and_clamps_selection() {
1257 let mut app = App::with_theme(
1261 tabs_from_config(&config_with_accounts(&["work", "personal"])),
1262 Theme::default(),
1263 );
1264 app.active = 2; app.tabs[0] = TabState::error("old");
1266 let old_tab = app.tabs_meta[0].clone();
1267 assert!(app.begin_refresh(&old_tab));
1268
1269 app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
1270 assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
1271 assert_eq!(app.active, 0, "selection clamped after shrink");
1272 assert!(matches!(app.tabs[0], TabState::Loading));
1273 assert!(!app.is_refreshing(&old_tab));
1274 }
1275
1276 #[test]
1277 fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
1278 let mut app = App::with_theme(
1279 vec![
1280 TabId::vendor(VendorId::Anthropic),
1281 TabId::vendor(VendorId::Openai),
1282 ],
1283 Theme::default(),
1284 );
1285 app.active = 1;
1286
1287 app.set_tabs(vec![
1288 TabId::vendor(VendorId::Anthropic),
1289 TabId::account("work"),
1290 TabId::vendor(VendorId::Openai),
1291 ]);
1292
1293 assert_eq!(app.active, 2);
1294 assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
1295 }
1296
1297 #[test]
1298 fn refresh_from_old_generation_is_discarded() {
1299 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1300 let old_generation = app.tab_generation;
1301 app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
1302
1303 assert!(!app.apply_refresh(
1304 old_generation,
1305 &TabId::vendor(VendorId::Anthropic),
1306 TabState::error("old result"),
1307 ));
1308 assert!(matches!(app.tabs[0], TabState::Loading));
1309 }
1310
1311 #[test]
1312 fn refresh_identity_mismatch_is_discarded() {
1313 let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1314 let generation = app.tab_generation;
1315
1316 assert!(!app.apply_refresh(
1317 generation,
1318 &TabId::vendor(VendorId::Openai),
1319 TabState::error("wrong tab"),
1320 ));
1321 assert!(matches!(app.tabs[0], TabState::Loading));
1322 }
1323
1324 #[test]
1325 fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
1326 let anthropic = TabId::vendor(VendorId::Anthropic);
1327 let openai = TabId::vendor(VendorId::Openai);
1328 let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
1329 let generation = app.tab_generation;
1330 assert!(app.begin_refresh(&anthropic));
1331
1332 app.tabs_meta.swap(0, 1);
1335 app.tabs.swap(0, 1);
1336 assert!(app.apply_refresh(generation, &anthropic, TabState::error("ready")));
1337 assert!(matches!(app.tabs[0], TabState::Loading));
1338 assert!(matches!(&app.tabs[1], TabState::Error { message, .. } if message == "ready"));
1339 assert!(!app.is_refreshing(&anthropic));
1340 }
1341
1342 fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
1343 TabState::Ready(Box::new(ReadyTab {
1344 snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
1345 label: "test".into(),
1346 total_credits: 0.0,
1347 total_usage: 0.0,
1348 usage_daily: 0.0,
1349 usage_weekly: 0.0,
1350 usage_monthly: 0.0,
1351 is_free_tier: false,
1352 limit: None,
1353 limit_remaining: None,
1354 }),
1355 stale: false,
1356 last_error: None,
1357 fetched_at: Some(fetched_at),
1358 }))
1359 }
1360
1361 #[test]
1362 fn refresh_keeps_ready_snapshot_visible_and_suppresses_duplicates() {
1363 let tab = TabId::vendor(VendorId::Openrouter);
1364 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1365 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1366 app.tabs[0] = ready_at(fetched_at);
1367
1368 assert!(app.begin_refresh(&tab));
1369 assert!(
1370 !app.begin_refresh(&tab),
1371 "duplicate request must be suppressed"
1372 );
1373 assert!(app.is_refreshing(&tab));
1374 match &app.tabs[0] {
1375 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1376 other => panic!("ready snapshot disappeared during refresh: {other:?}"),
1377 }
1378 }
1379
1380 #[test]
1381 fn first_refresh_still_uses_loading_until_data_arrives() {
1382 let tab = TabId::vendor(VendorId::Openrouter);
1383 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1384
1385 assert!(app.begin_refresh(&tab));
1386 assert!(app.is_refreshing(&tab));
1387 assert!(matches!(app.tabs[0], TabState::Loading));
1388
1389 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("not signed in"),));
1390 assert!(!app.is_refreshing(&tab));
1391 assert!(
1392 matches!(&app.tabs[0], TabState::Error { message, .. } if message == "not signed in")
1393 );
1394 }
1395
1396 #[test]
1397 fn successful_revalidation_replaces_snapshot_and_clears_indicator() {
1398 let tab = TabId::vendor(VendorId::Openrouter);
1399 let old_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1400 let new_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 1, 0).unwrap();
1401 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1402 app.tabs[0] = ready_at(old_at);
1403
1404 assert!(app.begin_refresh(&tab));
1405 assert!(app.apply_refresh(app.tab_generation, &tab, ready_at(new_at)));
1406 assert!(!app.is_refreshing(&tab));
1407 match &app.tabs[0] {
1408 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(new_at)),
1409 other => panic!("expected replacement snapshot, got {other:?}"),
1410 }
1411 }
1412
1413 #[test]
1414 fn failed_revalidation_preserves_snapshot_with_visible_warning() {
1415 let tab = TabId::vendor(VendorId::Openrouter);
1416 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1417 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1418 app.tabs[0] = ready_at(fetched_at);
1419
1420 assert!(app.begin_refresh(&tab));
1421 assert!(app.apply_refresh(app.tab_generation, &tab, TabState::error("refresh failed"),));
1422 assert!(!app.is_refreshing(&tab));
1423 match &app.tabs[0] {
1424 TabState::Ready(ready) => {
1425 assert_eq!(ready.fetched_at, Some(fetched_at));
1426 assert!(ready.stale);
1427 assert_eq!(ready.last_error, Some((0, "refresh failed".into())));
1428 }
1429 other => panic!("last successful snapshot was lost: {other:?}"),
1430 }
1431 let sections = crate::tui::panels::sections_for(&app.tabs[0], Utc::now(), 5);
1432 assert!(sections.iter().any(|section| matches!(
1433 section,
1434 crate::tui::panels::Section::Text { label, value }
1435 if label == "Warning" && value == "refresh failed"
1436 )));
1437 }
1438
1439 #[test]
1440 fn old_generation_result_does_not_clear_current_refresh() {
1441 let tab = TabId::vendor(VendorId::Openrouter);
1442 let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1443 let old_generation = app.tab_generation;
1444 app.set_tabs(vec![tab.clone()]);
1445 assert!(app.begin_refresh(&tab));
1446
1447 assert!(!app.apply_refresh(old_generation, &tab, TabState::error("old result"),));
1448 assert!(app.is_refreshing(&tab));
1449 assert!(matches!(app.tabs[0], TabState::Loading));
1450 }
1451
1452 #[test]
1453 fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
1454 let anthropic = TabId::vendor(VendorId::Anthropic);
1460 let openai = TabId::vendor(VendorId::Openai);
1461 let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
1462 let generation = app.tab_generation;
1463 let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1464
1465 assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
1466 match &app.tabs[0] {
1467 TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1468 other => panic!("expected Anthropic tab Ready, got {other:?}"),
1469 }
1470 assert!(matches!(app.tabs[1], TabState::Loading));
1471 }
1472
1473 #[test]
1474 fn select_primary_lands_on_default_account_tab() {
1475 let app = {
1478 let tabs = tabs_from_config(&config_with_accounts(&["work"]));
1479 let mut a = App::with_theme(tabs, Theme::default());
1480 a.select_primary(Some(VendorId::Anthropic));
1481 a
1482 };
1483 assert_eq!(app.active, 0);
1484 assert_eq!(
1485 app.active_tab_id(),
1486 Some(&TabId::vendor(VendorId::Anthropic))
1487 );
1488 }
1489
1490 fn custom_spec(id: &str, enabled: bool) -> CustomProviderConfig {
1491 CustomProviderConfig {
1492 id: id.into(),
1493 name: "My Tool".into(),
1494 short_name: "myt".into(),
1495 enabled,
1496 ..Default::default()
1497 }
1498 }
1499
1500 #[test]
1501 fn custom_providers_get_tabs_after_every_builtin() {
1502 let config = Config {
1503 custom: vec![custom_spec("mytool", true), custom_spec("other", true)],
1504 ..Default::default()
1505 };
1506 let tabs = tabs_from_config(&config);
1507 let builtin_count = config.enabled_vendors().len();
1508 assert_eq!(tabs.len(), builtin_count + 2);
1509 assert!(
1510 tabs[..builtin_count]
1511 .iter()
1512 .all(|t| matches!(t.source, TabSource::Builtin(_)))
1513 );
1514 assert_eq!(tabs[builtin_count], TabId::custom(&config.custom[0]));
1515 assert_eq!(tabs[builtin_count + 1], TabId::custom(&config.custom[1]));
1516 assert_eq!(
1517 tabs[builtin_count].source,
1518 TabSource::Custom {
1519 id: "mytool".into(),
1520 name: "My Tool".into(),
1521 short_name: "myt".into(),
1522 }
1523 );
1524 assert!(tabs[builtin_count].account.is_none());
1525 assert!(!tabs[builtin_count].desktop);
1526 assert_eq!(tabs[builtin_count].vendor_id(), None);
1527 }
1528
1529 #[test]
1530 fn disabled_custom_provider_has_no_tab() {
1531 let config = Config {
1532 custom: vec![custom_spec("mytool", false)],
1533 ..Default::default()
1534 };
1535 let tabs = tabs_from_config(&config);
1536 assert!(
1537 tabs.iter()
1538 .all(|t| matches!(t.source, TabSource::Builtin(_)))
1539 );
1540 assert_eq!(tabs.len(), config.enabled_vendors().len());
1541 }
1542
1543 #[test]
1544 fn custom_tabs_are_listed_by_the_default_overview_but_not_by_a_vendor_filter() {
1545 let spec = custom_spec("mytool", true);
1546 let mut app = App::with_theme(
1547 vec![
1548 TabId::vendor(VendorId::Anthropic),
1549 TabId::custom(&spec),
1550 TabId::vendor(VendorId::Openai),
1551 ],
1552 Theme::default(),
1553 );
1554 assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
1555
1556 app.overview_vendors = Some(vec![VendorId::Openai, VendorId::Anthropic]);
1558 assert_eq!(app.overview_tabs(), vec![2, 0]);
1559 }
1560
1561 #[test]
1562 fn a_custom_active_tab_has_no_vendor_and_never_staggers() {
1563 let spec = custom_spec("mytool", true);
1564 let tabs = vec![
1565 TabId::vendor(VendorId::Anthropic),
1566 TabId::custom(&spec),
1567 TabId::account("work"),
1568 ];
1569 let mut app = App::with_theme(tabs.clone(), Theme::default());
1570 app.active = 1;
1571 assert_eq!(app.active_vendor(), None);
1572 assert_eq!(app.active_tab_id(), Some(&TabId::custom(&spec)));
1573
1574 app.select_primary(Some(VendorId::Anthropic));
1576 assert_eq!(app.active, 0);
1577
1578 let step = Duration::from_millis(800);
1579 assert_eq!(
1580 refresh_stagger(&tabs, step),
1581 vec![Duration::ZERO, Duration::ZERO, step]
1582 );
1583 }
1584}