Skip to main content

ai_usagebar/tui/
app.rs

1//! TUI app state — vendors, tab selection, per-vendor snapshot cache.
2
3use 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/// What we display per vendor — raw snapshot + fetch metadata for native
16/// panel rendering, or an error message when the fetch failed.
17///
18/// `Ready` is boxed because the snapshot is much larger than the other two
19/// variants (silences `clippy::large_enum_variant`).
20#[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    /// Absolute moment the cache was written (i.e. the API response landed).
33    /// Snapshotted once at TabState build time so the rendered "Updated …"
34    /// timestamp stays stable across redraws instead of drifting with the
35    /// passing wall clock.
36    pub fetched_at: Option<chrono::DateTime<chrono::Utc>>,
37}
38
39/// Identity of one TUI tab. Usually a whole vendor; for Anthropic it can also
40/// name a specific configured account (issues #14 / #17). `account: None` is a
41/// plain vendor tab — the default Claude account, or any non-Anthropic vendor.
42/// `desktop` marks an account whose usage comes from the Claude Desktop app's
43/// own token rather than a `claude` CLI credential.
44#[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    /// A plain vendor tab (default account for Anthropic).
53    pub fn vendor(vendor: VendorId) -> Self {
54        Self {
55            vendor,
56            account: None,
57            desktop: false,
58        }
59    }
60
61    /// A named Anthropic account tab (`[[anthropic.accounts]]` label).
62    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    /// An Anthropic account whose usage is read from the Claude Desktop app's
71    /// own token store (a saved `~/.claude-acc/profiles/<label>` account).
72    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
81/// Expand enabled vendors into the tab list. Anthropic yields its default
82/// account tab followed by one tab per `[[anthropic.accounts]]` entry, in
83/// config order; every other vendor is a single tab. With no extra accounts
84/// configured the result equals `config.enabled_vendors()` — identical tab set
85/// and order to before (issue #14/#17 back-compat).
86///
87/// Config-only and pure — no Desktop profiles. Production uses
88/// [`tabs_with_desktop`]; this stays for the hermetic unit tests and any caller
89/// that only wants configured accounts.
90pub fn tabs_from_config(config: &Config) -> Vec<TabId> {
91    build_tabs(config, &[], &[])
92}
93
94/// The production tab list: configured accounts plus every saved Claude Desktop
95/// profile that has usable credentials and is not already a working CLI account.
96/// Desktop discovery is best-effort and macOS-only; anywhere else this equals
97/// [`tabs_from_config`].
98pub fn tabs_with_desktop(config: &Config) -> Vec<TabId> {
99    let desktop = desktop_profile_labels(config);
100    // A configured CLI account normally wins its label, but only if it can
101    // actually authenticate. A half-finished `account add <label>` (config
102    // entry written, login never completed) would otherwise mask a perfectly
103    // good Desktop profile of the same name — the account shows an error
104    // instead of its usage. Where that happens, let the Desktop source take
105    // over. Checked only for labels that exist in both, so the credential probe
106    // is rare.
107    let broken = broken_cli_labels(config, &desktop);
108    build_tabs(config, &desktop, &broken)
109}
110
111/// Core expansion, parameterized so it stays pure and unit-testable.
112/// `desktop_labels` are Claude Desktop accounts; `broken_cli` names configured
113/// CLI accounts to drop (their credential is unusable and a Desktop profile
114/// covers them). Desktop accounts follow the CLI accounts, de-duplicated by
115/// label (a *working* configured CLI account wins), and count toward "Anthropic
116/// has accounts" for the default-tab suppression.
117fn build_tabs(config: &Config, desktop_labels: &[String], broken_cli: &[String]) -> Vec<TabId> {
118    let mut tabs = Vec::new();
119    for vendor in config.enabled_vendors() {
120        if vendor == VendorId::Anthropic {
121            let accounts: Vec<_> = config
122                .anthropic
123                .all_accounts()
124                .into_iter()
125                .filter(|a| !broken_cli.iter().any(|b| b == &a.label))
126                .collect();
127            let cli_labels: HashSet<&str> = accounts.iter().map(|a| a.label.as_str()).collect();
128            let desktop: Vec<&String> = desktop_labels
129                .iter()
130                .filter(|label| !cli_labels.contains(label.as_str()))
131                .collect();
132            // The default (unnamed) Claude tab is suppressible once every
133            // account is named — but never when it would leave Anthropic with
134            // no tab at all. Desktop accounts count as named accounts here.
135            if config.anthropic.show_default_account || (accounts.is_empty() && desktop.is_empty())
136            {
137                tabs.push(TabId::vendor(vendor));
138            }
139            for acct in accounts {
140                tabs.push(TabId::account(acct.label));
141            }
142            for label in desktop {
143                tabs.push(TabId::desktop_account(label.clone()));
144            }
145        } else {
146            tabs.push(TabId::vendor(vendor));
147        }
148    }
149    tabs
150}
151
152/// Configured CLI accounts that both (a) share a label with a Desktop profile
153/// and (b) can't authenticate — so the Desktop source should win. Resolving the
154/// credential may read the macOS Keychain, so this only runs for the rare label
155/// present in both places.
156fn broken_cli_labels(config: &Config, desktop_labels: &[String]) -> Vec<String> {
157    use crate::anthropic::creds;
158    config
159        .anthropic
160        .all_accounts()
161        .into_iter()
162        .filter(|a| desktop_labels.iter().any(|d| d == &a.label))
163        .filter(|a| match config.anthropic.account_target(&a.label) {
164            Ok((target, _)) => creds::resolve(&target)
165                .map(|(c, _)| creds::is_unusable(&c.claude_ai_oauth))
166                .unwrap_or(true),
167            Err(_) => true,
168        })
169        .map(|a| a.label)
170        .collect()
171}
172
173/// Labels of saved Claude Desktop profiles with usable credentials. macOS-only
174/// (elsewhere there is no Desktop app); best-effort, so an unreadable profile
175/// store just yields none rather than failing the whole tab list.
176#[cfg(target_os = "macos")]
177fn desktop_profile_labels(config: &Config) -> Vec<String> {
178    let Ok(paths) = crate::claude_desktop::Paths::resolve(&config.anthropic) else {
179        return Vec::new();
180    };
181    if !paths.available() {
182        return Vec::new();
183    }
184    crate::claude_desktop::load_profiles(&paths.profiles_dir)
185        .into_iter()
186        .filter(|p| p.has_credentials)
187        .map(|p| p.label)
188        .collect()
189}
190
191#[cfg(not(target_os = "macos"))]
192fn desktop_profile_labels(_config: &Config) -> Vec<String> {
193    Vec::new()
194}
195
196#[derive(Debug)]
197pub struct App {
198    pub tabs_meta: Vec<TabId>,
199    pub active: usize,
200    pub tabs: Vec<TabState>,
201    /// Tab identities with a request currently in flight. Kept separate from
202    /// `tabs` so a successful snapshot remains visible while it is refreshed.
203    refreshing_tabs: HashSet<TabId>,
204    /// Monotonically increasing identity for a complete tab-set replacement.
205    /// Background fetches carry this with their tab identity so results from a
206    /// previous Settings reload cannot land in a new tab at the old index.
207    pub tab_generation: u64,
208    /// When `true`, the Overview pane is selected (the virtual first tab that
209    /// summarizes every vendor at once) instead of a per-vendor detail tab.
210    pub overview: bool,
211    /// Which vendors the Overview lists (`[ui] overview_vendors`); `None` = all.
212    pub overview_vendors: Option<Vec<VendorId>>,
213    pub theme: Theme,
214    pub quit: bool,
215    /// When `Some`, the Settings overlay is open and consuming key events.
216    pub settings: Option<crate::tui::settings::SettingsState>,
217    /// Local context monitoring is separately opt-in and never changes the
218    /// vendor tab set.
219    pub context_enabled: bool,
220    /// Monotonic across overlay close/reopen cycles so an old detached scan
221    /// can never share the new overlay's first generation number.
222    pub context_generation: u64,
223    /// When `Some`, the local Claude Code context overlay owns keyboard input.
224    pub context: Option<crate::tui::context::ContextState>,
225    /// Presentation style for the vendor navigation box (`[ui] vendor_box`).
226    pub vendor_box: crate::config::VendorBoxStyle,
227}
228
229impl App {
230    pub fn new(tabs_meta: Vec<TabId>) -> Self {
231        // Production: resolve the palette from the environment (Omarchy theme
232        // if present, else One Dark).
233        Self::with_theme(tabs_meta, Theme::default().merged_with_omarchy())
234    }
235
236    /// Like [`App::new`] but with an explicit theme. Lets tests build an `App`
237    /// without reading the real Omarchy theme file
238    /// (`$HOME/.config/omarchy/current/theme/colors.toml`) — `new` resolves
239    /// that path and the `$HOME` env var via `merged_with_omarchy`, which is
240    /// not hermetic. Production code uses `new`/`new_with_primary`.
241    pub fn with_theme(tabs_meta: Vec<TabId>, theme: Theme) -> Self {
242        let n = tabs_meta.len();
243        Self {
244            tabs_meta,
245            active: 0,
246            tabs: vec![TabState::Loading; n],
247            refreshing_tabs: HashSet::new(),
248            tab_generation: 0,
249            overview: false,
250            overview_vendors: None,
251            theme,
252            quit: false,
253            settings: None,
254            context_enabled: false,
255            context_generation: 0,
256            context: None,
257            vendor_box: crate::config::VendorBoxStyle::Sidebar,
258        }
259    }
260
261    /// Construct with an initial active tab — usually `[ui] primary` from
262    /// config. Silently falls through to index 0 if the requested vendor
263    /// isn't present (e.g. it was disabled).
264    pub fn new_with_primary(tabs_meta: Vec<TabId>, primary: Option<VendorId>) -> Self {
265        let mut app = Self::new(tabs_meta);
266        // Default landing is the Overview (show everything at once). An explicit
267        // `[ui] primary` opts into opening on that vendor's tab instead.
268        if primary.is_some() {
269            app.select_primary(primary);
270        } else {
271            app.overview = true;
272        }
273        app
274    }
275
276    pub fn active_tab_id(&self) -> Option<&TabId> {
277        self.tabs_meta.get(self.active)
278    }
279
280    pub fn active_vendor(&self) -> Option<VendorId> {
281        self.tabs_meta.get(self.active).map(|t| t.vendor)
282    }
283
284    /// Replace the tab set — used after a Settings save reloads config, so
285    /// tabs added or removed in `config.toml` while the TUI is open (e.g. a
286    /// new `[[anthropic.accounts]]` entry) appear without a restart. Every
287    /// tab resets to `Loading` (the caller re-spawns fetches). The selected tab
288    /// is preserved by identity when possible; otherwise its old position is
289    /// clamped in case the list shrank.
290    pub fn set_tabs(&mut self, tabs_meta: Vec<TabId>) {
291        let selected = self.active_tab_id().cloned();
292        let fallback = self.active.min(tabs_meta.len().saturating_sub(1));
293        self.tab_generation = self.tab_generation.wrapping_add(1);
294        self.active = selected
295            .as_ref()
296            .and_then(|tab| tabs_meta.iter().position(|candidate| candidate == tab))
297            .unwrap_or(fallback);
298        self.tabs = vec![TabState::Loading; tabs_meta.len()];
299        self.tabs_meta = tabs_meta;
300        self.refreshing_tabs.clear();
301    }
302
303    /// Mark one tab as in flight. A ready snapshot stays in place; tabs that
304    /// have never succeeded still use the full `Loading` state. Returning
305    /// `false` suppresses duplicate requests for the same tab.
306    pub fn begin_refresh(&mut self, tab: &TabId) -> bool {
307        let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
308            return false;
309        };
310        if !self.refreshing_tabs.insert(tab.clone()) {
311            return false;
312        }
313        if !matches!(self.tabs[index], TabState::Ready(_)) {
314            self.tabs[index] = TabState::Loading;
315        }
316        true
317    }
318
319    pub fn is_refreshing(&self, tab: &TabId) -> bool {
320        self.refreshing_tabs.contains(tab)
321    }
322
323    pub fn tab_is_refreshing(&self, index: usize) -> bool {
324        self.tabs_meta
325            .get(index)
326            .is_some_and(|tab| self.is_refreshing(tab))
327    }
328
329    /// Apply an asynchronous refresh only when it still belongs to this tab
330    /// generation and the captured tab identity still exists. Lookup by
331    /// identity, rather than the old positional index, also makes a reordered
332    /// tab list safe.
333    pub fn apply_refresh(&mut self, generation: u64, tab: &TabId, state: TabState) -> bool {
334        if generation != self.tab_generation {
335            return false;
336        }
337        let Some(index) = self.tabs_meta.iter().position(|current| current == tab) else {
338            return false;
339        };
340        let was_refreshing = self.refreshing_tabs.remove(tab);
341        // If revalidation fails after a successful snapshot, preserve the
342        // useful data but make the failure explicit. Initial failures still
343        // become the normal Error state because there is no data to preserve.
344        if was_refreshing
345            && let TabState::Ready(ready) = &mut self.tabs[index]
346            && let TabState::Error(message) = state
347        {
348            ready.stale = true;
349            ready.last_error = Some((0, message));
350        } else {
351            self.tabs[index] = state;
352        }
353        true
354    }
355
356    /// Move to the first tab of `primary`'s vendor (the default account tab,
357    /// since it precedes any of that vendor's account tabs).
358    pub fn select_primary(&mut self, primary: Option<VendorId>) {
359        if let Some(p) = primary
360            && let Some(idx) = self.tabs_meta.iter().position(|t| t.vendor == p)
361        {
362            self.active = idx;
363            self.overview = false;
364        }
365    }
366
367    /// The selectable ring is `[Overview, tab0, tab1, …]`. `next_tab`/`prev_tab`
368    /// walk it, wrapping through the Overview at the ends.
369    pub fn next_tab(&mut self) {
370        if self.overview {
371            if !self.tabs_meta.is_empty() {
372                self.overview = false;
373                self.active = 0;
374            }
375        } else if self.active + 1 < self.tabs_meta.len() {
376            self.active += 1;
377        } else {
378            self.overview = true;
379        }
380    }
381
382    pub fn prev_tab(&mut self) {
383        if self.overview {
384            if !self.tabs_meta.is_empty() {
385                self.overview = false;
386                self.active = self.tabs_meta.len() - 1;
387            }
388        } else if self.active > 0 {
389            self.active -= 1;
390        } else {
391            self.overview = true;
392        }
393    }
394
395    /// Tabs the Overview should list: `overview_vendors` filtered against the
396    /// live tab set (preserving the config order), or all tabs when unset.
397    pub fn overview_tabs(&self) -> Vec<usize> {
398        match &self.overview_vendors {
399            None => (0..self.tabs_meta.len()).collect(),
400            Some(wanted) => wanted
401                .iter()
402                .flat_map(|v| {
403                    self.tabs_meta
404                        .iter()
405                        .enumerate()
406                        .filter(move |(_, t)| t.vendor == *v)
407                        .map(|(i, _)| i)
408                })
409                .collect(),
410        }
411    }
412}
413
414/// Fetch and render one tab — returns a `TabState`.
415pub async fn refresh_one(client: &Client, config: &Config, tab: &TabId) -> TabState {
416    match build_outcome(client, config, tab).await {
417        Ok(outcome) => {
418            // Resolve the cache age (a duration from "now" at fetch time) into an
419            // absolute instant ONCE. Without this, sections_for would recompute
420            // `Utc::now() - cache_age` on every draw and the displayed time would
421            // tick upward in real time instead of holding at the last refresh.
422            let now = Utc::now();
423            let fetched_at = outcome
424                .cache_age
425                .map(|age| now - chrono::Duration::from_std(age).unwrap_or_default());
426            TabState::Ready(Box::new(ReadyTab {
427                snapshot: outcome.snapshot,
428                stale: outcome.stale,
429                last_error: outcome.last_error.map(|(code, message)| {
430                    (code, crate::display::sanitize_untrusted_field(&message))
431                }),
432                fetched_at,
433            }))
434        }
435        Err(e) => TabState::Error(crate::display::sanitize_untrusted_field(&e.user_message())),
436    }
437}
438
439async fn build_outcome(client: &Client, config: &Config, tab: &TabId) -> Result<VendorOutcome> {
440    match tab.vendor {
441        VendorId::Anthropic => {
442            // A named account resolves to its own file + `anthropic/<label>`
443            // cache, shared with the widget via `account_target` (#14/#17).
444            // The default tab keeps the pre-existing resolution: config
445            // `credentials_path` is an explicit strict read, and only the
446            // platform default gets the macOS Keychain fallback.
447            let (creds_target, cache) = match tab.account.as_deref() {
448                Some(label) if tab.desktop => {
449                    crate::anthropic::desktop_creds::account_target(config, label)?
450                }
451                Some(label) => config.anthropic.account_target(label)?,
452                None => {
453                    let target = match config.anthropic.credentials_path.clone() {
454                        Some(p) => crate::anthropic::creds::CredsTarget::Explicit(p),
455                        None => crate::anthropic::creds::CredsTarget::Default(
456                            crate::anthropic::creds::default_path().unwrap_or_default(),
457                        ),
458                    };
459                    (target, crate::cache::Cache::for_vendor("anthropic")?)
460                }
461            };
462            let endpoints = crate::anthropic::fetch::Endpoints::default();
463            let outcome = crate::anthropic::fetch_snapshot(
464                client,
465                &creds_target,
466                &cache,
467                &endpoints,
468                DEFAULT_TTL,
469            )
470            .await?;
471            Ok(crate::vendor::VendorOutcome {
472                snapshot: crate::usage::VendorSnapshot::Anthropic(outcome.snapshot),
473                stale: outcome.stale,
474                last_error: outcome.last_error,
475                cache_age: outcome.cache_age,
476            })
477        }
478        VendorId::AnthropicApi => {
479            let key = crate::config::resolve_api_key(
480                "Anthropic_API",
481                &config.anthropic_api.api_key_env,
482                config.anthropic_api.api_key.as_deref(),
483            )?;
484            let cache = crate::cache::Cache::for_vendor("anthropic_api")?;
485            let endpoints = crate::anthropic_api::fetch::Endpoints::default();
486            let outcome = crate::anthropic_api::fetch_snapshot(
487                client,
488                &key,
489                &cache,
490                &endpoints,
491                DEFAULT_TTL,
492                config.anthropic_api.monthly_limit,
493            )
494            .await?;
495            Ok(outcome.into())
496        }
497        VendorId::Openrouter => {
498            let api_key = crate::config::resolve_api_key(
499                "OpenRouter",
500                &config.openrouter.api_key_env,
501                config.openrouter.api_key.as_deref(),
502            )?;
503            let cache = crate::cache::Cache::for_vendor("openrouter")?;
504            let endpoints = crate::openrouter::fetch::Endpoints::default();
505            let outcome = crate::openrouter::fetch_snapshot(
506                client,
507                &api_key,
508                &cache,
509                &endpoints,
510                DEFAULT_TTL,
511            )
512            .await?;
513            Ok(outcome.into())
514        }
515        VendorId::Zai => {
516            let api_key = crate::config::resolve_api_key(
517                "Zai",
518                &config.zai.api_key_env,
519                config.zai.api_key.as_deref(),
520            )?;
521            let cache = crate::cache::Cache::for_vendor("zai")?;
522            let endpoints = crate::zai::fetch::Endpoints::default();
523            let outcome = crate::zai::fetch_snapshot(
524                client,
525                &api_key,
526                &cache,
527                &endpoints,
528                DEFAULT_TTL,
529                config.zai.plan_tier.as_deref(),
530            )
531            .await?;
532            Ok(outcome.into())
533        }
534        VendorId::Openai => {
535            let cache = crate::cache::Cache::for_vendor("openai")?;
536            let creds_path = config
537                .openai
538                .codex_auth_path
539                .clone()
540                .unwrap_or_else(|| crate::openai::creds::default_path().unwrap_or_default());
541            let endpoints = crate::openai::fetch::Endpoints::default();
542            let outcome =
543                crate::openai::fetch_snapshot(client, &creds_path, &cache, &endpoints, DEFAULT_TTL)
544                    .await?;
545            Ok(outcome.into())
546        }
547        VendorId::Deepseek => {
548            let api_key = crate::config::resolve_api_key(
549                "DeepSeek",
550                &config.deepseek.api_key_env,
551                config.deepseek.api_key.as_deref(),
552            )?;
553            let cache = crate::cache::Cache::for_vendor("deepseek")?;
554            let endpoints = crate::deepseek::fetch::Endpoints::default();
555            let outcome =
556                crate::deepseek::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
557                    .await?;
558            Ok(outcome.into())
559        }
560        VendorId::Kimi => {
561            let api_key = crate::config::resolve_api_key(
562                "Kimi",
563                &config.kimi.api_key_env,
564                config.kimi.api_key.as_deref(),
565            )?;
566            let cache = crate::cache::Cache::for_vendor("kimi")?;
567            let endpoints = crate::kimi::fetch::Endpoints::default();
568            let outcome =
569                crate::kimi::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
570                    .await?;
571            Ok(outcome.into())
572        }
573        VendorId::Kilo => {
574            let api_key = crate::config::resolve_api_key(
575                "Kilo",
576                &config.kilo.api_key_env,
577                config.kilo.api_key.as_deref(),
578            )?;
579            let cache = crate::cache::Cache::for_vendor("kilo")?;
580            let endpoints = crate::kilo::fetch::Endpoints::default();
581            let outcome = crate::kilo::fetch_snapshot(
582                client,
583                &api_key,
584                &cache,
585                &endpoints,
586                DEFAULT_TTL,
587                config.kilo.organization_id.as_deref(),
588            )
589            .await?;
590            Ok(outcome.into())
591        }
592        VendorId::Novita => {
593            let api_key = crate::config::resolve_api_key(
594                "Novita",
595                &config.novita.api_key_env,
596                config.novita.api_key.as_deref(),
597            )?;
598            let cache = crate::cache::Cache::for_vendor("novita")?;
599            let endpoints = crate::novita::fetch::Endpoints::default();
600            let outcome =
601                crate::novita::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
602                    .await?;
603            Ok(outcome.into())
604        }
605        VendorId::Moonshot => {
606            let api_key = crate::config::resolve_api_key(
607                "Moonshot",
608                &config.moonshot.api_key_env,
609                config.moonshot.api_key.as_deref(),
610            )?;
611            let cache = crate::cache::Cache::for_vendor("moonshot")?;
612            let (endpoints, currency) =
613                crate::moonshot::fetch::Endpoints::for_region(&config.moonshot.region);
614            let outcome = crate::moonshot::fetch_snapshot(
615                client,
616                &api_key,
617                &cache,
618                &endpoints,
619                DEFAULT_TTL,
620                currency,
621            )
622            .await?;
623            Ok(outcome.into())
624        }
625        VendorId::Grok => {
626            let key = crate::config::resolve_api_key(
627                "Grok",
628                &config.grok.api_key_env,
629                config.grok.api_key.as_deref(),
630            )?;
631            let cache = crate::cache::Cache::for_vendor("grok")?;
632            let endpoints = crate::grok::fetch::Endpoints::default();
633            let outcome = crate::grok::fetch_snapshot(
634                client,
635                &key,
636                &cache,
637                &endpoints,
638                DEFAULT_TTL,
639                config.grok.team_id.as_deref(),
640            )
641            .await?;
642            Ok(outcome.into())
643        }
644        VendorId::Supergrok => {
645            let cache = crate::cache::Cache::for_vendor("supergrok")?;
646            let scope_paths = crate::supergrok::scope::ScopePaths::with_overrides(
647                config.supergrok.auth_path.as_deref(),
648                config.supergrok.config_path.as_deref(),
649            )?;
650            let outcome = crate::supergrok::fetch_snapshot(
651                &config.supergrok.grok_binary,
652                &scope_paths,
653                &cache,
654                DEFAULT_TTL,
655            )
656            .await?;
657            Ok(outcome.into())
658        }
659        VendorId::Antigravity => {
660            // No credentials: the local Antigravity server is the source.
661            let cache = crate::cache::Cache::for_vendor("antigravity")?;
662            let outcome = crate::antigravity::fetch_snapshot(client, &cache, DEFAULT_TTL).await?;
663            Ok(outcome.into())
664        }
665        VendorId::Minimax => {
666            let api_key = crate::config::resolve_api_key(
667                "MiniMax",
668                &config.minimax.api_key_env,
669                config.minimax.api_key.as_deref(),
670            )?;
671            let cache = crate::cache::Cache::for_vendor("minimax")?;
672            let endpoints = crate::minimax::fetch::Endpoints::for_region(&config.minimax.region);
673            let outcome =
674                crate::minimax::fetch_snapshot(client, &api_key, &cache, &endpoints, DEFAULT_TTL)
675                    .await?;
676            Ok(outcome.into())
677        }
678        VendorId::Cursor => {
679            let cache = crate::cache::Cache::for_vendor("cursor")?;
680            let db_path = config
681                .cursor
682                .db_path
683                .clone()
684                .map(Ok)
685                .unwrap_or_else(crate::cursor::db::default_db_path)?;
686            let agent_auth_path = config
687                .cursor
688                .agent_auth_path
689                .clone()
690                .map(Ok)
691                .unwrap_or_else(crate::cursor::db::default_agent_auth_path)?;
692            let endpoints = crate::cursor::fetch::Endpoints::default();
693            let outcome = crate::cursor::fetch_snapshot(
694                client,
695                &db_path,
696                &agent_auth_path,
697                &cache,
698                &endpoints,
699                DEFAULT_TTL,
700            )
701            .await?;
702            Ok(outcome.into())
703        }
704        VendorId::Kiro => {
705            let cache = crate::cache::Cache::for_vendor("kiro")?;
706            let db_path = config
707                .kiro
708                .db_path
709                .clone()
710                .map(Ok)
711                .unwrap_or_else(crate::kiro::db::default_db_path)?;
712            let outcome =
713                crate::kiro::fetch_snapshot(client, &db_path, &cache, DEFAULT_TTL).await?;
714            Ok(outcome.into())
715        }
716    }
717}
718
719/// Convenience for the watch-driven binary: how long to wait between
720/// automatic refreshes.
721pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60);
722
723/// Gap between successive Anthropic fetches at refresh time. Every Anthropic
724/// tab (the default account and each named/discovered account) hits the same
725/// `/api/oauth/usage` + token-refresh endpoints, which rate-limit a burst of
726/// simultaneous requests from one client — so with several accounts the TUI
727/// would fire them all at once and some would come back `429`. Spacing them
728/// out keeps every account refreshing politely.
729pub const ANTHROPIC_REFRESH_STAGGER: Duration = Duration::from_millis(800);
730
731/// Per-tab startup delay for one `spawn_all` pass. Only Anthropic tabs are
732/// staggered (they share the rate-limited endpoint and multiply with accounts);
733/// every other vendor hits its own endpoint and starts immediately. The first
734/// Anthropic tab also starts immediately; each subsequent one waits one more
735/// `step`. Pure and position-based so it is unit-testable.
736pub fn refresh_stagger(tabs: &[TabId], step: Duration) -> Vec<Duration> {
737    let mut anthropic_seen: u32 = 0;
738    tabs.iter()
739        .map(|tab| {
740            if tab.vendor == VendorId::Anthropic {
741                let delay = step * anthropic_seen;
742                anthropic_seen += 1;
743                delay
744            } else {
745                Duration::ZERO
746            }
747        })
748        .collect()
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754    use chrono::TimeZone;
755
756    // Use `App::with_theme(.., Theme::default())` rather than `App::new`, which
757    // would read the real Omarchy theme file + `$HOME`. The tab-selection logic
758    // under test is theme-agnostic.
759    #[test]
760    fn refresh_stagger_spaces_out_anthropic_tabs_only() {
761        let step = Duration::from_millis(800);
762        let tabs = vec![
763            TabId::vendor(VendorId::Anthropic), // default account
764            TabId::account("work"),
765            TabId::account("personal"),
766            TabId::vendor(VendorId::Openai),
767            TabId::vendor(VendorId::Zai),
768        ];
769        let delays = refresh_stagger(&tabs, step);
770        assert_eq!(
771            delays,
772            vec![
773                Duration::ZERO, // 1st anthropic — immediate
774                step,           // 2nd anthropic
775                step * 2,       // 3rd anthropic
776                Duration::ZERO, // openai — own endpoint, immediate
777                Duration::ZERO, // zai — own endpoint, immediate
778            ]
779        );
780    }
781
782    #[test]
783    fn refresh_stagger_is_a_noop_without_anthropic_accounts() {
784        // A single Anthropic tab (or none) never waits.
785        let tabs = vec![
786            TabId::vendor(VendorId::Anthropic),
787            TabId::vendor(VendorId::Openrouter),
788        ];
789        assert!(
790            refresh_stagger(&tabs, Duration::from_millis(800))
791                .iter()
792                .all(|d| d.is_zero())
793        );
794    }
795
796    #[test]
797    fn select_primary_moves_to_enabled_vendor() {
798        let mut app = App::with_theme(
799            vec![
800                TabId::vendor(VendorId::Anthropic),
801                TabId::vendor(VendorId::Openrouter),
802            ],
803            Theme::default(),
804        );
805        app.select_primary(Some(VendorId::Openrouter));
806        assert_eq!(app.active_vendor(), Some(VendorId::Openrouter));
807    }
808
809    #[test]
810    fn select_primary_ignores_disabled_vendor() {
811        let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
812        app.select_primary(Some(VendorId::Openai));
813        assert_eq!(app.active_vendor(), Some(VendorId::Anthropic));
814    }
815
816    #[test]
817    fn nav_ring_wraps_through_the_overview_at_both_ends() {
818        let mut app = App::with_theme(
819            vec![
820                TabId::vendor(VendorId::Anthropic),
821                TabId::vendor(VendorId::Openai),
822            ],
823            Theme::default(),
824        );
825        app.overview = true;
826
827        app.next_tab(); // Overview -> first vendor
828        assert!(!app.overview);
829        assert_eq!(app.active, 0);
830        app.next_tab();
831        assert_eq!(app.active, 1);
832        app.next_tab(); // last vendor -> Overview
833        assert!(app.overview);
834
835        app.prev_tab(); // Overview -> last vendor
836        assert!(!app.overview);
837        assert_eq!(app.active, 1);
838        app.prev_tab();
839        assert_eq!(app.active, 0);
840        app.prev_tab(); // first vendor -> Overview
841        assert!(app.overview);
842    }
843
844    #[test]
845    fn overview_tabs_defaults_to_all_and_honors_the_config_filter() {
846        let mut app = App::with_theme(
847            vec![
848                TabId::vendor(VendorId::Anthropic),
849                TabId::vendor(VendorId::Openai),
850                TabId::vendor(VendorId::Zai),
851            ],
852            Theme::default(),
853        );
854        assert_eq!(app.overview_tabs(), vec![0, 1, 2]);
855
856        // Subset in the given order.
857        app.overview_vendors = Some(vec![VendorId::Zai, VendorId::Anthropic]);
858        assert_eq!(app.overview_tabs(), vec![2, 0]);
859
860        // A listed-but-absent vendor is simply skipped.
861        app.overview_vendors = Some(vec![VendorId::Grok, VendorId::Openai]);
862        assert_eq!(app.overview_tabs(), vec![1]);
863    }
864
865    fn config_with_accounts(labels: &[&str]) -> Config {
866        let mut config = Config::default();
867        // Keep only Anthropic enabled so the test asserts on account expansion,
868        // not on the full default vendor set.
869        config.openai.enabled = false;
870        config.zai.enabled = false;
871        config.openrouter.enabled = false;
872        config.anthropic.accounts = labels
873            .iter()
874            .map(|l| crate::config::AnthropicAccount {
875                label: (*l).to_string(),
876                credentials_path: format!("/creds/{l}.json").into(),
877            })
878            .collect();
879        config
880    }
881
882    #[test]
883    fn show_default_account_false_hides_the_unnamed_claude_tab() {
884        // With named accounts and show_default_account=false, only the named
885        // tabs appear — no redundant default "Claude" tab.
886        let mut config = config_with_accounts(&["work", "personal"]);
887        config.anthropic.show_default_account = false;
888        assert_eq!(
889            tabs_from_config(&config),
890            vec![TabId::account("work"), TabId::account("personal")]
891        );
892
893        // But with no named accounts it is kept, so Anthropic never loses its
894        // only tab.
895        let mut empty = Config::default();
896        empty.openai.enabled = false;
897        empty.zai.enabled = false;
898        empty.openrouter.enabled = false;
899        empty.anthropic.show_default_account = false;
900        assert_eq!(
901            tabs_from_config(&empty),
902            vec![TabId::vendor(VendorId::Anthropic)]
903        );
904    }
905
906    #[test]
907    fn tabs_expand_anthropic_accounts_after_default() {
908        // Default Claude tab first, then each account in config order.
909        let tabs = tabs_from_config(&config_with_accounts(&["work", "personal"]));
910        assert_eq!(
911            tabs,
912            vec![
913                TabId::vendor(VendorId::Anthropic),
914                TabId::account("work"),
915                TabId::account("personal"),
916            ]
917        );
918    }
919
920    #[test]
921    fn tabs_without_accounts_are_just_enabled_vendors() {
922        // No [[anthropic.accounts]] → one tab per enabled vendor, unchanged.
923        let config = Config::default();
924        let tabs = tabs_from_config(&config);
925        let vendors: Vec<VendorId> = tabs.iter().map(|t| t.vendor).collect();
926        assert_eq!(vendors, config.enabled_vendors());
927        assert!(tabs.iter().all(|t| t.account.is_none()));
928    }
929
930    #[test]
931    fn tabs_include_accounts_auto_discovered_from_accounts_dir() {
932        // A CLAUDE_CONFIG_DIR-style directory becomes account tabs with no
933        // explicit [[anthropic.accounts]] entry. Hermetic: real TempDir.
934        let td = tempfile::tempdir().unwrap();
935        for label in ["work", "personal"] {
936            let dir = td.path().join(label);
937            std::fs::create_dir_all(&dir).unwrap();
938            std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
939        }
940        let mut config = Config::default();
941        config.openai.enabled = false;
942        config.zai.enabled = false;
943        config.openrouter.enabled = false;
944        config.anthropic.accounts_dir = Some(td.path().to_path_buf());
945
946        let tabs = tabs_from_config(&config);
947        assert_eq!(
948            tabs,
949            vec![
950                TabId::vendor(VendorId::Anthropic),
951                TabId::account("personal"), // sorted by label
952                TabId::account("work"),
953            ]
954        );
955    }
956
957    #[test]
958    fn desktop_labels_become_account_tabs_after_cli_accounts() {
959        // Pure core: desktop accounts follow CLI accounts, in the order given.
960        let config = config_with_accounts(&["work"]);
961        let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()], &[]);
962        assert_eq!(
963            tabs,
964            vec![
965                TabId::vendor(VendorId::Anthropic),
966                TabId::account("work"),
967                TabId::desktop_account("gmail"),
968                TabId::desktop_account("hotmail"),
969            ]
970        );
971    }
972
973    #[test]
974    fn a_cli_account_shadows_a_desktop_profile_of_the_same_label() {
975        // One tab per label; the explicitly configured CLI account wins.
976        let config = config_with_accounts(&["gmail"]);
977        let tabs = build_tabs(&config, &["gmail".into(), "hotmail".into()], &[]);
978        assert_eq!(
979            tabs,
980            vec![
981                TabId::vendor(VendorId::Anthropic),
982                TabId::account("gmail"),
983                TabId::desktop_account("hotmail"),
984            ]
985        );
986    }
987
988    #[test]
989    fn a_broken_cli_account_yields_its_label_to_the_desktop_profile() {
990        // The exact failure a half-finished `account add gmail` caused: a CLI
991        // account is configured but unusable, and a Desktop profile of the same
992        // name exists. Passed in `broken_cli`, the CLI account is dropped and the
993        // Desktop source surfaces instead of an error tab.
994        let config = config_with_accounts(&["gmail"]);
995        let tabs = build_tabs(&config, &["gmail".into()], &["gmail".into()]);
996        assert_eq!(
997            tabs,
998            vec![
999                TabId::vendor(VendorId::Anthropic),
1000                TabId::desktop_account("gmail"),
1001            ]
1002        );
1003    }
1004
1005    #[test]
1006    fn desktop_accounts_suppress_the_default_tab_like_named_ones() {
1007        // show_default_account=false + only Desktop accounts => no default tab,
1008        // exactly as if they were [[anthropic.accounts]] (the Desktop-only user).
1009        let mut config = config_with_accounts(&[]);
1010        config.cursor.enabled = false;
1011        config.anthropic.show_default_account = false;
1012
1013        // No accounts of either kind: the default tab survives (never leave
1014        // Anthropic tab-less).
1015        assert_eq!(
1016            build_tabs(&config, &[], &[]),
1017            vec![TabId::vendor(VendorId::Anthropic)]
1018        );
1019        // A Desktop account is present: default suppressed, only the account.
1020        assert_eq!(
1021            build_tabs(&config, &["gmail".into()], &[]),
1022            vec![TabId::desktop_account("gmail")]
1023        );
1024    }
1025
1026    #[test]
1027    fn set_tabs_resets_states_and_clamps_selection() {
1028        // Simulates a Settings save that shrank the tab list: the selection
1029        // must clamp into range and every tab must reset to Loading so the
1030        // caller's spawn_all repopulates against the new config.
1031        let mut app = App::with_theme(
1032            tabs_from_config(&config_with_accounts(&["work", "personal"])),
1033            Theme::default(),
1034        );
1035        app.active = 2; // "personal"
1036        app.tabs[0] = TabState::Error("old".into());
1037        let old_tab = app.tabs_meta[0].clone();
1038        assert!(app.begin_refresh(&old_tab));
1039
1040        app.set_tabs(tabs_from_config(&config_with_accounts(&[])));
1041        assert_eq!(app.tabs_meta, vec![TabId::vendor(VendorId::Anthropic)]);
1042        assert_eq!(app.active, 0, "selection clamped after shrink");
1043        assert!(matches!(app.tabs[0], TabState::Loading));
1044        assert!(!app.is_refreshing(&old_tab));
1045    }
1046
1047    #[test]
1048    fn set_tabs_preserves_selected_identity_when_entries_are_inserted() {
1049        let mut app = App::with_theme(
1050            vec![
1051                TabId::vendor(VendorId::Anthropic),
1052                TabId::vendor(VendorId::Openai),
1053            ],
1054            Theme::default(),
1055        );
1056        app.active = 1;
1057
1058        app.set_tabs(vec![
1059            TabId::vendor(VendorId::Anthropic),
1060            TabId::account("work"),
1061            TabId::vendor(VendorId::Openai),
1062        ]);
1063
1064        assert_eq!(app.active, 2);
1065        assert_eq!(app.active_tab_id(), Some(&TabId::vendor(VendorId::Openai)));
1066    }
1067
1068    #[test]
1069    fn refresh_from_old_generation_is_discarded() {
1070        let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1071        let old_generation = app.tab_generation;
1072        app.set_tabs(vec![TabId::vendor(VendorId::Openai)]);
1073
1074        assert!(!app.apply_refresh(
1075            old_generation,
1076            &TabId::vendor(VendorId::Anthropic),
1077            TabState::Error("old result".into()),
1078        ));
1079        assert!(matches!(app.tabs[0], TabState::Loading));
1080    }
1081
1082    #[test]
1083    fn refresh_identity_mismatch_is_discarded() {
1084        let mut app = App::with_theme(vec![TabId::vendor(VendorId::Anthropic)], Theme::default());
1085        let generation = app.tab_generation;
1086
1087        assert!(!app.apply_refresh(
1088            generation,
1089            &TabId::vendor(VendorId::Openai),
1090            TabState::Error("wrong tab".into()),
1091        ));
1092        assert!(matches!(app.tabs[0], TabState::Loading));
1093    }
1094
1095    #[test]
1096    fn refresh_identity_lands_at_new_index_after_same_generation_reorder() {
1097        let anthropic = TabId::vendor(VendorId::Anthropic);
1098        let openai = TabId::vendor(VendorId::Openai);
1099        let mut app = App::with_theme(vec![anthropic.clone(), openai.clone()], Theme::default());
1100        let generation = app.tab_generation;
1101        assert!(app.begin_refresh(&anthropic));
1102
1103        // A reorder is safe because delivery resolves the captured identity,
1104        // not a stale positional index.
1105        app.tabs_meta.swap(0, 1);
1106        app.tabs.swap(0, 1);
1107        assert!(app.apply_refresh(generation, &anthropic, TabState::Error("ready".into())));
1108        assert!(matches!(app.tabs[0], TabState::Loading));
1109        assert!(matches!(&app.tabs[1], TabState::Error(message) if message == "ready"));
1110        assert!(!app.is_refreshing(&anthropic));
1111    }
1112
1113    fn ready_at(fetched_at: chrono::DateTime<Utc>) -> TabState {
1114        TabState::Ready(Box::new(ReadyTab {
1115            snapshot: crate::usage::VendorSnapshot::Openrouter(crate::usage::OpenRouterSnapshot {
1116                label: "test".into(),
1117                total_credits: 0.0,
1118                total_usage: 0.0,
1119                usage_daily: 0.0,
1120                usage_weekly: 0.0,
1121                usage_monthly: 0.0,
1122                is_free_tier: false,
1123                limit: None,
1124                limit_remaining: None,
1125            }),
1126            stale: false,
1127            last_error: None,
1128            fetched_at: Some(fetched_at),
1129        }))
1130    }
1131
1132    #[test]
1133    fn refresh_keeps_ready_snapshot_visible_and_suppresses_duplicates() {
1134        let tab = TabId::vendor(VendorId::Openrouter);
1135        let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1136        let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1137        app.tabs[0] = ready_at(fetched_at);
1138
1139        assert!(app.begin_refresh(&tab));
1140        assert!(
1141            !app.begin_refresh(&tab),
1142            "duplicate request must be suppressed"
1143        );
1144        assert!(app.is_refreshing(&tab));
1145        match &app.tabs[0] {
1146            TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1147            other => panic!("ready snapshot disappeared during refresh: {other:?}"),
1148        }
1149    }
1150
1151    #[test]
1152    fn first_refresh_still_uses_loading_until_data_arrives() {
1153        let tab = TabId::vendor(VendorId::Openrouter);
1154        let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1155
1156        assert!(app.begin_refresh(&tab));
1157        assert!(app.is_refreshing(&tab));
1158        assert!(matches!(app.tabs[0], TabState::Loading));
1159
1160        assert!(app.apply_refresh(
1161            app.tab_generation,
1162            &tab,
1163            TabState::Error("not signed in".into()),
1164        ));
1165        assert!(!app.is_refreshing(&tab));
1166        assert!(matches!(&app.tabs[0], TabState::Error(message) if message == "not signed in"));
1167    }
1168
1169    #[test]
1170    fn successful_revalidation_replaces_snapshot_and_clears_indicator() {
1171        let tab = TabId::vendor(VendorId::Openrouter);
1172        let old_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1173        let new_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 1, 0).unwrap();
1174        let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1175        app.tabs[0] = ready_at(old_at);
1176
1177        assert!(app.begin_refresh(&tab));
1178        assert!(app.apply_refresh(app.tab_generation, &tab, ready_at(new_at)));
1179        assert!(!app.is_refreshing(&tab));
1180        match &app.tabs[0] {
1181            TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(new_at)),
1182            other => panic!("expected replacement snapshot, got {other:?}"),
1183        }
1184    }
1185
1186    #[test]
1187    fn failed_revalidation_preserves_snapshot_with_visible_warning() {
1188        let tab = TabId::vendor(VendorId::Openrouter);
1189        let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1190        let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1191        app.tabs[0] = ready_at(fetched_at);
1192
1193        assert!(app.begin_refresh(&tab));
1194        assert!(app.apply_refresh(
1195            app.tab_generation,
1196            &tab,
1197            TabState::Error("refresh failed".into()),
1198        ));
1199        assert!(!app.is_refreshing(&tab));
1200        match &app.tabs[0] {
1201            TabState::Ready(ready) => {
1202                assert_eq!(ready.fetched_at, Some(fetched_at));
1203                assert!(ready.stale);
1204                assert_eq!(ready.last_error, Some((0, "refresh failed".into())));
1205            }
1206            other => panic!("last successful snapshot was lost: {other:?}"),
1207        }
1208        let sections = crate::tui::panels::sections_for(&app.tabs[0], Utc::now(), 5);
1209        assert!(sections.iter().any(|section| matches!(
1210            section,
1211            crate::tui::panels::Section::Text { label, value }
1212                if label == "Warning" && value == "refresh failed"
1213        )));
1214    }
1215
1216    #[test]
1217    fn old_generation_result_does_not_clear_current_refresh() {
1218        let tab = TabId::vendor(VendorId::Openrouter);
1219        let mut app = App::with_theme(vec![tab.clone()], Theme::default());
1220        let old_generation = app.tab_generation;
1221        app.set_tabs(vec![tab.clone()]);
1222        assert!(app.begin_refresh(&tab));
1223
1224        assert!(!app.apply_refresh(old_generation, &tab, TabState::Error("old result".into()),));
1225        assert!(app.is_refreshing(&tab));
1226        assert!(matches!(app.tabs[0], TabState::Loading));
1227    }
1228
1229    #[test]
1230    fn apply_refresh_stamps_fetched_at_on_only_the_matching_tab() {
1231        // Pins the per-tab `fetched_at` the header now reads: a landed Anthropic
1232        // response leaves the still-loading OpenAI tab with no time of its own.
1233        // Dropping the global `last_refresh` clock is not observable from here
1234        // (it was write-only) — that is asserted against the rendered header in
1235        // `view::tests::header_refresh_*`.
1236        let anthropic = TabId::vendor(VendorId::Anthropic);
1237        let openai = TabId::vendor(VendorId::Openai);
1238        let mut app = App::with_theme(vec![anthropic.clone(), openai], Theme::default());
1239        let generation = app.tab_generation;
1240        let fetched_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
1241
1242        assert!(app.apply_refresh(generation, &anthropic, ready_at(fetched_at)));
1243        match &app.tabs[0] {
1244            TabState::Ready(ready) => assert_eq!(ready.fetched_at, Some(fetched_at)),
1245            other => panic!("expected Anthropic tab Ready, got {other:?}"),
1246        }
1247        assert!(matches!(app.tabs[1], TabState::Loading));
1248    }
1249
1250    #[test]
1251    fn select_primary_lands_on_default_account_tab() {
1252        // With account tabs present, `primary = anthropic` selects the default
1253        // Claude tab (index 0), not one of its account tabs.
1254        let app = {
1255            let tabs = tabs_from_config(&config_with_accounts(&["work"]));
1256            let mut a = App::with_theme(tabs, Theme::default());
1257            a.select_primary(Some(VendorId::Anthropic));
1258            a
1259        };
1260        assert_eq!(app.active, 0);
1261        assert_eq!(
1262            app.active_tab_id(),
1263            Some(&TabId::vendor(VendorId::Anthropic))
1264        );
1265    }
1266}